Compare commits

..

13 Commits

29 changed files with 1288 additions and 6630 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,13 +14,14 @@ import 'package:anitrack/src/ui/pages/anime_search.dart';
import 'package:anitrack/src/ui/pages/calendar.dart'; import 'package:anitrack/src/ui/pages/calendar.dart';
import 'package:anitrack/src/ui/pages/details/details.dart'; import 'package:anitrack/src/ui/pages/details/details.dart';
import 'package:anitrack/src/ui/pages/settings.dart'; import 'package:anitrack/src/ui/pages/settings.dart';
import 'package:anitrack/src/ui/widgets/shell_wrapper.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
void main() async { void main() async {
final navKey = GlobalKey<NavigatorState>();
// Initialize the widgets binding for sqflite // Initialize the widgets binding for sqflite
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -33,7 +34,7 @@ void main() async {
GetIt.I.registerSingleton<AnimeListBloc>(AnimeListBloc()); GetIt.I.registerSingleton<AnimeListBloc>(AnimeListBloc());
GetIt.I.registerSingleton<AnimeSearchBloc>(AnimeSearchBloc()); GetIt.I.registerSingleton<AnimeSearchBloc>(AnimeSearchBloc());
GetIt.I.registerSingleton<DetailsBloc>(DetailsBloc()); GetIt.I.registerSingleton<DetailsBloc>(DetailsBloc());
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey)); GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc());
GetIt.I.registerSingleton<SettingsBloc>(SettingsBloc()); GetIt.I.registerSingleton<SettingsBloc>(SettingsBloc());
GetIt.I.registerSingleton<CalendarBloc>(CalendarBloc()); GetIt.I.registerSingleton<CalendarBloc>(CalendarBloc());
GetIt.I.registerSingleton<AniListClient>(AniListClient()); GetIt.I.registerSingleton<AniListClient>(AniListClient());
@@ -68,53 +69,85 @@ void main() async {
create: (_) => GetIt.I.get<CalendarBloc>(), create: (_) => GetIt.I.get<CalendarBloc>(),
), ),
], ],
child: MyApp(navKey), child: MyApp(),
), ),
); );
} }
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
const MyApp( MyApp({super.key});
this.navKey, {
super.key,
});
final GlobalKey<NavigatorState> navKey; /// The router to attach to the main app.
final _router = GoRouter(
initialLocation: animeListRoute,
debugLogDiagnostics: kDebugMode,
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return BlocListener<NavigationBloc, NavigationState>(
title: 'AniTrack', listener: (context, state) {
theme: ThemeData( if (state is NoopNavigationState) {
brightness: Brightness.light, // NOOP
primarySwatch: Colors.blue, } else if (state is PushNavigationState) {
useMaterial3: true, _router.push(state.destination);
), } else if (state is GoNavigationState) {
darkTheme: ThemeData( if (_router.canPop()) {
brightness: Brightness.dark, _router.replace(state.destination);
primarySwatch: Colors.blue, } else {
useMaterial3: true, _router.go(state.destination);
), }
navigatorKey: navKey, } else if (state is PoppedNavigationState) {
onGenerateRoute: (settings) { _router.pop();
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 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,
),
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ import 'package:graphql/client.dart';
class AniListClient { class AniListClient {
/// The base GraphQL client for AniList /// The base GraphQL client for AniList
final _client = GraphQLClient( final _client = GraphQLClient(
link: HttpLink("https://graphql.anilist.co"), link: HttpLink('https://graphql.anilist.co'),
cache: GraphQLCache(), cache: GraphQLCache(),
); );
@@ -42,11 +42,11 @@ class AniListClient {
); );
if (result.hasException) { if (result.hasException) {
// TODO: Handle this more elegantly // TODO: Handle this more elegantly
print(result.exception.toString()); print(result.exception);
return []; return [];
} }
return (result.data!["Page"]["media"] as List<Object?>) return (result.data!['Page']['media'] as List<Object?>)
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.map(AnimeSearchResult.fromJson) .map(AnimeSearchResult.fromJson)
.toList(); .toList();
@@ -83,11 +83,11 @@ class AniListClient {
); );
if (result.hasException) { if (result.hasException) {
// TODO: Handle this more elegantly // TODO: Handle this more elegantly
print(result.exception.toString()); print(result.exception);
return []; return [];
} }
return (result.data!["Page"]["media"] as List<Object?>) return (result.data!['Page']['media'] as List<Object?>)
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.map(MangaSearchResult.fromJson) .map(MangaSearchResult.fromJson)
.toList(); .toList();
@@ -119,7 +119,7 @@ class AniListClient {
), ),
); );
if (result.hasException) { if (result.hasException) {
print(result.exception.toString()); print(result.exception);
} }
return Anime.fromJson(result.data!['Media'] as Map<String, dynamic>); return Anime.fromJson(result.data!['Media'] as Map<String, dynamic>);

View File

@@ -75,6 +75,7 @@ abstract class Anime with _$Anime {
required String title, required String title,
required bool isAiring, required bool isAiring,
required String? broadcastDay, required String? broadcastDay,
required int? episodes,
}) = _Anime; }) = _Anime;
factory Anime.fromJson(Map<String, Object?> json) { factory Anime.fromJson(Map<String, Object?> json) {
@@ -88,6 +89,7 @@ abstract class Anime with _$Anime {
return Anime( return Anime(
title: (json['title']! as Map<String, dynamic>)['romaji']! as String, title: (json['title']! as Map<String, dynamic>)['romaji']! as String,
isAiring: json['status'] == 'RELEASING', isAiring: json['status'] == 'RELEASING',
episodes: json['episodes'] as int?,
broadcastDay: airingDayOfTheWeek, broadcastDay: airingDayOfTheWeek,
); );
} }

View File

@@ -570,7 +570,7 @@ as String?,
/// @nodoc /// @nodoc
mixin _$Anime { mixin _$Anime {
String get title; bool get isAiring; String? get broadcastDay; String get title; bool get isAiring; String? get broadcastDay; int? get episodes;
/// Create a copy of Anime /// Create a copy of Anime
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -581,16 +581,16 @@ $AnimeCopyWith<Anime> get copyWith => _$AnimeCopyWithImpl<Anime>(this as Anime,
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Anime&&(identical(other.title, title) || other.title == title)&&(identical(other.isAiring, isAiring) || other.isAiring == isAiring)&&(identical(other.broadcastDay, broadcastDay) || other.broadcastDay == broadcastDay)); return identical(this, other) || (other.runtimeType == runtimeType&&other is Anime&&(identical(other.title, title) || other.title == title)&&(identical(other.isAiring, isAiring) || other.isAiring == isAiring)&&(identical(other.broadcastDay, broadcastDay) || other.broadcastDay == broadcastDay)&&(identical(other.episodes, episodes) || other.episodes == episodes));
} }
@override @override
int get hashCode => Object.hash(runtimeType,title,isAiring,broadcastDay); int get hashCode => Object.hash(runtimeType,title,isAiring,broadcastDay,episodes);
@override @override
String toString() { String toString() {
return 'Anime(title: $title, isAiring: $isAiring, broadcastDay: $broadcastDay)'; return 'Anime(title: $title, isAiring: $isAiring, broadcastDay: $broadcastDay, episodes: $episodes)';
} }
@@ -601,7 +601,7 @@ abstract mixin class $AnimeCopyWith<$Res> {
factory $AnimeCopyWith(Anime value, $Res Function(Anime) _then) = _$AnimeCopyWithImpl; factory $AnimeCopyWith(Anime value, $Res Function(Anime) _then) = _$AnimeCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String title, bool isAiring, String? broadcastDay String title, bool isAiring, String? broadcastDay, int? episodes
}); });
@@ -618,12 +618,13 @@ class _$AnimeCopyWithImpl<$Res>
/// Create a copy of Anime /// Create a copy of Anime
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? isAiring = null,Object? broadcastDay = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? title = null,Object? isAiring = null,Object? broadcastDay = freezed,Object? episodes = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,isAiring: null == isAiring ? _self.isAiring : isAiring // ignore: cast_nullable_to_non_nullable as String,isAiring: null == isAiring ? _self.isAiring : isAiring // ignore: cast_nullable_to_non_nullable
as bool,broadcastDay: freezed == broadcastDay ? _self.broadcastDay : broadcastDay // ignore: cast_nullable_to_non_nullable as bool,broadcastDay: freezed == broadcastDay ? _self.broadcastDay : broadcastDay // ignore: cast_nullable_to_non_nullable
as String?, as String?,episodes: freezed == episodes ? _self.episodes : episodes // ignore: cast_nullable_to_non_nullable
as int?,
)); ));
} }
@@ -708,10 +709,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String title, bool isAiring, String? broadcastDay)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String title, bool isAiring, String? broadcastDay, int? episodes)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _Anime() when $default != null: case _Anime() when $default != null:
return $default(_that.title,_that.isAiring,_that.broadcastDay);case _: return $default(_that.title,_that.isAiring,_that.broadcastDay,_that.episodes);case _:
return orElse(); return orElse();
} }
@@ -729,10 +730,10 @@ return $default(_that.title,_that.isAiring,_that.broadcastDay);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String title, bool isAiring, String? broadcastDay) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String title, bool isAiring, String? broadcastDay, int? episodes) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _Anime(): case _Anime():
return $default(_that.title,_that.isAiring,_that.broadcastDay);case _: return $default(_that.title,_that.isAiring,_that.broadcastDay,_that.episodes);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -749,10 +750,10 @@ return $default(_that.title,_that.isAiring,_that.broadcastDay);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String title, bool isAiring, String? broadcastDay)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String title, bool isAiring, String? broadcastDay, int? episodes)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _Anime() when $default != null: case _Anime() when $default != null:
return $default(_that.title,_that.isAiring,_that.broadcastDay);case _: return $default(_that.title,_that.isAiring,_that.broadcastDay,_that.episodes);case _:
return null; return null;
} }
@@ -764,12 +765,13 @@ return $default(_that.title,_that.isAiring,_that.broadcastDay);case _:
class _Anime implements Anime { class _Anime implements Anime {
const _Anime({required this.title, required this.isAiring, required this.broadcastDay}); const _Anime({required this.title, required this.isAiring, required this.broadcastDay, required this.episodes});
@override final String title; @override final String title;
@override final bool isAiring; @override final bool isAiring;
@override final String? broadcastDay; @override final String? broadcastDay;
@override final int? episodes;
/// Create a copy of Anime /// Create a copy of Anime
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -781,16 +783,16 @@ _$AnimeCopyWith<_Anime> get copyWith => __$AnimeCopyWithImpl<_Anime>(this, _$ide
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Anime&&(identical(other.title, title) || other.title == title)&&(identical(other.isAiring, isAiring) || other.isAiring == isAiring)&&(identical(other.broadcastDay, broadcastDay) || other.broadcastDay == broadcastDay)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _Anime&&(identical(other.title, title) || other.title == title)&&(identical(other.isAiring, isAiring) || other.isAiring == isAiring)&&(identical(other.broadcastDay, broadcastDay) || other.broadcastDay == broadcastDay)&&(identical(other.episodes, episodes) || other.episodes == episodes));
} }
@override @override
int get hashCode => Object.hash(runtimeType,title,isAiring,broadcastDay); int get hashCode => Object.hash(runtimeType,title,isAiring,broadcastDay,episodes);
@override @override
String toString() { String toString() {
return 'Anime(title: $title, isAiring: $isAiring, broadcastDay: $broadcastDay)'; return 'Anime(title: $title, isAiring: $isAiring, broadcastDay: $broadcastDay, episodes: $episodes)';
} }
@@ -801,7 +803,7 @@ abstract mixin class _$AnimeCopyWith<$Res> implements $AnimeCopyWith<$Res> {
factory _$AnimeCopyWith(_Anime value, $Res Function(_Anime) _then) = __$AnimeCopyWithImpl; factory _$AnimeCopyWith(_Anime value, $Res Function(_Anime) _then) = __$AnimeCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String title, bool isAiring, String? broadcastDay String title, bool isAiring, String? broadcastDay, int? episodes
}); });
@@ -818,12 +820,13 @@ class __$AnimeCopyWithImpl<$Res>
/// Create a copy of Anime /// Create a copy of Anime
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? title = null,Object? isAiring = null,Object? broadcastDay = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? title = null,Object? isAiring = null,Object? broadcastDay = freezed,Object? episodes = freezed,}) {
return _then(_Anime( return _then(_Anime(
title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,isAiring: null == isAiring ? _self.isAiring : isAiring // ignore: cast_nullable_to_non_nullable as String,isAiring: null == isAiring ? _self.isAiring : isAiring // ignore: cast_nullable_to_non_nullable
as bool,broadcastDay: freezed == broadcastDay ? _self.broadcastDay : broadcastDay // ignore: cast_nullable_to_non_nullable as bool,broadcastDay: freezed == broadcastDay ? _self.broadcastDay : broadcastDay // ignore: cast_nullable_to_non_nullable
as String?, as String?,episodes: freezed == episodes ? _self.episodes : episodes // ignore: cast_nullable_to_non_nullable
as int?,
)); ));
} }

