diff --git a/lib/api/server_provider.dart b/lib/api/server_provider.dart index a9b4267..3dc0cfb 100644 --- a/lib/api/server_provider.dart +++ b/lib/api/server_provider.dart @@ -2,9 +2,10 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:whispering_pages/api/authenticated_user_provider.dart'; -import 'package:whispering_pages/settings/models/audiobookshelf_server.dart' as model; -import 'package:whispering_pages/settings/api_settings_provider.dart'; import 'package:whispering_pages/db/storage.dart'; +import 'package:whispering_pages/settings/api_settings_provider.dart'; +import 'package:whispering_pages/settings/models/audiobookshelf_server.dart' + as model; part 'server_provider.g.dart'; diff --git a/lib/main.dart b/lib/main.dart index 9578083..17a9a4a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,13 +20,7 @@ void main() async { ); } -// final _router = MyAppRouter(needOnboarding: _needAuth()); - -// bool _needAuth() { -// final apiSettings = ApiSettings().readFromBoxOrCreate(); -// final servers = AudiobookShelfServer().readFromBoxOrCreate(); -// return apiSettings.activeUser == null || servers.isEmpty; -// } +var routerConfig = const MyAppRouter().config; class MyApp extends ConsumerWidget { const MyApp({super.key}); @@ -36,16 +30,12 @@ class MyApp extends ConsumerWidget { final servers = ref.watch(audiobookShelfServerProvider); final apiSettings = ref.watch(apiSettingsProvider); - bool needOnboarding() { - return apiSettings.activeUser == null || servers.isEmpty; + final needOnboarding = apiSettings.activeUser == null || servers.isEmpty; + + if (needOnboarding) { + routerConfig.goNamed(Routes.onboarding.name); } - var routerConfig = MyAppRouter(needOnboarding: needOnboarding()).config; - // if (needOnboarding()) { - // routerConfig.goNamed(Routes.onboarding); - // } - - return MaterialApp.router( theme: lightTheme, darkTheme: darkTheme, @@ -53,7 +43,6 @@ class MyApp extends ConsumerWidget { ? ThemeMode.dark : ThemeMode.light, routerConfig: routerConfig, - // routerConfig: _router.config, ); } } diff --git a/lib/pages/onboarding/onboarding_single_page.dart b/lib/pages/onboarding/onboarding_single_page.dart index 20c6ca1..775df13 100644 --- a/lib/pages/onboarding/onboarding_single_page.dart +++ b/lib/pages/onboarding/onboarding_single_page.dart @@ -30,7 +30,6 @@ class OnboardingSinglePage extends HookConsumerWidget { final usernameController = useTextEditingController(); final passwordController = useTextEditingController(); - // reverse the animation if the user is not logged in void addServer() { var newServer = serverUriController.text.isEmpty ? null diff --git a/lib/router/constants.dart b/lib/router/constants.dart index 170f4a2..33ab776 100644 --- a/lib/router/constants.dart +++ b/lib/router/constants.dart @@ -4,7 +4,10 @@ part of 'router.dart'; class Routes { static const home = 'home'; - static const onboarding = 'onboarding'; + static const onboarding = _SimpleRoute( + pathName: 'login', + name: 'onboarding', + ); static const library = _SimpleRoute( pathName: 'library', pathParamName: 'libraryId', @@ -15,6 +18,10 @@ class Routes { pathParamName: 'itemId', name: 'libraryItem', ); + static const settings = _SimpleRoute( + pathName: 'config', + name: 'settings', + ); } // a class to store path @@ -22,13 +29,14 @@ class Routes { class _SimpleRoute { const _SimpleRoute({ required this.pathName, - required this.pathParamName, + this.pathParamName, required this.name, }); final String pathName; - final String pathParamName; + final String? pathParamName; final String name; - String get path => '/$pathName/:$pathParamName'; + String get path => + '/$pathName${pathParamName != null ? '/:$pathParamName' : ''}'; } diff --git a/lib/router/router.dart b/lib/router/router.dart index 3fba78c..afd6c77 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,52 +1,100 @@ +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:whispering_pages/pages/app_settings.dart'; import 'package:whispering_pages/pages/home_page.dart'; import 'package:whispering_pages/pages/library_item_page.dart'; import 'package:whispering_pages/pages/library_page.dart'; import 'package:whispering_pages/pages/onboarding/onboarding_single_page.dart'; +import 'scaffold_with_nav_bar.dart'; +import 'transitions/slide.dart'; + part 'constants.dart'; +final GlobalKey _rootNavigatorKey = + GlobalKey(debugLabel: 'root'); +final GlobalKey _sectionANavigatorKey = + GlobalKey(debugLabel: 'sectionANav'); + // GoRouter configuration class MyAppRouter { - const MyAppRouter({required this.needOnboarding}); - final bool needOnboarding; - // some static strings for named routes + const MyAppRouter(); GoRouter get config => GoRouter( routes: [ + // sign in page GoRoute( - path: '/login', - name: Routes.onboarding, + path: Routes.onboarding.path, + name: Routes.onboarding.name, builder: (context, state) => const OnboardingSinglePage(), ), - GoRoute( - path: '/', - name: Routes.home, - builder: (context, state) => const HomePage(), - ), - // /library/:libraryId - GoRoute( - path: Routes.library.path, - name: Routes.library.name, - builder: (context, state) => LibraryPage( - libraryId: state.pathParameters[Routes.library.pathParamName]!, - ), - ), - GoRoute( - path: Routes.libraryItem.path, - name: Routes.libraryItem.name, - builder: (context, state) { - final itemId = - state.pathParameters[Routes.libraryItem.pathParamName]!; - return LibraryItemPage(itemId: itemId, extra: state.extra); + StatefulShellRoute.indexedStack( + builder: ( + BuildContext context, + GoRouterState state, + StatefulNavigationShell navigationShell, + ) { + // Return the widget that implements the custom shell (in this case + // using a BottomNavigationBar). The StatefulNavigationShell is passed + // to be able access the state of the shell and to navigate to other + // branches in a stateful way. + return ScaffoldWithNavBar(navigationShell: navigationShell); }, + branches: [ + // The route branch for the first tab of the bottom navigation bar. + StatefulShellBranch( + navigatorKey: _sectionANavigatorKey, + routes: [ + GoRoute( + path: '/', + name: Routes.home, + // builder: (context, state) => const HomePage(), + pageBuilder: defaultPageBuilder(const HomePage()), + ), + // /library/:libraryId + GoRoute( + path: Routes.library.path, + name: Routes.library.name, + builder: (context, state) => LibraryPage( + libraryId: + state.pathParameters[Routes.library.pathParamName]!, + ), + ), + GoRoute( + path: Routes.libraryItem.path, + name: Routes.libraryItem.name, + // builder: (context, state) { + // final itemId = state + // .pathParameters[Routes.libraryItem.pathParamName]!; + // return LibraryItemPage( + // itemId: itemId, extra: state.extra); + // }, + pageBuilder: (context, state) { + final itemId = state + .pathParameters[Routes.libraryItem.pathParamName]!; + final child = + LibraryItemPage(itemId: itemId, extra: state.extra); + return buildPageWithDefaultTransition( + context: context, + state: state, + child: child, + ); + }, + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: Routes.settings.path, + name: Routes.settings.name, + // builder: (context, state) => const AppSettingsPage(), + pageBuilder: defaultPageBuilder(const AppSettingsPage()), + ), + ], + ), + ], ), ], - redirect: (context, state) { - if (needOnboarding) { - return config.namedLocation(Routes.onboarding); - } - return null; - }, ); } diff --git a/lib/router/scaffold_with_nav_bar.dart b/lib/router/scaffold_with_nav_bar.dart new file mode 100644 index 0000000..c306e88 --- /dev/null +++ b/lib/router/scaffold_with_nav_bar.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Builds the "shell" for the app by building a Scaffold with a +/// BottomNavigationBar, where [child] is placed in the body of the Scaffold. +class ScaffoldWithNavBar extends StatelessWidget { + /// Constructs an [ScaffoldWithNavBar]. + const ScaffoldWithNavBar({ + required this.navigationShell, + Key? key, + }) : super(key: key ?? const ValueKey('ScaffoldWithNavBar')); + + /// The navigation shell and container for the branch Navigators. + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: navigationShell, + bottomNavigationBar: BottomNavigationBar( + elevation: 0.0, + landscapeLayout: BottomNavigationBarLandscapeLayout.centered, + selectedFontSize: Theme.of(context).textTheme.labelMedium!.fontSize!, + unselectedFontSize: Theme.of(context).textTheme.labelMedium!.fontSize!, + // fixedColor: Theme.of(context).colorScheme.primary, + // type: BottomNavigationBarType.fixed, + + // Here, the items of BottomNavigationBar are hard coded. In a real + // world scenario, the items would most likely be generated from the + // branches of the shell route, which can be fetched using + // `navigationShell.route.branches`. + items: const [ + BottomNavigationBarItem( + label: 'Home', + icon: Icon(Icons.home_outlined), + activeIcon: Icon(Icons.home), + ), + BottomNavigationBarItem( + label: 'Settings', + icon: Icon(Icons.settings_outlined), + activeIcon: Icon(Icons.settings), + ), + ], + currentIndex: navigationShell.currentIndex, + onTap: (int index) => _onTap(context, index), + ), + ); + } + + /// Navigate to the current location of the branch at the provided index when + /// tapping an item in the BottomNavigationBar. + void _onTap(BuildContext context, int index) { + // When navigating to a new branch, it's recommended to use the goBranch + // method, as doing so makes sure the last navigation state of the + // Navigator for the branch is restored. + navigationShell.goBranch( + index, + // A common pattern when using bottom navigation bars is to support + // navigating to the initial location when tapping the item that is + // already active. This example demonstrates how to support this behavior, + // using the initialLocation parameter of goBranch. + initialLocation: index == navigationShell.currentIndex, + ); + } +} diff --git a/lib/router/transitions/slide.dart b/lib/router/transitions/slide.dart new file mode 100644 index 0000000..adeddc4 --- /dev/null +++ b/lib/router/transitions/slide.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:go_router/go_router.dart'; + +// class CustomSlideTransition extends CustomTransitionPage { +// CustomSlideTransition({super.key, required super.child}) +// : super( +// transitionDuration: const Duration(milliseconds: 250), +// transitionsBuilder: (_, animation, __, child) { +// return SlideTransition( +// position: animation.drive( +// Tween( +// begin: const Offset(1.5, 0), +// end: Offset.zero, +// ).chain( +// CurveTween(curve: Curves.ease), +// ), +// ), +// child: child, +// ); +// }, +// ); +// } + +CustomTransitionPage buildPageWithDefaultTransition({ + required BuildContext context, + required GoRouterState state, + required Widget child, +}) { + return CustomTransitionPage( + key: state.pageKey, + transitionDuration: 250.ms, + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition( + opacity: animation, + child: SlideTransition( + position: animation.drive( + Tween( + begin: const Offset(0, 1.50), + end: Offset.zero, + ).chain( + CurveTween(curve: Curves.easeOut), + ), + ), + child: child, + ), + ), + ); +} + +Page Function(BuildContext, GoRouterState) defaultPageBuilder( + Widget child, +) => + (BuildContext context, GoRouterState state) { + return buildPageWithDefaultTransition( + context: context, + state: state, + child: child, + ); + }; diff --git a/lib/settings/api_settings_provider.dart b/lib/settings/api_settings_provider.dart index e2e1ff0..4b2e0f1 100644 --- a/lib/settings/api_settings_provider.dart +++ b/lib/settings/api_settings_provider.dart @@ -23,7 +23,14 @@ class ApiSettings extends _$ApiSettings { model.ApiSettings readFromBoxOrCreate() { // see if the settings are already in the box if (_box.isNotEmpty) { - final foundSettings = _box.getAt(0); + var foundSettings = _box.getAt(0); + // foundSettings.activeServer ??= foundSettings.activeUser?.server; + // foundSettings =foundSettings.copyWith(activeServer: foundSettings.activeUser?.server); + if (foundSettings.activeServer == null) { + foundSettings = foundSettings.copyWith( + activeServer: foundSettings.activeUser?.server, + ); + } debugPrint('found api settings in box: $foundSettings'); return foundSettings; } else { diff --git a/lib/widgets/drawer.dart b/lib/widgets/drawer.dart index 922aba6..0b2cfb2 100644 --- a/lib/widgets/drawer.dart +++ b/lib/widgets/drawer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:whispering_pages/pages/app_settings.dart'; +import 'package:go_router/go_router.dart'; import 'package:whispering_pages/pages/server_manager.dart'; +import 'package:whispering_pages/router/router.dart'; class MyDrawer extends StatelessWidget { @@ -35,11 +36,7 @@ class MyDrawer extends StatelessWidget { ListTile( title: const Text('App Settings'), onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const AppSettingsPage(), - ), - ); + context.goNamed(Routes.settings.name); }, ), ], diff --git a/lib/widgets/shelves/book_shelf.dart b/lib/widgets/shelves/book_shelf.dart index ed26814..2e6beea 100644 --- a/lib/widgets/shelves/book_shelf.dart +++ b/lib/widgets/shelves/book_shelf.dart @@ -72,50 +72,59 @@ class BookOnShelf extends HookConsumerWidget { // the cover image of the book // take up remaining space Expanded( - child: InkWell( - onTap: () { - // open the book - context.pushNamed( - Routes.libraryItem.name, - pathParameters: { - Routes.libraryItem.pathParamName: item.id, - }, - extra: LibraryItemExtras( - book: book, - heroTagSuffix: heroTagSuffix, - coverImage: coverImage.valueOrNull, - ), - ); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: coverImage.when( - data: (image) { - // return const BookCoverSkeleton(); - if (image.isEmpty) { - return const Icon(Icons.error); - } - // cover 80% of parent height - return Hero( - tag: HeroTagPrefixes.bookCover + - item.id + - heroTagSuffix, - child: Image.memory( + child: Center( + child: InkWell( + onTap: () { + // open the book + context.pushNamed( + Routes.libraryItem.name, + pathParameters: { + Routes.libraryItem.pathParamName!: item.id, + }, + extra: LibraryItemExtras( + book: book, + heroTagSuffix: heroTagSuffix, + coverImage: coverImage.valueOrNull, + ), + ); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: coverImage.when( + data: (image) { + // return const BookCoverSkeleton(); + if (image.isEmpty) { + return const Icon(Icons.error); + } + var imageWidget = Image.memory( image, - fit: BoxFit.cover, + fit: BoxFit.fill, cacheWidth: (height * - 1.2 * + 1.2 * MediaQuery.of(context).devicePixelRatio) .round(), - ), - ); - }, - loading: () { - return const Center(child: BookCoverSkeleton()); - }, - error: (error, stack) { - return const Icon(Icons.error); - }, + ); + return Hero( + tag: HeroTagPrefixes.bookCover + + item.id + + heroTagSuffix, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + child: imageWidget, + ), + ); + }, + loading: () { + return const Center(child: BookCoverSkeleton()); + }, + error: (error, stack) { + return const Icon(Icons.error); + }, + ), ), ), ),