fix: Fix routing

This commit is contained in:
2026-07-26 15:53:36 +02:00
parent 34b3817e6d
commit d034875e16
17 changed files with 602 additions and 500 deletions

View File

@@ -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,

View File

@@ -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<PopupMenuItem<MediumTrackingState>> getPopupButtonItems(
TrackingMediumType type,
) {
return [
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.ongoing,
child: Text(MediumTrackingState.ongoing.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.completed,
child: Text(MediumTrackingState.completed.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.planned,
child: Text(MediumTrackingState.planned.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.dropped,
child: Text(MediumTrackingState.dropped.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.paused,
child: Text(MediumTrackingState.paused.getName(type)),
),
const PopupMenuItem<MediumTrackingState>(
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<AnimeListBloc>().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<AnimeListBloc>().add(
MangaFilterChangedEvent(filterState),
);
},
itemBuilder: (_) =>
AnimeListPage.getPopupButtonItems(TrackingMediumType.manga),
);
}
}
static AppBar buildAppBar(BuildContext context) {
return AppBar(
title: BlocBuilder<AnimeListBloc, AnimeListState>(
builder: (context, state) => Text(
AnimeListPage.getPageTitle(state.trackingType),
),
),
actions: [
BlocBuilder<AnimeListBloc, AnimeListState>(
builder: (context, state) =>
AnimeListPage.getPopupButton(context, state),
),
],
);
}
static Widget buildBottomNavigationBar(BuildContext context) {
final pageController = context.read<AnimeListBloc>().pageController;
return BlocBuilder<AnimeListBloc, AnimeListState>(
builder: (context, state) => BottomBar(
selectedIndex: state.trackingType == TrackingMediumType.anime ? 0 : 1,
onTap: (index) {
context.read<AnimeListBloc>().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<AnimeListBloc, AnimeListState>(
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<AnimeListPage> {
}
}
String _getPageTitle(TrackingMediumType type) {
switch (type) {
case TrackingMediumType.anime:
return t.content.anime;
case TrackingMediumType.manga:
return t.content.manga;
}
}
List<PopupMenuItem<MediumTrackingState>> _getPopupButtonItems(
TrackingMediumType type,
) {
return [
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.ongoing,
child: Text(MediumTrackingState.ongoing.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.completed,
child: Text(MediumTrackingState.completed.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.planned,
child: Text(MediumTrackingState.planned.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.dropped,
child: Text(MediumTrackingState.dropped.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.paused,
child: Text(MediumTrackingState.paused.getName(type)),
),
const PopupMenuItem<MediumTrackingState>(
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<AnimeListBloc>().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<AnimeListBloc>().add(
MangaFilterChangedEvent(filterState),
);
},
itemBuilder: (_) => _getPopupButtonItems(TrackingMediumType.manga),
);
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AnimeListBloc, AnimeListState>(
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<AnimeListBloc>().add(
AnimeEpisodeDecrementedEvent(
anime.id,
),
);
},
plusCallback: () {
context.read<AnimeListBloc>().add(
AnimeEpisodeIncrementedEvent(
anime.id,
),
);
},
child: AnimeCoverImage(
url: anime.thumbnailUrl,
hero: 'grid_${anime.id}',
onTap: () {
context.read<DetailsBloc>().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<AnimeListBloc>().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<AnimeListBloc>().add(
MangaChapterDecrementedEvent(
manga.id,
),
);
},
plusCallback: () {
context.read<AnimeListBloc>().add(
MangaChapterIncrementedEvent(
manga.id,
),
);
},
child: AnimeCoverImage(
hero: 'grid_${manga.id}',
url: manga.thumbnailUrl,
onTap: () {
context.read<DetailsBloc>().add(
MangaDetailsRequestedEvent(
manga,
heroImagePrefix: 'grid_',
),
);
},
plusCallback: () {
context.read<AnimeListBloc>().add(
AnimeEpisodeIncrementedEvent(
anime.id,
),
);
},
child: AnimeCoverImage(
url: anime.thumbnailUrl,
hero: 'grid_${anime.id}',
onTap: () {
context.read<DetailsBloc>().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<AnimeListBloc>().add(
MangaChapterDecrementedEvent(
manga.id,
),
);
},
plusCallback: () {
context.read<AnimeListBloc>().add(
MangaChapterIncrementedEvent(
manga.id,
),
);
},
child: AnimeCoverImage(
hero: 'grid_${manga.id}',
url: manga.thumbnailUrl,
onTap: () {
context.read<DetailsBloc>().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<AnimeListBloc, AnimeListState>(
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<AnimeSearchBloc>().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<AnimeListBloc>().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,
),
],
),
),
],
);
},
);

View File

@@ -20,81 +20,84 @@ class AnimeSearchPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<AnimeSearchBloc, AnimeSearchState>(
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<AnimeSearchBloc>().add(
SearchQuerySubmittedEvent(),
);
},
onChanged: (value) {
context.read<AnimeSearchBloc>().add(
SearchQueryChangedEvent(value),
);
},
),
return BlocListener<AnimeSearchBloc, AnimeSearchState>(
listener: (context, state) {},
child: BlocBuilder<AnimeSearchBloc, AnimeSearchState>(
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<AnimeSearchBloc>().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<AnimeSearchBloc>().add(
SearchQuerySubmittedEvent(),
);
},
onChanged: (value) {
context.read<AnimeSearchBloc>().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<AnimeSearchBloc>().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,
),
),
],
),
);
},
),
),
],
),
);
},
),
);
}
}

View File

@@ -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<CalendarBloc>().add(
RefreshPerformedEvent(),
);
},
icon: const Icon(Icons.refresh),
),
],
);
}
@override
CalendarPageState createState() => CalendarPageState();
}
@@ -131,28 +147,20 @@ class CalendarPageState extends State<CalendarPage> {
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<CalendarPage> {
right: 0,
top: 0,
bottom: 0,
child: Scaffold(
appBar: AppBar(
title: Text(t.calendar.calendar),
actions: [
IconButton(
onPressed: () {
context.read<CalendarBloc>().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(

View File

@@ -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),
);
}
}

View File

@@ -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: [