View File

@@ -8,6 +8,7 @@ import 'package:anitrack/src/service/migrations/0000_score.dart';
import 'package:anitrack/src/service/migrations/0001_anime_watcher.dart'; import 'package:anitrack/src/service/migrations/0001_anime_watcher.dart';
import 'package:anitrack/src/service/migrations/0002_anilist.dart'; import 'package:anitrack/src/service/migrations/0002_anilist.dart';
import 'package:anitrack/src/service/migrations/0003_other_titles.dart'; import 'package:anitrack/src/service/migrations/0003_other_titles.dart';
import 'package:anitrack/src/service/migrations/0004_fix_constraints.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart';
@@ -73,8 +74,8 @@ Future<void> _createDatabase(Database db, int version) async {
name TEXT NOT NULL, name TEXT NOT NULL,
anime TEXT NOT NULL, anime TEXT NOT NULL,
CONSTRAINT pk_watcher_join_table PRIMARY KEY(name, anime), CONSTRAINT pk_watcher_join_table PRIMARY KEY(name, anime),
CONSTRAINT fk_watcher FOREIGN KEY (name) REFERENCES $animeWatcherTable(name), CONSTRAINT fk_watcher FOREIGN KEY (name) REFERENCES $animeWatcherTable(name) ON DELETE CASCADE,
CONSTRAINT fk_anime FOREIGN KEY (anime) REFERENCES $animeTable(id) CONSTRAINT fk_anime FOREIGN KEY (anime) REFERENCES $animeTable(id) ON DELETE CASCADE
)''', )''',
); );
} }
@@ -106,7 +107,7 @@ class DatabaseService {
print('Opening database at $databasePath'); print('Opening database at $databasePath');
_db = await openDatabase( _db = await openDatabase(
databasePath, databasePath,
version: 6, version: 7,
onConfigure: (db) async { onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign // In order to do schema changes during database upgrades, we disable foreign
// keys in the onConfigure phase, but re-enable them here. // keys in the onConfigure phase, but re-enable them here.
@@ -134,6 +135,9 @@ class DatabaseService {
if (oldVersion < 6) { if (oldVersion < 6) {
await migrateFromV5ToV6(db); await migrateFromV5ToV6(db);
} }
if (oldVersion < 7) {
await migrateFromV6ToV7(db);
}
}, },
); );
} }

