diff --git a/lib/main.dart b/lib/main.dart index a9884f4..7f0dc1d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,13 +14,13 @@ import 'package:anitrack/src/ui/pages/anime_search.dart'; import 'package:anitrack/src/ui/pages/calendar.dart'; import 'package:anitrack/src/ui/pages/details/details.dart'; import 'package:anitrack/src/ui/pages/settings.dart'; +import 'package:anitrack/src/ui/widgets/shell_wrapper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; void main() async { - final navKey = GlobalKey(); - // Initialize the widgets binding for sqflite WidgetsFlutterBinding.ensureInitialized(); @@ -33,7 +33,7 @@ void main() async { GetIt.I.registerSingleton(AnimeListBloc()); GetIt.I.registerSingleton(AnimeSearchBloc()); GetIt.I.registerSingleton(DetailsBloc()); - GetIt.I.registerSingleton(NavigationBloc(navKey)); + GetIt.I.registerSingleton(NavigationBloc()); GetIt.I.registerSingleton(SettingsBloc()); GetIt.I.registerSingleton(CalendarBloc()); GetIt.I.registerSingleton(AniListClient()); @@ -68,53 +68,81 @@ void main() async { create: (_) => GetIt.I.get(), ), ], - child: MyApp(navKey), + child: MyApp(), ), ); } class MyApp extends StatelessWidget { - const MyApp( - this.navKey, { - super.key, - }); + MyApp({super.key}); - final GlobalKey navKey; + /// The router to attach to the main app. + final _router = GoRouter( + initialLocation: animeListRoute, + debugLogDiagnostics: true, + routes: [ + ShellRoute( + builder: (context, state, child) { + return ShellWrapper(state: state, child: child); + }, + routes: [ + GoRoute( + path: animeListRoute, + builder: (context, state) => const AnimeListPage(), + ), + GoRoute( + path: calendarRoute, + builder: (context, state) => const CalendarPage(), + ), + ], + ), + GoRoute( + path: animeSearchRoute, + builder: (context, state) => const AnimeSearchPage(), + ), + GoRoute( + path: detailsRoute, + builder: (context, state) => const DetailsPage(), + ), + GoRoute( + path: aboutRoute, + builder: (context, state) => const AboutPage(), + ), + GoRoute( + path: settingsRoute, + builder: (context, state) => const SettingsPage(), + ), + ], + ); @override Widget build(BuildContext context) { - return MaterialApp( - title: 'AniTrack', - theme: ThemeData( - brightness: Brightness.light, - primarySwatch: Colors.blue, - useMaterial3: true, - ), - darkTheme: ThemeData( - brightness: Brightness.dark, - primarySwatch: Colors.blue, - useMaterial3: true, - ), - navigatorKey: navKey, - onGenerateRoute: (settings) { - switch (settings.name) { - case '/': - case animeListRoute: - return AnimeListPage.route; - case animeSearchRoute: - return AnimeSearchPage.route; - case calendarRoute: - return CalendarPage.route; - case detailsRoute: - return DetailsPage.route; - case aboutRoute: - return AboutPage.route; - case settingsRoute: - return SettingsPage.route; + return BlocListener( + listener: (context, state) { + if (state is NoopNavigationState) { + // NOOP + } else if (state is PushNavigationState) { + _router.go(state.destination); + } else if (state is GoNavigationState) { + _router.push(state.destination); + } else if (state is PoppedNavigationState) { + _router.pop(); } - - return null; }, + child: MaterialApp.router( + title: 'AniTrack', + theme: ThemeData( + brightness: Brightness.light, + primarySwatch: Colors.blue, + useMaterial3: true, + ), + darkTheme: ThemeData( + brightness: Brightness.dark, + primarySwatch: Colors.blue, + useMaterial3: true, + ), + routerConfig: _router, + ), ); } } diff --git a/lib/src/ui/bloc/anime_list_bloc.dart b/lib/src/ui/bloc/anime_list_bloc.dart index 69ae158..7079ab3 100644 --- a/lib/src/ui/bloc/anime_list_bloc.dart +++ b/lib/src/ui/bloc/anime_list_bloc.dart @@ -6,6 +6,7 @@ import 'package:anitrack/src/ui/bloc/navigation_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; import 'package:bloc/bloc.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:get_it/get_it.dart'; @@ -41,6 +42,9 @@ class AnimeListBloc extends Bloc { growable: true, ); + /// Controller for the bottom navigation bar + final pageController = PageController(); + List get unfilteredAnime => _animes; List _getFilteredAnime({ @@ -372,10 +376,7 @@ class AnimeListBloc extends Bloc { ), ); GetIt.I.get().add( - PushedNamedAndRemoveUntilEvent( - const NavigationDestination(animeListRoute), - (_) => false, - ), + GoNavigationEvent(animeListRoute), ); } } diff --git a/lib/src/ui/bloc/anime_search_bloc.dart b/lib/src/ui/bloc/anime_search_bloc.dart index 733540b..ed07490 100644 --- a/lib/src/ui/bloc/anime_search_bloc.dart +++ b/lib/src/ui/bloc/anime_search_bloc.dart @@ -5,14 +5,12 @@ import 'package:anitrack/src/data/search_result.dart'; import 'package:anitrack/src/data/source.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/service/anilist/anilist_client.dart'; -import 'package:anitrack/src/service/anilist/model.dart'; import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart' as list; import 'package:anitrack/src/ui/bloc/navigation_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:get_it/get_it.dart'; -import 'package:jikan_api/jikan_api.dart'; part 'anime_search_state.dart'; part 'anime_search_event.dart'; @@ -43,9 +41,7 @@ class AnimeSearchBloc extends Bloc { ); GetIt.I.get().add( - PushedNamedEvent( - const NavigationDestination(animeSearchRoute), - ), + PushNavigationEvent(animeSearchRoute), ); } @@ -166,7 +162,7 @@ class AnimeSearchBloc extends Bloc { ); GetIt.I.get().add( - PoppedRouteEvent(), + GoNavigationEvent(animeListRoute), ); } } diff --git a/lib/src/ui/bloc/details_bloc.dart b/lib/src/ui/bloc/details_bloc.dart index 944c9b8..2fc99a7 100644 --- a/lib/src/ui/bloc/details_bloc.dart +++ b/lib/src/ui/bloc/details_bloc.dart @@ -44,9 +44,7 @@ class DetailsBloc extends Bloc { ); GetIt.I.get().add( - PushedNamedEvent( - const NavigationDestination(detailsRoute), - ), + GoNavigationEvent(detailsRoute), ); } @@ -63,9 +61,7 @@ class DetailsBloc extends Bloc { ); GetIt.I.get().add( - PushedNamedEvent( - const NavigationDestination(detailsRoute), - ), + PushNavigationEvent(detailsRoute), ); } @@ -126,7 +122,7 @@ class DetailsBloc extends Bloc { } // Navigate back - GetIt.I.get().add(PoppedRouteEvent()); + GetIt.I.get().add(PopNavigationEvent()); } Future _onAnimeWatcherAdded( diff --git a/lib/src/ui/bloc/navigation_bloc.dart b/lib/src/ui/bloc/navigation_bloc.dart index cb31bc4..b1b5833 100644 --- a/lib/src/ui/bloc/navigation_bloc.dart +++ b/lib/src/ui/bloc/navigation_bloc.dart @@ -1,57 +1,39 @@ +import 'dart:async'; + import 'package:bloc/bloc.dart'; -import 'package:flutter/material.dart'; part 'navigation_event.dart'; part 'navigation_state.dart'; class NavigationBloc extends Bloc { - NavigationBloc(this.navigationKey) : super(NavigationState()) { - on(_onPushedNamed); - on(_onPushedNamedAndRemoveUntil); - on(_onPushedNamedReplaceEvent); - on(_onPoppedRoute); + NavigationBloc() : super(NoopNavigationState()) { + on(_onGoEvent); + on(_onPushEvent); + on(_onPopEvent); } - final GlobalKey navigationKey; - Future _onPushedNamed( - PushedNamedEvent event, + Future _onGoEvent( + GoNavigationEvent event, Emitter emit, ) async { - await navigationKey.currentState!.pushNamed( - event.destination.path, - arguments: event.destination.arguments, + emit( + GoNavigationState(event.destination), ); } - Future _onPushedNamedAndRemoveUntil( - PushedNamedAndRemoveUntilEvent event, + Future _onPushEvent( + PushNavigationEvent event, Emitter emit, ) async { - await navigationKey.currentState!.pushNamedAndRemoveUntil( - event.destination.path, - event.predicate, - arguments: event.destination.arguments, + emit( + PushNavigationState(event.destination), ); } - Future _onPushedNamedReplaceEvent( - PushedNamedReplaceEvent event, + Future _onPopEvent( + PopNavigationEvent event, Emitter emit, ) async { - await navigationKey.currentState!.pushReplacementNamed( - event.destination.path, - arguments: event.destination.arguments, - ); - } - - Future _onPoppedRoute( - PoppedRouteEvent event, - Emitter emit, - ) async { - navigationKey.currentState!.pop(); - } - - bool canPop() { - return navigationKey.currentState!.canPop(); + emit(PoppedNavigationState()); } } diff --git a/lib/src/ui/bloc/navigation_event.dart b/lib/src/ui/bloc/navigation_event.dart index 9426ff8..e494c72 100644 --- a/lib/src/ui/bloc/navigation_event.dart +++ b/lib/src/ui/bloc/navigation_event.dart @@ -11,20 +11,14 @@ class NavigationDestination { abstract class NavigationEvent {} -class PushedNamedEvent extends NavigationEvent { - PushedNamedEvent(this.destination); - final NavigationDestination destination; +class GoNavigationEvent extends NavigationEvent { + GoNavigationEvent(this.destination); + final String destination; } -class PushedNamedAndRemoveUntilEvent extends NavigationEvent { - PushedNamedAndRemoveUntilEvent(this.destination, this.predicate); - final NavigationDestination destination; - final RoutePredicate predicate; +class PushNavigationEvent extends NavigationEvent { + PushNavigationEvent(this.destination); + final String destination; } -class PushedNamedReplaceEvent extends NavigationEvent { - PushedNamedReplaceEvent(this.destination); - final NavigationDestination destination; -} - -class PoppedRouteEvent extends NavigationEvent {} +class PopNavigationEvent extends NavigationEvent {} diff --git a/lib/src/ui/bloc/navigation_state.dart b/lib/src/ui/bloc/navigation_state.dart index e42a8a8..7624f9e 100644 --- a/lib/src/ui/bloc/navigation_state.dart +++ b/lib/src/ui/bloc/navigation_state.dart @@ -1,3 +1,19 @@ part of 'navigation_bloc.dart'; -class NavigationState {} +abstract class NavigationState {} + +class NoopNavigationState extends NavigationState {} + +class PoppedNavigationState extends NavigationState {} + +class PushNavigationState extends NavigationState { + PushNavigationState(this.destination); + + final String destination; +} + +class GoNavigationState extends NavigationState { + GoNavigationState(this.destination); + + final String destination; +} diff --git a/lib/src/ui/helpers.dart b/lib/src/ui/helpers.dart index d7703c9..a87a2a0 100644 --- a/lib/src/ui/helpers.dart +++ b/lib/src/ui/helpers.dart @@ -3,6 +3,7 @@ import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; Widget getDrawer(BuildContext context) { return Drawer( @@ -24,33 +25,30 @@ Widget getDrawer(BuildContext context) { leading: const Icon(Icons.list), title: Text(t.content.list), onTap: () { - GetIt.I.get().add( - AnimeListRequestedEvent(), - ); + GoRouter.of(context).go(animeListRoute); + Navigator.of(context).pop(); }, ), ListTile( leading: const Icon(Icons.calendar_today), title: Text(t.calendar.calendar), onTap: () { - Navigator.of(context).pushNamedAndRemoveUntil( - calendarRoute, - (_) => false, - ); + GoRouter.of(context).go(calendarRoute); + Navigator.of(context).pop(); }, ), ListTile( leading: const Icon(Icons.settings), title: Text(t.settings.title), onTap: () { - Navigator.of(context).pushNamed(settingsRoute); + GoRouter.of(context).push(settingsRoute); }, ), ListTile( leading: const Icon(Icons.info), title: Text(t.about.title), onTap: () { - Navigator.of(context).pushNamed(aboutRoute); + GoRouter.of(context).push(aboutRoute); }, ), ], diff --git a/lib/src/ui/pages/about.dart b/lib/src/ui/pages/about.dart index 55bacc4..a84d931 100644 --- a/lib/src/ui/pages/about.dart +++ b/lib/src/ui/pages/about.dart @@ -3,6 +3,7 @@ import 'package:anitrack/licenses.g.dart'; import 'package:anitrack/src/ui/constants.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; class AboutPage extends StatelessWidget { @@ -22,6 +23,12 @@ class AboutPage extends StatelessWidget { return Scaffold( appBar: AppBar( title: Text(t.about.title), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + GoRouter.of(context).pop(); + }, + ), ), body: ListView.builder( itemCount: ossLicenses.length + 1, diff --git a/lib/src/ui/pages/anime_list.dart b/lib/src/ui/pages/anime_list.dart index ee7395b..ce5af6b 100644 --- a/lib/src/ui/pages/anime_list.dart +++ b/lib/src/ui/pages/anime_list.dart @@ -4,13 +4,13 @@ import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart'; import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart'; import 'package:anitrack/src/ui/bloc/details_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; -import 'package:anitrack/src/ui/helpers.dart'; import 'package:anitrack/src/ui/widgets/grid_item.dart'; import 'package:anitrack/src/ui/widgets/image.dart'; import 'package:bottom_bar/bottom_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; class AnimeListPage extends StatefulWidget { const AnimeListPage({ @@ -24,6 +24,147 @@ class AnimeListPage extends StatefulWidget { ), ); + static String getPageTitle(TrackingMediumType type) { + switch (type) { + case TrackingMediumType.anime: + return t.content.anime; + case TrackingMediumType.manga: + return t.content.manga; + } + } + + static List> getPopupButtonItems( + TrackingMediumType type, + ) { + return [ + PopupMenuItem( + value: MediumTrackingState.ongoing, + child: Text(MediumTrackingState.ongoing.getName(type)), + ), + PopupMenuItem( + value: MediumTrackingState.completed, + child: Text(MediumTrackingState.completed.getName(type)), + ), + PopupMenuItem( + value: MediumTrackingState.planned, + child: Text(MediumTrackingState.planned.getName(type)), + ), + PopupMenuItem( + value: MediumTrackingState.dropped, + child: Text(MediumTrackingState.dropped.getName(type)), + ), + PopupMenuItem( + value: MediumTrackingState.paused, + child: Text(MediumTrackingState.paused.getName(type)), + ), + const PopupMenuItem( + value: MediumTrackingState.all, + child: Text('All'), + ), + ]; + } + + static Widget getPopupButton(BuildContext context, AnimeListState state) { + switch (state.trackingType) { + case TrackingMediumType.anime: + return PopupMenuButton( + icon: const Icon( + Icons.filter_list, + ), + initialValue: state.animeFilterState, + onSelected: (filterState) { + context.read().add( + AnimeFilterChangedEvent(filterState), + ); + }, + itemBuilder: (_) => + AnimeListPage.getPopupButtonItems(TrackingMediumType.anime), + ); + case TrackingMediumType.manga: + return PopupMenuButton( + icon: const Icon( + Icons.filter_list, + ), + initialValue: state.mangaFilterState, + onSelected: (filterState) { + context.read().add( + MangaFilterChangedEvent(filterState), + ); + }, + itemBuilder: (_) => + AnimeListPage.getPopupButtonItems(TrackingMediumType.manga), + ); + } + } + + static AppBar buildAppBar(BuildContext context) { + return AppBar( + title: BlocBuilder( + builder: (context, state) => Text( + AnimeListPage.getPageTitle(state.trackingType), + ), + ), + actions: [ + BlocBuilder( + builder: (context, state) => + AnimeListPage.getPopupButton(context, state), + ), + ], + ); + } + + static Widget buildBottomNavigationBar(BuildContext context) { + final pageController = context.read().pageController; + return BlocBuilder( + builder: (context, state) => BottomBar( + selectedIndex: state.trackingType == TrackingMediumType.anime ? 0 : 1, + onTap: (index) { + context.read().add( + AnimeTrackingTypeChanged( + index == 0 ? TrackingMediumType.anime : TrackingMediumType.manga, + ), + ); + + pageController.jumpToPage(index); + }, + items: [ + BottomBarItem( + icon: const Icon(Icons.tv), + title: Text(t.content.anime), + activeColor: Colors.blue, + ), + BottomBarItem( + icon: const Icon(Icons.auto_stories), + title: Text(t.content.manga), + activeColor: Colors.red, + ), + ], + ), + ); + } + + static Widget buildFab() { + return BlocBuilder( + buildWhen: (prev, next) => + prev.buttonVisibility != next.buttonVisibility || + prev.trackingType != next.trackingType, + builder: (context, state) { + return AnimatedScale( + duration: const Duration(milliseconds: 250), + scale: state.buttonVisibility ? 1 : 0, + curve: Curves.easeInOutQuint, + child: FloatingActionButton( + onPressed: () { + context.push(animeSearchRoute); + }, + tooltip: t.tooltips.addNewItem, + child: const Icon(Icons.add), + ), + ); + }, + ); + } + @override AnimeListPageState createState() => AnimeListPageState(); } @@ -57,255 +198,124 @@ class AnimeListPageState extends State { } } - String _getPageTitle(TrackingMediumType type) { - switch (type) { - case TrackingMediumType.anime: - return t.content.anime; - case TrackingMediumType.manga: - return t.content.manga; - } - } - - List> _getPopupButtonItems( - TrackingMediumType type, - ) { - return [ - PopupMenuItem( - value: MediumTrackingState.ongoing, - child: Text(MediumTrackingState.ongoing.getName(type)), - ), - PopupMenuItem( - value: MediumTrackingState.completed, - child: Text(MediumTrackingState.completed.getName(type)), - ), - PopupMenuItem( - value: MediumTrackingState.planned, - child: Text(MediumTrackingState.planned.getName(type)), - ), - PopupMenuItem( - value: MediumTrackingState.dropped, - child: Text(MediumTrackingState.dropped.getName(type)), - ), - PopupMenuItem( - value: MediumTrackingState.paused, - child: Text(MediumTrackingState.paused.getName(type)), - ), - const PopupMenuItem( - value: MediumTrackingState.all, - child: Text('All'), - ), - ]; - } - - Widget _getPopupButton(BuildContext context, AnimeListState state) { - switch (state.trackingType) { - case TrackingMediumType.anime: - return PopupMenuButton( - icon: const Icon( - Icons.filter_list, - ), - initialValue: state.animeFilterState, - onSelected: (filterState) { - context.read().add( - AnimeFilterChangedEvent(filterState), - ); - }, - itemBuilder: (_) => _getPopupButtonItems(TrackingMediumType.anime), - ); - case TrackingMediumType.manga: - return PopupMenuButton( - icon: const Icon( - Icons.filter_list, - ), - initialValue: state.mangaFilterState, - onSelected: (filterState) { - context.read().add( - MangaFilterChangedEvent(filterState), - ); - }, - itemBuilder: (_) => _getPopupButtonItems(TrackingMediumType.manga), - ); - } - } - @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - return Scaffold( - appBar: AppBar( - title: Text( - _getPageTitle(state.trackingType), + return PageView( + // Prevent swiping between pages + // (https://github.com/flutter/flutter/issues/37510#issuecomment-612663656) + physics: const NeverScrollableScrollPhysics(), + controller: _controller, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 120 / (100 * (16 / 9)), + ), + itemCount: state.animes.length, + controller: _animeScrollController, + itemBuilder: (context, index) { + final anime = state.animes[index]; + return GridItem( + minusCallback: () { + context.read().add( + AnimeEpisodeDecrementedEvent( + anime.id, + ), + ); + }, + plusCallback: () { + context.read().add( + AnimeEpisodeIncrementedEvent( + anime.id, + ), + ); + }, + child: AnimeCoverImage( + url: anime.thumbnailUrl, + hero: 'grid_${anime.id}', + onTap: () { + context.read().add( + AnimeDetailsRequestedEvent( + anime, + heroImagePrefix: 'grid_', + ), + ); + }, + extra: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + '${anime.episodesWatched}/${anime.episodesTotal ?? "???"}', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ), + ), + ); + }, + ), ), - actions: [ - _getPopupButton(context, state), - ], - ), - drawer: getDrawer(context), - body: PageView( - // Prevent swiping between pages - // (https://github.com/flutter/flutter/issues/37510#issuecomment-612663656) - physics: const NeverScrollableScrollPhysics(), - controller: _controller, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 120 / (100 * (16 / 9)), - ), - itemCount: state.animes.length, - controller: _animeScrollController, - itemBuilder: (context, index) { - final anime = state.animes[index]; - return GridItem( - minusCallback: () { - context.read().add( - AnimeEpisodeDecrementedEvent( - anime.id, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 120 / (100 * (16 / 9)), + ), + itemCount: state.mangas.length, + itemBuilder: (context, index) { + final manga = state.mangas[index]; + return GridItem( + minusCallback: () { + context.read().add( + MangaChapterDecrementedEvent( + manga.id, + ), + ); + }, + plusCallback: () { + context.read().add( + MangaChapterIncrementedEvent( + manga.id, + ), + ); + }, + child: AnimeCoverImage( + hero: 'grid_${manga.id}', + url: manga.thumbnailUrl, + onTap: () { + context.read().add( + MangaDetailsRequestedEvent( + manga, + heroImagePrefix: 'grid_', ), ); }, - plusCallback: () { - context.read().add( - AnimeEpisodeIncrementedEvent( - anime.id, - ), - ); - }, - child: AnimeCoverImage( - url: anime.thumbnailUrl, - hero: 'grid_${anime.id}', - onTap: () { - context.read().add( - AnimeDetailsRequestedEvent( - anime, - heroImagePrefix: 'grid_', - ), - ); - }, - extra: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: Text( - '${anime.episodesWatched}/${anime.episodesTotal ?? "???"}', - style: Theme.of(context).textTheme.titleMedium, - ), + extra: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + '${manga.chaptersRead}/${manga.chaptersTotal ?? "???"}', + style: Theme.of(context).textTheme.titleMedium, ), ), ), - ); - }, - ), + ), + ); + }, ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 120 / (100 * (16 / 9)), - ), - itemCount: state.mangas.length, - itemBuilder: (context, index) { - final manga = state.mangas[index]; - return GridItem( - minusCallback: () { - context.read().add( - MangaChapterDecrementedEvent( - manga.id, - ), - ); - }, - plusCallback: () { - context.read().add( - MangaChapterIncrementedEvent( - manga.id, - ), - ); - }, - child: AnimeCoverImage( - hero: 'grid_${manga.id}', - url: manga.thumbnailUrl, - onTap: () { - context.read().add( - MangaDetailsRequestedEvent( - manga, - heroImagePrefix: 'grid_', - ), - ); - }, - extra: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: Text( - '${manga.chaptersRead}/${manga.chaptersTotal ?? "???"}', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ), - ), - ); - }, - ), - ), - ], - ), - floatingActionButton: BlocBuilder( - buildWhen: (prev, next) => - prev.buttonVisibility != next.buttonVisibility || - prev.trackingType != next.trackingType, - builder: (context, state) { - return AnimatedScale( - duration: const Duration(milliseconds: 250), - scale: state.buttonVisibility ? 1 : 0, - curve: Curves.easeInOutQuint, - child: FloatingActionButton( - onPressed: () { - context.read().add( - AnimeSearchRequestedEvent(state.trackingType), - ); - }, - tooltip: t.tooltips.addNewItem, - child: const Icon(Icons.add), - ), - ); - }, - ), - bottomNavigationBar: BottomBar( - selectedIndex: state.trackingType == TrackingMediumType.anime - ? 0 - : 1, - onTap: (int index) { - context.read().add( - AnimeTrackingTypeChanged( - index == 0 - ? TrackingMediumType.anime - : TrackingMediumType.manga, - ), - ); - - _controller.jumpToPage(index); - }, - items: [ - BottomBarItem( - icon: const Icon(Icons.tv), - title: Text(t.content.anime), - activeColor: Colors.blue, - ), - BottomBarItem( - icon: const Icon(Icons.auto_stories), - title: Text(t.content.manga), - activeColor: Colors.red, - ), - ], - ), + ), + ], ); }, ); diff --git a/lib/src/ui/pages/anime_search.dart b/lib/src/ui/pages/anime_search.dart index d1e9fde..6246808 100644 --- a/lib/src/ui/pages/anime_search.dart +++ b/lib/src/ui/pages/anime_search.dart @@ -20,81 +20,84 @@ class AnimeSearchPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Scaffold( - appBar: AppBar( - title: Text( - state.trackingType == TrackingMediumType.anime - ? t.search.anime - : t.search.manga, - ), - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextField( - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: t.search.query, - ), - onSubmitted: (_) { - context.read().add( - SearchQuerySubmittedEvent(), - ); - }, - onChanged: (value) { - context.read().add( - SearchQueryChangedEvent(value), - ); - }, - ), + return BlocListener( + listener: (context, state) {}, + child: BlocBuilder( + builder: (context, state) { + return Scaffold( + appBar: AppBar( + title: Text( + state.trackingType == TrackingMediumType.anime + ? t.search.anime + : t.search.manga, ), - if (state.working) - const Expanded( - child: Align( - child: CircularProgressIndicator(), - ), - ) - else - Expanded( - child: ListView.builder( - itemCount: state.searchResults.length, - itemBuilder: (context, index) { - final item = state.searchResults[index]; - return InkWell( - onTap: () { - context.read().add( - ResultTappedEvent(item), - ); - }, - child: ListItem( - title: item.title, - thumbnailUrl: item.thumbnailUrl, - cached: false, - extra: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - item.description, - textAlign: TextAlign.justify, - style: Theme.of(context).textTheme.bodyMedium, - maxLines: 4, - softWrap: true, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: t.search.query, + ), + onSubmitted: (_) { + context.read().add( + SearchQuerySubmittedEvent(), + ); + }, + onChanged: (value) { + context.read().add( + SearchQueryChangedEvent(value), ); }, ), ), - ], - ), - ); - }, + if (state.working) + const Expanded( + child: Align( + child: CircularProgressIndicator(), + ), + ) + else + Expanded( + child: ListView.builder( + itemCount: state.searchResults.length, + itemBuilder: (context, index) { + final item = state.searchResults[index]; + return InkWell( + onTap: () { + context.read().add( + ResultTappedEvent(item), + ); + }, + child: ListItem( + title: item.title, + thumbnailUrl: item.thumbnailUrl, + cached: false, + extra: [ + Align( + alignment: Alignment.centerLeft, + child: Text( + item.description, + textAlign: TextAlign.justify, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 4, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }, + ), ); } } diff --git a/lib/src/ui/pages/calendar.dart b/lib/src/ui/pages/calendar.dart index fb3e06a..6d1cf81 100644 --- a/lib/src/ui/pages/calendar.dart +++ b/lib/src/ui/pages/calendar.dart @@ -63,6 +63,22 @@ class CalendarPage extends StatefulWidget { ), ); + static AppBar buildAppBar(BuildContext context) { + return AppBar( + title: Text(t.calendar.calendar), + actions: [ + IconButton( + onPressed: () { + context.read().add( + RefreshPerformedEvent(), + ); + }, + icon: const Icon(Icons.refresh), + ), + ], + ); + } + @override CalendarPageState createState() => CalendarPageState(); } @@ -131,28 +147,20 @@ class CalendarPageState extends State { switch (anime.broadcastDay) { case 'Mondays': day = Weekday.monday; - break; case 'Tuesdays': day = Weekday.tuesday; - break; case 'Wednesdays': day = Weekday.wednesday; - break; case 'Thursdays': day = Weekday.thursday; - break; case 'Fridays': day = Weekday.friday; - break; case 'Saturdays': day = Weekday.saturday; - break; case 'Sundays': day = Weekday.sunday; - break; default: day = Weekday.unknown; - break; } airingAnimeMap.addOrSet(day, anime); @@ -184,79 +192,63 @@ class CalendarPageState extends State { right: 0, top: 0, bottom: 0, - child: Scaffold( - appBar: AppBar( - title: Text(t.calendar.calendar), - actions: [ - IconButton( - onPressed: () { - context.read().add( - RefreshPerformedEvent(), - ); - }, - icon: const Icon(Icons.refresh), + child: Padding( + padding: const EdgeInsetsGeometry.symmetric( + horizontal: 12, + ), + child: CustomScrollView( + slivers: [ + // Render all available weekdays + ..._renderWeekdayList( + context, + Weekday.unknown, + airingAnimeMap, + ), + ..._renderWeekdayList( + context, + Weekday.monday, + airingAnimeMap, + ), + ..._renderWeekdayList( + context, + Weekday.tuesday, + airingAnimeMap, + ), + ..._renderWeekdayList( + context, + Weekday.wednesday, + airingAnimeMap, + ), + ..._renderWeekdayList( + context, + Weekday.thursday, + airingAnimeMap, + ), + ..._renderWeekdayList( + context, + Weekday.friday, + airingAnimeMap, + ), + ..._renderWeekdayList( + context, + Weekday.saturday, + airingAnimeMap, + ), + ..._renderWeekdayList( + context, + Weekday.sunday, + airingAnimeMap, + ), + + // Provide a nice bottom padding, while keeping the elastic effect attached + // to the bottom-most edge. + const SliverToBoxAdapter( + child: SizedBox( + height: 16, + ), ), ], ), - drawer: getDrawer(context), - body: Padding( - padding: const EdgeInsetsGeometry.symmetric( - horizontal: 12, - ), - child: CustomScrollView( - slivers: [ - // Render all available weekdays - ..._renderWeekdayList( - context, - Weekday.unknown, - airingAnimeMap, - ), - ..._renderWeekdayList( - context, - Weekday.monday, - airingAnimeMap, - ), - ..._renderWeekdayList( - context, - Weekday.tuesday, - airingAnimeMap, - ), - ..._renderWeekdayList( - context, - Weekday.wednesday, - airingAnimeMap, - ), - ..._renderWeekdayList( - context, - Weekday.thursday, - airingAnimeMap, - ), - ..._renderWeekdayList( - context, - Weekday.friday, - airingAnimeMap, - ), - ..._renderWeekdayList( - context, - Weekday.saturday, - airingAnimeMap, - ), - ..._renderWeekdayList( - context, - Weekday.sunday, - airingAnimeMap, - ), - - // Provide a nice bottom padding, while keeping the elastic effect attached - // to the bottom-most edge. - const SliverToBoxAdapter( - child: SizedBox( - height: 16, - ), - ), - ], - ), - ), ), ), Positioned( diff --git a/lib/src/ui/pages/main_page.dart b/lib/src/ui/pages/main_page.dart new file mode 100644 index 0000000..0446a85 --- /dev/null +++ b/lib/src/ui/pages/main_page.dart @@ -0,0 +1,14 @@ +import 'package:anitrack/src/ui/helpers.dart'; +import 'package:flutter/material.dart'; + +/// A page that renders the anime list and the calendar as nested views. +class MainPage extends StatelessWidget { + const MainPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + drawer: getDrawer(context), + ); + } +} diff --git a/lib/src/ui/pages/settings.dart b/lib/src/ui/pages/settings.dart index 5f20dca..7464a6b 100644 --- a/lib/src/ui/pages/settings.dart +++ b/lib/src/ui/pages/settings.dart @@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; class SettingsPage extends StatelessWidget { @@ -35,6 +36,12 @@ class SettingsPage extends StatelessWidget { child: Scaffold( appBar: AppBar( title: Text(t.settings.title), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + GoRouter.of(context).pop(); + }, + ), ), body: ListView( children: [ diff --git a/lib/src/ui/widgets/shell_wrapper.dart b/lib/src/ui/widgets/shell_wrapper.dart new file mode 100644 index 0000000..04ec9ee --- /dev/null +++ b/lib/src/ui/widgets/shell_wrapper.dart @@ -0,0 +1,49 @@ +import 'package:anitrack/i18n/strings.g.dart'; +import 'package:anitrack/src/ui/constants.dart'; +import 'package:anitrack/src/ui/helpers.dart'; +import 'package:anitrack/src/ui/pages/anime_list.dart'; +import 'package:anitrack/src/ui/pages/calendar.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ShellWrapper extends StatelessWidget { + const ShellWrapper({ + required this.state, + required this.child, + super.key, + }); + + /// The current router state. + final GoRouterState state; + + /// The child to show. + final Widget child; + + @override + Widget build(BuildContext context) { + final currentPath = GoRouterState.of(context).uri.toString(); + + AppBar? appBar; + Widget? drawer; + Widget? fab; + Widget? bottomNavigationBar; + switch (currentPath) { + case animeListRoute: + drawer = getDrawer(context); + appBar = AnimeListPage.buildAppBar(context); + bottomNavigationBar = AnimeListPage.buildBottomNavigationBar(context); + fab = AnimeListPage.buildFab(); + case calendarRoute: + drawer = getDrawer(context); + appBar = CalendarPage.buildAppBar(context); + } + + return Scaffold( + appBar: appBar, + bottomNavigationBar: bottomNavigationBar, + drawer: drawer, + floatingActionButton: fab, + body: child, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 34e3141..04606fa 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -413,6 +413,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" + url: "https://pub.dev" + source: hosted + version: "17.3.0" gql: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b1f8677..b9c75dd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: fluttertoast: ^9.0.0 freezed_annotation: ^3.1.0 get_it: ^9.2.1 + go_router: ^17.3.0 graphql: ^5.2.4 jikan_api: ^2.2.1 json_annotation: ^4.11.0