View File

@@ -0,0 +1,22 @@
import 'package:anitrack/src/service/database.dart';
import 'package:sqflite/sqflite.dart';
Future<void> migrateFromV6ToV7(Database db) async {
await db.execute(
'''
CREATE TABLE ${animeWatcherJoinTable}_2 (
name TEXT NOT NULL,
anime TEXT NOT NULL,
CONSTRAINT pk_watcher_join_table PRIMARY KEY(name, anime),
CONSTRAINT fk_watcher FOREIGN KEY (name) REFERENCES $animeWatcherTable(name) ON DELETE CASCADE,
CONSTRAINT fk_anime FOREIGN KEY (anime) REFERENCES $animeTable(id) ON DELETE CASCADE
);''',
);
await db.execute(
'INSERT INTO ${animeWatcherJoinTable}_2 SELECT * from $animeWatcherJoinTable;',
);
await db.execute('DROP TABLE $animeWatcherJoinTable');
await db.execute(
'ALTER TABLE ${animeWatcherJoinTable}_2 RENAME TO $animeWatcherJoinTable',
);
}

View File

@@ -125,8 +125,9 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
final anime = state.animes[index]; final anime = state.animes[index];
if (anime.episodesTotal != null && if (anime.episodesTotal != null &&
anime.episodesWatched + 1 > anime.episodesTotal!) anime.episodesWatched + 1 > anime.episodesTotal!) {
return; return;
}
final newAnime = await GetIt.I final newAnime = await GetIt.I
.get<DatabaseService>() .get<DatabaseService>()
@@ -226,8 +227,9 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
final manga = state.mangas[index]; final manga = state.mangas[index];
if (manga.chaptersTotal != null && if (manga.chaptersTotal != null &&
manga.chaptersRead + 1 > manga.chaptersTotal!) manga.chaptersRead + 1 > manga.chaptersTotal!) {
return; return;
}
final newManga = await GetIt.I final newManga = await GetIt.I
.get<DatabaseService>() .get<DatabaseService>()
@@ -372,10 +374,7 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
), ),
); );
GetIt.I.get<NavigationBloc>().add( GetIt.I.get<NavigationBloc>().add(
PushedNamedAndRemoveUntilEvent( GoNavigationEvent(animeListRoute),
const NavigationDestination(animeListRoute),
(_) => false,
),
); );
} }
} }

View File

@@ -5,14 +5,12 @@ import 'package:anitrack/src/data/search_result.dart';
import 'package:anitrack/src/data/source.dart'; import 'package:anitrack/src/data/source.dart';
import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/data/type.dart';
import 'package:anitrack/src/service/anilist/anilist_client.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/anime_list_bloc.dart' as list;
import 'package:anitrack/src/ui/bloc/navigation_bloc.dart'; import 'package:anitrack/src/ui/bloc/navigation_bloc.dart';
import 'package:anitrack/src/ui/constants.dart'; import 'package:anitrack/src/ui/constants.dart';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:jikan_api/jikan_api.dart';
part 'anime_search_state.dart'; part 'anime_search_state.dart';
part 'anime_search_event.dart'; part 'anime_search_event.dart';
@@ -43,9 +41,7 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
); );
GetIt.I.get<NavigationBloc>().add( GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent( PushNavigationEvent(animeSearchRoute),
const NavigationDestination(animeSearchRoute),
),
); );
} }
@@ -166,7 +162,7 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
); );
GetIt.I.get<NavigationBloc>().add( GetIt.I.get<NavigationBloc>().add(
PoppedRouteEvent(), GoNavigationEvent(animeListRoute),
); );
} }
} }

View File

@@ -44,29 +44,36 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
String? broadcastDay; String? broadcastDay;
bool airing; bool airing;
int? episodes;
try { try {
switch (anime.source) { switch (anime.source) {
case TrackingDataSource.mal: case TrackingDataSource.mal:
final apiData = await Jikan().getAnime(int.parse(anime.id)); final apiData = await Jikan().getAnime(int.parse(anime.id));
airing = apiData.airing; airing = apiData.airing;
broadcastDay = apiData.broadcast?.split(' ').first; broadcastDay = apiData.broadcast?.split(' ').first;
episodes = apiData.episodes;
case TrackingDataSource.anilist: case TrackingDataSource.anilist:
final apiData = await GetIt.I.get<AniListClient>().getAnimeById( final apiData = await GetIt.I.get<AniListClient>().getAnimeById(
anime.id, anime.id,
); );
airing = apiData.isAiring; airing = apiData.isAiring;
broadcastDay = apiData.broadcastDay; broadcastDay = apiData.broadcastDay;
episodes = apiData.episodes;
} }
} catch (ex) { } catch (ex) {
print('API request for anime ${anime.id} failed: $ex'); print('API request for anime ${anime.id} failed: $ex');
airing = false; continue;
} }
print('Anime "${anime.title}": airing=$airing'); print('Anime "${anime.title}": airing=$airing; episodes=$episodes');
if (!airing) { if (!airing) {
al.add( al.add(
AnimeUpdatedEvent( AnimeUpdatedEvent(
anime.copyWith(airing: false, broadcastDay: null), anime.copyWith(
airing: false,
broadcastDay: null,
episodesTotal: episodes,
),
commit: true, commit: true,
), ),
); );
@@ -74,13 +81,17 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
print('Updating Anime "${anime.title}": broadcastDay=$broadcastDay'); print('Updating Anime "${anime.title}": broadcastDay=$broadcastDay');
al.add( al.add(
AnimeUpdatedEvent( AnimeUpdatedEvent(
anime.copyWith(airing: true, broadcastDay: broadcastDay), anime.copyWith(
airing: true,
broadcastDay: broadcastDay,
episodesTotal: episodes,
),
commit: true, commit: true,
), ),
); );
} }
// Prevent hammering Jikan // Prevent hammering Jikan/AniList
await Future<void>.delayed(const Duration(milliseconds: 500)); await Future<void>.delayed(const Duration(milliseconds: 500));
} }

View File

@@ -44,9 +44,7 @@ class DetailsBloc extends Bloc<DetailsEvent, DetailsState> {
); );
GetIt.I.get<NavigationBloc>().add( GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent( PushNavigationEvent(detailsRoute),
const NavigationDestination(detailsRoute),
),
); );
} }
@@ -63,9 +61,7 @@ class DetailsBloc extends Bloc<DetailsEvent, DetailsState> {
); );
GetIt.I.get<NavigationBloc>().add( GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent( PushNavigationEvent(detailsRoute),
const NavigationDestination(detailsRoute),
),
); );
} }
@@ -119,14 +115,12 @@ class DetailsBloc extends Bloc<DetailsEvent, DetailsState> {
switch (event.trackingType) { switch (event.trackingType) {
case TrackingMediumType.anime: case TrackingMediumType.anime:
bloc.add(AnimeRemovedEvent(event.id)); bloc.add(AnimeRemovedEvent(event.id));
break;
case TrackingMediumType.manga: case TrackingMediumType.manga:
bloc.add(MangaRemovedEvent(event.id)); bloc.add(MangaRemovedEvent(event.id));
break;
} }
// Navigate back // Navigate back
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent()); GetIt.I.get<NavigationBloc>().add(PopNavigationEvent());
} }
Future<void> _onAnimeWatcherAdded( Future<void> _onAnimeWatcherAdded(

View File

@@ -1,57 +1,39 @@
import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:flutter/material.dart';
part 'navigation_event.dart'; part 'navigation_event.dart';
part 'navigation_state.dart'; part 'navigation_state.dart';
class NavigationBloc extends Bloc<NavigationEvent, NavigationState> { class NavigationBloc extends Bloc<NavigationEvent, NavigationState> {
NavigationBloc(this.navigationKey) : super(NavigationState()) { NavigationBloc() : super(NoopNavigationState()) {
on<PushedNamedEvent>(_onPushedNamed); on<GoNavigationEvent>(_onGoEvent);
on<PushedNamedAndRemoveUntilEvent>(_onPushedNamedAndRemoveUntil); on<PushNavigationEvent>(_onPushEvent);
on<PushedNamedReplaceEvent>(_onPushedNamedReplaceEvent); on<PopNavigationEvent>(_onPopEvent);
on<PoppedRouteEvent>(_onPoppedRoute);
} }
final GlobalKey<NavigatorState> navigationKey;
Future<void> _onPushedNamed( Future<void> _onGoEvent(
PushedNamedEvent event, GoNavigationEvent event,
Emitter<NavigationState> emit, Emitter<NavigationState> emit,
) async { ) async {
await navigationKey.currentState!.pushNamed( emit(
event.destination.path, GoNavigationState(event.destination),
arguments: event.destination.arguments,
); );
} }
Future<void> _onPushedNamedAndRemoveUntil( Future<void> _onPushEvent(
PushedNamedAndRemoveUntilEvent event, PushNavigationEvent event,
Emitter<NavigationState> emit, Emitter<NavigationState> emit,
) async { ) async {
await navigationKey.currentState!.pushNamedAndRemoveUntil( emit(
event.destination.path, PushNavigationState(event.destination),
event.predicate,
arguments: event.destination.arguments,
); );
} }
Future<void> _onPushedNamedReplaceEvent( Future<void> _onPopEvent(
PushedNamedReplaceEvent event, PopNavigationEvent event,
Emitter<NavigationState> emit, Emitter<NavigationState> emit,
) async { ) async {
await navigationKey.currentState!.pushReplacementNamed( emit(PoppedNavigationState());
event.destination.path,
arguments: event.destination.arguments,
);
}
Future<void> _onPoppedRoute(
PoppedRouteEvent event,
Emitter<NavigationState> emit,
) async {
navigationKey.currentState!.pop();
}
bool canPop() {
return navigationKey.currentState!.canPop();
} }
} }

View File

@@ -11,20 +11,14 @@ class NavigationDestination {
abstract class NavigationEvent {} abstract class NavigationEvent {}
class PushedNamedEvent extends NavigationEvent { class GoNavigationEvent extends NavigationEvent {
PushedNamedEvent(this.destination); GoNavigationEvent(this.destination);
final NavigationDestination destination; final String destination;
} }
class PushedNamedAndRemoveUntilEvent extends NavigationEvent { class PushNavigationEvent extends NavigationEvent {
PushedNamedAndRemoveUntilEvent(this.destination, this.predicate); PushNavigationEvent(this.destination);
final NavigationDestination destination; final String destination;
final RoutePredicate predicate;
} }
class PushedNamedReplaceEvent extends NavigationEvent { class PopNavigationEvent extends NavigationEvent {}
PushedNamedReplaceEvent(this.destination);
final NavigationDestination destination;
}
class PoppedRouteEvent extends NavigationEvent {}

View File

@@ -1,3 +1,19 @@
part of 'navigation_bloc.dart'; 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;
}

View File

@@ -250,7 +250,7 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
Emitter<SettingsState> emit, Emitter<SettingsState> emit,
) async { ) async {
final al = GetIt.I.get<AnimeListBloc>(); final al = GetIt.I.get<AnimeListBloc>();
final exportArchive = archive.GZipDecoder().decodeBytes( final exportArchive = const archive.GZipDecoder().decodeBytes(
await File(event.path).readAsBytes(), await File(event.path).readAsBytes(),
); );
final json = jsonDecode(utf8.decode(exportArchive)) as Map<String, dynamic>; final json = jsonDecode(utf8.decode(exportArchive)) as Map<String, dynamic>;

View File

@@ -1,8 +1,7 @@
import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/i18n/strings.g.dart';
import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart';
import 'package:anitrack/src/ui/constants.dart'; import 'package:anitrack/src/ui/constants.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart';
Widget getDrawer(BuildContext context) { Widget getDrawer(BuildContext context) {
return Drawer( return Drawer(
@@ -24,33 +23,30 @@ Widget getDrawer(BuildContext context) {
leading: const Icon(Icons.list), leading: const Icon(Icons.list),
title: Text(t.content.list), title: Text(t.content.list),
onTap: () { onTap: () {
GetIt.I.get<AnimeListBloc>().add( GoRouter.of(context).go(animeListRoute);
AnimeListRequestedEvent(), Navigator.of(context).pop();
);
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.calendar_today), leading: const Icon(Icons.calendar_today),
title: Text(t.calendar.calendar), title: Text(t.calendar.calendar),
onTap: () { onTap: () {
Navigator.of(context).pushNamedAndRemoveUntil( GoRouter.of(context).go(calendarRoute);
calendarRoute, Navigator.of(context).pop();
(_) => false,
);
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.settings), leading: const Icon(Icons.settings),
title: Text(t.settings.title), title: Text(t.settings.title),
onTap: () { onTap: () {
Navigator.of(context).pushNamed(settingsRoute); GoRouter.of(context).push(settingsRoute);
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.info), leading: const Icon(Icons.info),
title: Text(t.about.title), title: Text(t.about.title),
onTap: () { onTap: () {
Navigator.of(context).pushNamed(aboutRoute); GoRouter.of(context).push(aboutRoute);
}, },
), ),
], ],

View File

@@ -1,8 +1,9 @@
import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/i18n/strings.g.dart';
import 'package:anitrack/licenses.g.dart'; import 'package:anitrack/oss_licenses.dart';
import 'package:anitrack/src/ui/constants.dart'; import 'package:anitrack/src/ui/constants.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class AboutPage extends StatelessWidget { class AboutPage extends StatelessWidget {
@@ -22,9 +23,15 @@ class AboutPage extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(t.about.title), title: Text(t.about.title),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
},
),
), ),
body: ListView.builder( body: ListView.builder(
itemCount: ossLicenses.length + 1, itemCount: dependencies.length + 1,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) { if (index == 0) {
return Padding( return Padding(
@@ -41,7 +48,11 @@ class AboutPage extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.only(
top: 8,
left: 4,
right: 4,
),
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
await launchUrl( await launchUrl(
@@ -55,7 +66,11 @@ class AboutPage extends StatelessWidget {
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.only(
top: 8,
left: 4,
right: 4,
),
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
final licenseText = await rootBundle.loadString( final licenseText = await rootBundle.loadString(
@@ -90,9 +105,7 @@ class AboutPage extends StatelessWidget {
); );
} }
final dep = ossLicenses[index - 1]; final dep = dependencies[index - 1];
if (!dep.isDirectDependency) return Container();
return ListTile( return ListTile(
title: Text(dep.name), title: Text(dep.name),
onTap: () { onTap: () {

View File

@@ -4,7 +4,6 @@ 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/anime_search_bloc.dart';
import 'package:anitrack/src/ui/bloc/details_bloc.dart'; import 'package:anitrack/src/ui/bloc/details_bloc.dart';
import 'package:anitrack/src/ui/constants.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/grid_item.dart';
import 'package:anitrack/src/ui/widgets/image.dart'; import 'package:anitrack/src/ui/widgets/image.dart';
import 'package:bottom_bar/bottom_bar.dart'; import 'package:bottom_bar/bottom_bar.dart';
@@ -24,40 +23,7 @@ class AnimeListPage extends StatefulWidget {
), ),
); );
@override static String getPageTitle(TrackingMediumType type) {
AnimeListPageState createState() => AnimeListPageState();
}
class AnimeListPageState extends State<AnimeListPage> {
final PageController _controller = PageController();
final ScrollController _animeScrollController = ScrollController();
@override
void initState() {
super.initState();
_animeScrollController.addListener(_onAnimeListScrolled);
}
void _onAnimeListScrolled() {
//print(_animeScrollController.position.maxScrollExtent);
final bloc = GetIt.I.get<AnimeListBloc>();
if (_animeScrollController.offset + 20 >=
_animeScrollController.position.maxScrollExtent) {
if (bloc.state.buttonVisibility) {
bloc.add(
AddButtonVisibilitySetEvent(false),
);
}
} else {
if (!bloc.state.buttonVisibility) {
bloc.add(
AddButtonVisibilitySetEvent(true),
);
}
}
}
String _getPageTitle(TrackingMediumType type) {
switch (type) { switch (type) {
case TrackingMediumType.anime: case TrackingMediumType.anime:
return t.content.anime; return t.content.anime;
@@ -66,7 +32,7 @@ class AnimeListPageState extends State<AnimeListPage> {
} }
} }
List<PopupMenuItem<MediumTrackingState>> _getPopupButtonItems( static List<PopupMenuItem<MediumTrackingState>> getPopupButtonItems(
TrackingMediumType type, TrackingMediumType type,
) { ) {
return [ return [
@@ -90,14 +56,14 @@ class AnimeListPageState extends State<AnimeListPage> {
value: MediumTrackingState.paused, value: MediumTrackingState.paused,
child: Text(MediumTrackingState.paused.getName(type)), child: Text(MediumTrackingState.paused.getName(type)),
), ),
const PopupMenuItem<MediumTrackingState>( PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.all, value: MediumTrackingState.all,
child: Text('All'), child: Text(t.data.all),
), ),
]; ];
} }
Widget _getPopupButton(BuildContext context, AnimeListState state) { static Widget getPopupButton(BuildContext context, AnimeListState state) {
switch (state.trackingType) { switch (state.trackingType) {
case TrackingMediumType.anime: case TrackingMediumType.anime:
return PopupMenuButton( return PopupMenuButton(
@@ -110,7 +76,8 @@ class AnimeListPageState extends State<AnimeListPage> {
AnimeFilterChangedEvent(filterState), AnimeFilterChangedEvent(filterState),
); );
}, },
itemBuilder: (_) => _getPopupButtonItems(TrackingMediumType.anime), itemBuilder: (_) =>
AnimeListPage.getPopupButtonItems(TrackingMediumType.anime),
); );
case TrackingMediumType.manga: case TrackingMediumType.manga:
return PopupMenuButton( return PopupMenuButton(
@@ -123,189 +90,224 @@ class AnimeListPageState extends State<AnimeListPage> {
MangaFilterChangedEvent(filterState), MangaFilterChangedEvent(filterState),
); );
}, },
itemBuilder: (_) => _getPopupButtonItems(TrackingMediumType.manga), 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: const [
BlocBuilder<AnimeListBloc, AnimeListState>(
builder: AnimeListPage.getPopupButton,
),
],
);
}
static Widget buildBottomNavigationBar(BuildContext context) {
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,
),
);
},
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.read<AnimeSearchBloc>().add(
AnimeSearchRequestedEvent(state.trackingType),
);
},
tooltip: t.tooltips.addNewItem,
child: const Icon(Icons.add),
),
);
},
);
}
@override
AnimeListPageState createState() => AnimeListPageState();
}
class AnimeListPageState extends State<AnimeListPage> {
final ScrollController _animeScrollController = ScrollController();
@override
void initState() {
super.initState();
_animeScrollController.addListener(_onAnimeListScrolled);
}
void _onAnimeListScrolled() {
final bloc = GetIt.I.get<AnimeListBloc>();
if (_animeScrollController.offset + 20 >=
_animeScrollController.position.maxScrollExtent) {
if (bloc.state.buttonVisibility) {
bloc.add(
AddButtonVisibilitySetEvent(false),
);
}
} else {
if (!bloc.state.buttonVisibility) {
bloc.add(
AddButtonVisibilitySetEvent(true),
);
}
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<AnimeListBloc, AnimeListState>( return BlocBuilder<AnimeListBloc, AnimeListState>(
builder: (context, state) { builder: (context, state) {
return Scaffold( return IndexedStack(
appBar: AppBar( index: state.trackingType == TrackingMediumType.anime ? 0 : 1,
title: Text( children: [
_getPageTitle(state.trackingType), 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: [ Padding(
_getPopupButton(context, state), padding: const EdgeInsets.symmetric(horizontal: 8),
], child: GridView.builder(
), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
drawer: getDrawer(context), crossAxisCount: 3,
body: PageView( mainAxisSpacing: 8,
// Prevent swiping between pages crossAxisSpacing: 8,
// (https://github.com/flutter/flutter/issues/37510#issuecomment-612663656) childAspectRatio: 120 / (100 * (16 / 9)),
physics: const NeverScrollableScrollPhysics(), ),
controller: _controller, itemCount: state.mangas.length,
children: [ itemBuilder: (context, index) {
Padding( final manga = state.mangas[index];
padding: const EdgeInsets.symmetric(horizontal: 8), return GridItem(
child: GridView.builder( minusCallback: () {
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( context.read<AnimeListBloc>().add(
crossAxisCount: 3, MangaChapterDecrementedEvent(
mainAxisSpacing: 8, manga.id,
crossAxisSpacing: 8, ),
childAspectRatio: 120 / (100 * (16 / 9)), );
), },
itemCount: state.animes.length, plusCallback: () {
controller: _animeScrollController, context.read<AnimeListBloc>().add(
itemBuilder: (context, index) { MangaChapterIncrementedEvent(
final anime = state.animes[index]; manga.id,
return GridItem( ),
minusCallback: () { );
context.read<AnimeListBloc>().add( },
AnimeEpisodeDecrementedEvent( child: AnimeCoverImage(
anime.id, hero: 'grid_${manga.id}',
url: manga.thumbnailUrl,
onTap: () {
context.read<DetailsBloc>().add(
MangaDetailsRequestedEvent(
manga,
heroImagePrefix: 'grid_',
), ),
); );
}, },
plusCallback: () { extra: Align(
context.read<AnimeListBloc>().add( alignment: Alignment.centerRight,
AnimeEpisodeIncrementedEvent( child: Padding(
anime.id, padding: const EdgeInsets.only(right: 8),
), child: Text(
); '${manga.chaptersRead}/${manga.chaptersTotal ?? "???"}',
}, style: Theme.of(context).textTheme.titleMedium,
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,
),
), ),
), ),
), ),
); ),
}, );
), },
), ),
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<AnimeSearchBloc, AnimeSearchState>( return BlocListener<AnimeSearchBloc, AnimeSearchState>(
builder: (context, state) { listener: (context, state) {},
return Scaffold( child: BlocBuilder<AnimeSearchBloc, AnimeSearchState>(
appBar: AppBar( builder: (context, state) {
title: Text( return Scaffold(
state.trackingType == TrackingMediumType.anime appBar: AppBar(
? t.search.anime title: Text(
: t.search.manga, 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),
);
},
),
), ),
if (state.working) ),
const Expanded( body: Column(
child: Align( children: [
child: CircularProgressIndicator(), Padding(
), padding: const EdgeInsets.all(8),
) child: TextField(
else decoration: InputDecoration(
Expanded( border: const OutlineInputBorder(),
child: ListView.builder( labelText: t.search.query,
itemCount: state.searchResults.length, ),
itemBuilder: (context, index) { onSubmitted: (_) {
final item = state.searchResults[index]; context.read<AnimeSearchBloc>().add(
return InkWell( SearchQuerySubmittedEvent(),
onTap: () { );
context.read<AnimeSearchBloc>().add( },
ResultTappedEvent(item), onChanged: (value) {
); context.read<AnimeSearchBloc>().add(
}, SearchQueryChangedEvent(value),
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,
),
),
],
),
); );
}, },
), ),
), ),
], 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

@@ -4,7 +4,6 @@ import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart';
import 'package:anitrack/src/ui/bloc/calendar_bloc.dart'; import 'package:anitrack/src/ui/bloc/calendar_bloc.dart';
import 'package:anitrack/src/ui/bloc/details_bloc.dart'; import 'package:anitrack/src/ui/bloc/details_bloc.dart';
import 'package:anitrack/src/ui/constants.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/grid_item.dart';
import 'package:anitrack/src/ui/widgets/image.dart'; import 'package:anitrack/src/ui/widgets/image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -63,11 +62,30 @@ 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 @override
CalendarPageState createState() => CalendarPageState(); CalendarPageState createState() => CalendarPageState();
} }
class CalendarPageState extends State<CalendarPage> { class CalendarPageState extends State<CalendarPage> {
/// State for the "refreshing" overlay.
OverlayEntry? _overlayEntry;
List<Widget> _renderWeekdayList( List<Widget> _renderWeekdayList(
BuildContext context, BuildContext context,
Weekday day, Weekday day,
@@ -131,28 +149,20 @@ class CalendarPageState extends State<CalendarPage> {
switch (anime.broadcastDay) { switch (anime.broadcastDay) {
case 'Mondays': case 'Mondays':
day = Weekday.monday; day = Weekday.monday;
break;
case 'Tuesdays': case 'Tuesdays':
day = Weekday.tuesday; day = Weekday.tuesday;
break;
case 'Wednesdays': case 'Wednesdays':
day = Weekday.wednesday; day = Weekday.wednesday;
break;
case 'Thursdays': case 'Thursdays':
day = Weekday.thursday; day = Weekday.thursday;
break;
case 'Fridays': case 'Fridays':
day = Weekday.friday; day = Weekday.friday;
break;
case 'Saturdays': case 'Saturdays':
day = Weekday.saturday; day = Weekday.saturday;
break;
case 'Sundays': case 'Sundays':
day = Weekday.sunday; day = Weekday.sunday;
break;
default: default:
day = Weekday.unknown; day = Weekday.unknown;
break;
} }
airingAnimeMap.addOrSet(day, anime); airingAnimeMap.addOrSet(day, anime);
@@ -169,128 +179,20 @@ class CalendarPageState extends State<CalendarPage> {
listenWhen: (previous, current) => listenWhen: (previous, current) =>
previous.refreshing != current.refreshing, previous.refreshing != current.refreshing,
listener: (context, state) { listener: (context, state) {
// Force an update
if (!state.refreshing) { if (!state.refreshing) {
setState(() {}); _overlayEntry?.remove();
} _overlayEntry?.dispose();
}, _overlayEntry = null;
child: WillPopScope( } else {
onWillPop: () async => _overlayEntry = OverlayEntry(
!context.read<CalendarBloc>().state.refreshing, builder: (context) => SafeArea(
child: Stack( child: Stack(
children: [ children: [
Positioned( const ModalBarrier(
left: 0,
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),
),
],
),
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(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: BlocBuilder<CalendarBloc, CalendarState>(
buildWhen: (previous, current) =>
previous.refreshing != current.refreshing,
builder: (context, state) {
if (!state.refreshing) {
return const SizedBox();
}
return const ModalBarrier(
dismissible: false, dismissible: false,
color: Colors.black54, color: Colors.black54,
); ),
}, Center(
),
),
Positioned(
left: 0,
right: 0,
top: 0,
bottom: 0,
child: BlocBuilder<CalendarBloc, CalendarState>(
builder: (context, state) {
if (!state.refreshing) {
return const SizedBox();
}
return Center(
child: SizedBox( child: SizedBox(
width: 150, width: 150,
height: 150, height: 150,
@@ -307,22 +209,89 @@ class CalendarPageState extends State<CalendarPage> {
padding: EdgeInsets.all(25), padding: EdgeInsets.all(25),
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
Text( BlocBuilder<CalendarBloc, CalendarState>(
t.settings.importIndicator( builder: (context, state) => Text(
current: state.refreshingCount, t.settings.importIndicator(
total: state.refreshingTotal, current: state.refreshingCount,
total: state.refreshingTotal,
),
style: Theme.of(
context,
).textTheme.bodyMedium,
), ),
style: Theme.of(context).textTheme.bodyMedium,
), ),
], ],
), ),
), ),
), ),
); ),
}, ],
), ),
), ),
], );
Overlay.of(context).insert(_overlayEntry!);
}
},
child: WillPopScope(
onWillPop: () async =>
!context.read<CalendarBloc>().state.refreshing,
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,
),
),
],
),
), ),
), ),
); );

View File

@@ -16,6 +16,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class DetailsPage extends StatelessWidget { class DetailsPage extends StatelessWidget {
@@ -60,6 +61,12 @@ class DetailsPage extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(t.details.title), title: Text(t.details.title),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
},
),
), ),
body: BlocBuilder<DetailsBloc, DetailsState>( body: BlocBuilder<DetailsBloc, DetailsState>(
builder: (context, state) { builder: (context, state) {

View File

@@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
class SettingsPage extends StatelessWidget { class SettingsPage extends StatelessWidget {
@@ -35,6 +36,12 @@ class SettingsPage extends StatelessWidget {
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(t.settings.title), title: Text(t.settings.title),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
},
),
), ),
body: ListView( body: ListView(
children: [ children: [
@@ -101,8 +108,9 @@ class SettingsPage extends StatelessWidget {
if (!(await Permission.manageExternalStorage if (!(await Permission.manageExternalStorage
.request()) .request())
.isGranted) .isGranted) {
return; return;
}
GetIt.I.get<SettingsBloc>().add( GetIt.I.get<SettingsBloc>().add(
DataExportedEvent( DataExportedEvent(

View File

@@ -1,4 +1,3 @@
import 'package:anitrack/src/ui/widgets/list_item.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SelectorItem<T> { class SelectorItem<T> {

View File

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
class AnimeCoverImage extends StatelessWidget { class AnimeCoverImage extends StatelessWidget {
const AnimeCoverImage({ const AnimeCoverImage({
required this.url, required this.url,
required this.hero, this.hero,
this.cached = true, this.cached = true,
this.extra, this.extra,
this.onTap, this.onTap,
@@ -18,7 +18,7 @@ class AnimeCoverImage extends StatelessWidget {
final bool cached; final bool cached;
/// The hero tag of the image. /// The hero tag of the image.
final String hero; final String? hero;
/// An extra widget with a translucent backdrop. /// An extra widget with a translucent backdrop.
final Widget? extra; final Widget? extra;
@@ -28,53 +28,59 @@ class AnimeCoverImage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Hero( final image = ClipRRect(
tag: hero, borderRadius: BorderRadius.circular(8),
child: ClipRRect( child: Material(
borderRadius: BorderRadius.circular(8), child: SizedBox(
child: Material( height: 100 * (16 / 9),
child: SizedBox( width: 120,
height: 100 * (16 / 9), child: InkWell(
width: 120, onTap: onTap ?? () {},
child: InkWell( child: Stack(
onTap: onTap ?? () {}, children: [
child: Stack( Positioned(
children: [ left: 0,
Positioned( right: 0,
left: 0, top: 0,
right: 0, bottom: 0,
top: 0, child: DecoratedBox(
bottom: 0, decoration: BoxDecoration(
child: DecoratedBox( image: DecorationImage(
decoration: BoxDecoration( image: cached
image: DecorationImage( ? CachedNetworkImageProvider(url) as ImageProvider
image: cached : NetworkImage(url),
? CachedNetworkImageProvider(url) as ImageProvider fit: BoxFit.cover,
: NetworkImage(url),
fit: BoxFit.cover,
),
), ),
), ),
), ),
if (extra != null) ),
Positioned( if (extra != null)
left: 0, Positioned(
bottom: 0, left: 0,
right: 0, bottom: 0,
child: SizedBox( right: 0,
height: 40, child: SizedBox(
child: ColoredBox( height: 40,
color: Colors.black54, child: ColoredBox(
child: extra, color: Colors.black54,
), child: extra,
), ),
), ),
], ),
), ],
), ),
), ),
), ),
), ),
); );
if (hero != null) {
return Hero(
tag: hero!,
child: image,
);
}
return image;
} }
} }

View File

@@ -46,7 +46,7 @@ class ListItem extends StatelessWidget {
}, },
// TODO(PapaTutuWawa): Fix // TODO(PapaTutuWawa): Fix
key: UniqueKey(), key: UniqueKey(),
backgroundBuilder: (_, direction, __) { backgroundBuilder: (_, direction, _) {
if (direction == SwipeDirection.endToStart) { if (direction == SwipeDirection.endToStart) {
return const Align( return const Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
@@ -76,8 +76,6 @@ class ListItem extends StatelessWidget {
children: [ children: [
AnimeCoverImage( AnimeCoverImage(
cached: cached, cached: cached,
// TODO(Unknown): Have the ID here
hero: thumbnailUrl,
extra: imageExtra, extra: imageExtra,
url: thumbnailUrl, url: thumbnailUrl,
), ),

View File

@@ -0,0 +1,48 @@
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,
);
}
}

View File

@@ -413,6 +413,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" 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: gql:
dependency: transitive dependency: transitive
description: description:

View File

@@ -2,7 +2,7 @@ name: anitrack
description: An anime and manga tracker description: An anime and manga tracker
publish_to: "none" publish_to: "none"
version: 0.2.1+2018 version: 0.2.2+2019
environment: environment:
sdk: ^3.8.0 sdk: ^3.8.0
@@ -21,6 +21,7 @@ dependencies:
fluttertoast: ^9.0.0 fluttertoast: ^9.0.0
freezed_annotation: ^3.1.0 freezed_annotation: ^3.1.0
get_it: ^9.2.1 get_it: ^9.2.1
go_router: ^17.3.0
graphql: ^5.2.4 graphql: ^5.2.4
jikan_api: ^2.2.1 jikan_api: ^2.2.1
json_annotation: ^4.11.0 json_annotation: ^4.11.0