diff --git a/assets/i18n/strings_en.i18n.json b/assets/i18n/strings_en.i18n.json index f33043c..5ecdc74 100644 --- a/assets/i18n/strings_en.i18n.json +++ b/assets/i18n/strings_en.i18n.json @@ -19,6 +19,7 @@ "addNewItem": "Add new item" }, "content": { + "list": "List", "anime": "Anime", "manga": "Manga" }, @@ -39,6 +40,19 @@ "chapters": "Chapters", "volumesOwned": "Volumes owned" }, + "calendar": { + "calendar": "Calendar", + "days": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday", + "unknown": "Unknown" + } + }, "data": { "ongoing": { "anime": "Watching", diff --git a/build.yaml b/build.yaml index a8af113..11523b4 100644 --- a/build.yaml +++ b/build.yaml @@ -4,4 +4,6 @@ targets: slang_build_runner: options: input_directory: assets/i18n - output_directory: lib/i18n \ No newline at end of file + output_directory: lib/i18n + fallback_strategy: base_locale + base_locale: en diff --git a/lib/main.dart b/lib/main.dart index 04feda1..ee23ab3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/src/service/database.dart'; 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/calendar_bloc.dart'; import 'package:anitrack/src/ui/bloc/details_bloc.dart'; import 'package:anitrack/src/ui/bloc/navigation_bloc.dart'; import 'package:anitrack/src/ui/bloc/settings_bloc.dart'; @@ -9,6 +10,7 @@ import 'package:anitrack/src/ui/constants.dart'; import 'package:anitrack/src/ui/pages/about.dart'; import 'package:anitrack/src/ui/pages/anime_list.dart'; import 'package:anitrack/src/ui/pages/anime_search.dart'; +import 'package:anitrack/src/ui/pages/calendar.dart'; import 'package:anitrack/src/ui/pages/details.dart'; import 'package:anitrack/src/ui/pages/settings.dart'; import 'package:flutter/material.dart'; @@ -32,6 +34,7 @@ void main() async { GetIt.I.registerSingleton(DetailsBloc()); GetIt.I.registerSingleton(NavigationBloc(navKey)); GetIt.I.registerSingleton(SettingsBloc()); + GetIt.I.registerSingleton(CalendarBloc()); // Load animes GetIt.I.get().add( @@ -59,6 +62,9 @@ void main() async { BlocProvider( create: (_) => GetIt.I.get(), ), + BlocProvider( + create: (_) => GetIt.I.get(), + ), ], child: MyApp(navKey), ), @@ -95,6 +101,8 @@ class MyApp extends StatelessWidget { return AnimeListPage.route; case animeSearchRoute: return AnimeSearchPage.route; + case calendarRoute: + return CalendarPage.route; case detailsRoute: return DetailsPage.route; case aboutRoute: diff --git a/lib/src/data/anime.dart b/lib/src/data/anime.dart index a39116b..78335ed 100644 --- a/lib/src/data/anime.dart +++ b/lib/src/data/anime.dart @@ -1,9 +1,20 @@ import 'package:anitrack/src/data/type.dart'; +import 'package:anitrack/src/service/database.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'anime.freezed.dart'; part 'anime.g.dart'; +class BoolConverter implements JsonConverter { + const BoolConverter(); + + @override + bool fromJson(int json) => json.toBool(); + + @override + int toJson(bool object) => object.toInt(); +} + /// Data about a tracked anime @freezed class AnimeTrackingData with _$AnimeTrackingData, TrackingMedium { @@ -25,6 +36,12 @@ class AnimeTrackingData with _$AnimeTrackingData, TrackingMedium { /// URL to the thumbnail/cover art for the anime. String thumbnailUrl, + + /// Flag whether the anime is airing + @BoolConverter() bool airing, + + /// The day of the week the anime is airing + String? broadcastDay, ) = _AnimeTrackingData; /// JSON diff --git a/lib/src/data/anime.freezed.dart b/lib/src/data/anime.freezed.dart index 7e5c9e7..56b67e2 100644 --- a/lib/src/data/anime.freezed.dart +++ b/lib/src/data/anime.freezed.dart @@ -39,6 +39,13 @@ mixin _$AnimeTrackingData { /// URL to the thumbnail/cover art for the anime. String get thumbnailUrl => throw _privateConstructorUsedError; + /// Flag whether the anime is airing + @BoolConverter() + bool get airing => throw _privateConstructorUsedError; + + /// The day of the week the anime is airing + String? get broadcastDay => throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) $AnimeTrackingDataCopyWith get copyWith => @@ -56,7 +63,9 @@ abstract class $AnimeTrackingDataCopyWith<$Res> { String title, int episodesWatched, int? episodesTotal, - String thumbnailUrl}); + String thumbnailUrl, + @BoolConverter() bool airing, + String? broadcastDay}); } /// @nodoc @@ -76,6 +85,8 @@ class _$AnimeTrackingDataCopyWithImpl<$Res> Object? episodesWatched = freezed, Object? episodesTotal = freezed, Object? thumbnailUrl = freezed, + Object? airing = freezed, + Object? broadcastDay = freezed, }) { return _then(_value.copyWith( id: id == freezed @@ -102,6 +113,14 @@ class _$AnimeTrackingDataCopyWithImpl<$Res> ? _value.thumbnailUrl : thumbnailUrl // ignore: cast_nullable_to_non_nullable as String, + airing: airing == freezed + ? _value.airing + : airing // ignore: cast_nullable_to_non_nullable + as bool, + broadcastDay: broadcastDay == freezed + ? _value.broadcastDay + : broadcastDay // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -119,7 +138,9 @@ abstract class _$$_AnimeTrackingDataCopyWith<$Res> String title, int episodesWatched, int? episodesTotal, - String thumbnailUrl}); + String thumbnailUrl, + @BoolConverter() bool airing, + String? broadcastDay}); } /// @nodoc @@ -141,6 +162,8 @@ class __$$_AnimeTrackingDataCopyWithImpl<$Res> Object? episodesWatched = freezed, Object? episodesTotal = freezed, Object? thumbnailUrl = freezed, + Object? airing = freezed, + Object? broadcastDay = freezed, }) { return _then(_$_AnimeTrackingData( id == freezed @@ -167,6 +190,14 @@ class __$$_AnimeTrackingDataCopyWithImpl<$Res> ? _value.thumbnailUrl : thumbnailUrl // ignore: cast_nullable_to_non_nullable as String, + airing == freezed + ? _value.airing + : airing // ignore: cast_nullable_to_non_nullable + as bool, + broadcastDay == freezed + ? _value.broadcastDay + : broadcastDay // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -174,8 +205,15 @@ class __$$_AnimeTrackingDataCopyWithImpl<$Res> /// @nodoc @JsonSerializable() class _$_AnimeTrackingData implements _AnimeTrackingData { - _$_AnimeTrackingData(this.id, @MediumTrackingStateConverter() this.state, - this.title, this.episodesWatched, this.episodesTotal, this.thumbnailUrl); + _$_AnimeTrackingData( + this.id, + @MediumTrackingStateConverter() this.state, + this.title, + this.episodesWatched, + this.episodesTotal, + this.thumbnailUrl, + @BoolConverter() this.airing, + this.broadcastDay); factory _$_AnimeTrackingData.fromJson(Map json) => _$$_AnimeTrackingDataFromJson(json); @@ -205,9 +243,18 @@ class _$_AnimeTrackingData implements _AnimeTrackingData { @override final String thumbnailUrl; + /// Flag whether the anime is airing + @override + @BoolConverter() + final bool airing; + + /// The day of the week the anime is airing + @override + final String? broadcastDay; + @override String toString() { - return 'AnimeTrackingData(id: $id, state: $state, title: $title, episodesWatched: $episodesWatched, episodesTotal: $episodesTotal, thumbnailUrl: $thumbnailUrl)'; + return 'AnimeTrackingData(id: $id, state: $state, title: $title, episodesWatched: $episodesWatched, episodesTotal: $episodesTotal, thumbnailUrl: $thumbnailUrl, airing: $airing, broadcastDay: $broadcastDay)'; } @override @@ -223,7 +270,10 @@ class _$_AnimeTrackingData implements _AnimeTrackingData { const DeepCollectionEquality() .equals(other.episodesTotal, episodesTotal) && const DeepCollectionEquality() - .equals(other.thumbnailUrl, thumbnailUrl)); + .equals(other.thumbnailUrl, thumbnailUrl) && + const DeepCollectionEquality().equals(other.airing, airing) && + const DeepCollectionEquality() + .equals(other.broadcastDay, broadcastDay)); } @JsonKey(ignore: true) @@ -235,7 +285,9 @@ class _$_AnimeTrackingData implements _AnimeTrackingData { const DeepCollectionEquality().hash(title), const DeepCollectionEquality().hash(episodesWatched), const DeepCollectionEquality().hash(episodesTotal), - const DeepCollectionEquality().hash(thumbnailUrl)); + const DeepCollectionEquality().hash(thumbnailUrl), + const DeepCollectionEquality().hash(airing), + const DeepCollectionEquality().hash(broadcastDay)); @JsonKey(ignore: true) @override @@ -258,7 +310,9 @@ abstract class _AnimeTrackingData implements AnimeTrackingData { final String title, final int episodesWatched, final int? episodesTotal, - final String thumbnailUrl) = _$_AnimeTrackingData; + final String thumbnailUrl, + @BoolConverter() final bool airing, + final String? broadcastDay) = _$_AnimeTrackingData; factory _AnimeTrackingData.fromJson(Map json) = _$_AnimeTrackingData.fromJson; @@ -289,6 +343,15 @@ abstract class _AnimeTrackingData implements AnimeTrackingData { /// URL to the thumbnail/cover art for the anime. String get thumbnailUrl; @override + + /// Flag whether the anime is airing + @BoolConverter() + bool get airing; + @override + + /// The day of the week the anime is airing + String? get broadcastDay; + @override @JsonKey(ignore: true) _$$_AnimeTrackingDataCopyWith<_$_AnimeTrackingData> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/src/data/anime.g.dart b/lib/src/data/anime.g.dart index 4282cbf..72545f8 100644 --- a/lib/src/data/anime.g.dart +++ b/lib/src/data/anime.g.dart @@ -14,6 +14,8 @@ _$_AnimeTrackingData _$$_AnimeTrackingDataFromJson(Map json) => json['episodesWatched'] as int, json['episodesTotal'] as int?, json['thumbnailUrl'] as String, + const BoolConverter().fromJson(json['airing'] as int), + json['broadcastDay'] as String?, ); Map _$$_AnimeTrackingDataToJson( @@ -25,4 +27,6 @@ Map _$$_AnimeTrackingDataToJson( 'episodesWatched': instance.episodesWatched, 'episodesTotal': instance.episodesTotal, 'thumbnailUrl': instance.thumbnailUrl, + 'airing': const BoolConverter().toJson(instance.airing), + 'broadcastDay': instance.broadcastDay, }; diff --git a/lib/src/data/search_result.dart b/lib/src/data/search_result.dart index a757d27..31730ce 100644 --- a/lib/src/data/search_result.dart +++ b/lib/src/data/search_result.dart @@ -5,6 +5,8 @@ class SearchResult { this.total, this.thumbnailUrl, this.description, + this.isAiring, + this.broadcastDay, ); /// The title of the anime. @@ -22,4 +24,10 @@ class SearchResult { /// The description of the anime final String description; + + /// Flag whether the anime is airing. + final bool isAiring; + + /// The day of the week the anime is airing. + final String? broadcastDay; } diff --git a/lib/src/service/database.dart b/lib/src/service/database.dart index 0571929..6354335 100644 --- a/lib/src/service/database.dart +++ b/lib/src/service/database.dart @@ -1,22 +1,37 @@ import 'package:anitrack/src/data/anime.dart'; import 'package:anitrack/src/data/manga.dart'; +import 'package:anitrack/src/service/migrations/0000_airing.dart'; import 'package:anitrack/src/service/migrations/0000_score.dart'; import 'package:sqflite/sqflite.dart'; const animeTable = 'Anime'; const mangaTable = 'Manga'; +extension BoolToInt on bool { + int toInt() { + return this ? 1 : 0; + } +} + +extension IntToBool on int { + bool toBool() { + return this == 1; + } +} + Future _createDatabase(Database db, int version) async { await db.execute( ''' CREATE TABLE $animeTable( - id TEXT NOT NULL PRIMARY KEY, - state INTEGER NOT NULL, - episodesTotal INTEGER, + id TEXT NOT NULL PRIMARY KEY, + state INTEGER NOT NULL, + episodesTotal INTEGER, episodesWatched INTEGER NOT NULL, - thumbnailUrl TEXT NOT NULL, - title TEXT NOT NULL, - score INTEGER + thumbnailUrl TEXT NOT NULL, + title TEXT NOT NULL, + score INTEGER, + airing INTEGER NOT NULL, + broadcastDay TEXT )''', ); await db.execute( @@ -40,7 +55,7 @@ class DatabaseService { Future initialize() async { _db = await openDatabase( 'anitrack.db', - version: 2, + version: 3, onConfigure: (db) async { // In order to do schema changes during database upgrades, we disable foreign // keys in the onConfigure phase, but re-enable them here. @@ -56,6 +71,9 @@ class DatabaseService { if (oldVersion < 2) { await migrateFromV1ToV2(db); } + if (oldVersion < 3) { + await migrateFromV2ToV3(db); + } }, ); } diff --git a/lib/src/service/migrations/0000_airing.dart b/lib/src/service/migrations/0000_airing.dart new file mode 100644 index 0000000..374840b --- /dev/null +++ b/lib/src/service/migrations/0000_airing.dart @@ -0,0 +1,11 @@ +import 'package:anitrack/src/service/database.dart'; +import 'package:sqflite/sqflite.dart'; + +Future migrateFromV2ToV3(Database db) async { + await db.execute( + 'ALTER TABLE $animeTable ADD COLUMN airing INTEGER NOT NULL DEFAULT ${true.toInt()};', + ); + await db.execute( + 'ALTER TABLE $animeTable ADD COLUMN broadcastDay TEXT;', + ); +} diff --git a/lib/src/ui/bloc/anime_list_bloc.dart b/lib/src/ui/bloc/anime_list_bloc.dart index 017f1d5..83e9fee 100644 --- a/lib/src/ui/bloc/anime_list_bloc.dart +++ b/lib/src/ui/bloc/anime_list_bloc.dart @@ -270,6 +270,8 @@ class AnimeListBloc extends Bloc { animes: _getFilteredAnime(), ), ); + + await GetIt.I.get().updateAnime(event.anime); } Future _onMangaUpdated( diff --git a/lib/src/ui/bloc/anime_list_event.dart b/lib/src/ui/bloc/anime_list_event.dart index 2f2cc57..68e2a24 100644 --- a/lib/src/ui/bloc/anime_list_event.dart +++ b/lib/src/ui/bloc/anime_list_event.dart @@ -43,9 +43,12 @@ class AnimeTrackingTypeChanged extends AnimeListEvent { } class AnimeUpdatedEvent extends AnimeListEvent { - AnimeUpdatedEvent(this.anime); + AnimeUpdatedEvent(this.anime, {this.commit = false}); final AnimeTrackingData anime; + + /// Commit the new anime data to the database. + final bool commit; } class AnimeRemovedEvent extends AnimeListEvent { diff --git a/lib/src/ui/bloc/anime_search_bloc.dart b/lib/src/ui/bloc/anime_search_bloc.dart index e407c9c..55a3ea3 100644 --- a/lib/src/ui/bloc/anime_search_bloc.dart +++ b/lib/src/ui/bloc/anime_search_bloc.dart @@ -82,6 +82,8 @@ class AnimeSearchBloc extends Bloc { anime.episodes, anime.imageUrl, anime.synopsis ?? '', + anime.airing, + anime.broadcast?.split(' ').first, ), ) .toList(), @@ -104,6 +106,9 @@ class AnimeSearchBloc extends Bloc { manga.chapters, manga.imageUrl, manga.synopsis ?? '', + // TODO(Unknown): Implement for Manga + false, + null, ), ) .toList(), @@ -126,6 +131,8 @@ class AnimeSearchBloc extends Bloc { 0, event.result.total, event.result.thumbnailUrl, + event.result.isAiring, + event.result.broadcastDay, ), ) : list.MangaAddedEvent( diff --git a/lib/src/ui/bloc/calendar_bloc.dart b/lib/src/ui/bloc/calendar_bloc.dart new file mode 100644 index 0000000..fd27c7e --- /dev/null +++ b/lib/src/ui/bloc/calendar_bloc.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'package:anitrack/src/ui/bloc/anime_list_bloc.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 'calendar_state.dart'; +part 'calendar_bloc.freezed.dart'; +part 'calendar_event.dart'; + +class CalendarBloc extends Bloc { + CalendarBloc() : super(CalendarState(false, 0, 0)) { + on(_onRefreshPerformed); + } + + Future _onRefreshPerformed( + RefreshPerformedEvent event, + Emitter emit, + ) async { + emit( + state.copyWith( + refreshing: true, + refreshingCount: 0, + refreshingTotal: 0, + ), + ); + + final al = GetIt.I.get(); + final animes = al.state.animes.where((anime) => anime.airing); + emit( + state.copyWith( + refreshing: true, + refreshingCount: 0, + refreshingTotal: animes.length, + ), + ); + + for (final anime in animes) { + emit(state.copyWith(refreshingCount: state.refreshingCount + 1)); + + final apiData = await Jikan().getAnime(int.parse(anime.id)); + if (!apiData.airing) { + al.add( + AnimeUpdatedEvent( + anime.copyWith(airing: false, broadcastDay: null), + commit: true, + ), + ); + } + + // Prevent hammering Jikan + await Future.delayed(const Duration(milliseconds: 500)); + } + + emit(state.copyWith(refreshing: false)); + } +} diff --git a/lib/src/ui/bloc/calendar_bloc.freezed.dart b/lib/src/ui/bloc/calendar_bloc.freezed.dart new file mode 100644 index 0000000..d5681d2 --- /dev/null +++ b/lib/src/ui/bloc/calendar_bloc.freezed.dart @@ -0,0 +1,169 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target + +part of 'calendar_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +/// @nodoc +mixin _$CalendarState { + bool get refreshing => throw _privateConstructorUsedError; + int get refreshingCount => throw _privateConstructorUsedError; + int get refreshingTotal => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $CalendarStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $CalendarStateCopyWith<$Res> { + factory $CalendarStateCopyWith( + CalendarState value, $Res Function(CalendarState) then) = + _$CalendarStateCopyWithImpl<$Res>; + $Res call({bool refreshing, int refreshingCount, int refreshingTotal}); +} + +/// @nodoc +class _$CalendarStateCopyWithImpl<$Res> + implements $CalendarStateCopyWith<$Res> { + _$CalendarStateCopyWithImpl(this._value, this._then); + + final CalendarState _value; + // ignore: unused_field + final $Res Function(CalendarState) _then; + + @override + $Res call({ + Object? refreshing = freezed, + Object? refreshingCount = freezed, + Object? refreshingTotal = freezed, + }) { + return _then(_value.copyWith( + refreshing: refreshing == freezed + ? _value.refreshing + : refreshing // ignore: cast_nullable_to_non_nullable + as bool, + refreshingCount: refreshingCount == freezed + ? _value.refreshingCount + : refreshingCount // ignore: cast_nullable_to_non_nullable + as int, + refreshingTotal: refreshingTotal == freezed + ? _value.refreshingTotal + : refreshingTotal // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc +abstract class _$$_CalendarStateCopyWith<$Res> + implements $CalendarStateCopyWith<$Res> { + factory _$$_CalendarStateCopyWith( + _$_CalendarState value, $Res Function(_$_CalendarState) then) = + __$$_CalendarStateCopyWithImpl<$Res>; + @override + $Res call({bool refreshing, int refreshingCount, int refreshingTotal}); +} + +/// @nodoc +class __$$_CalendarStateCopyWithImpl<$Res> + extends _$CalendarStateCopyWithImpl<$Res> + implements _$$_CalendarStateCopyWith<$Res> { + __$$_CalendarStateCopyWithImpl( + _$_CalendarState _value, $Res Function(_$_CalendarState) _then) + : super(_value, (v) => _then(v as _$_CalendarState)); + + @override + _$_CalendarState get _value => super._value as _$_CalendarState; + + @override + $Res call({ + Object? refreshing = freezed, + Object? refreshingCount = freezed, + Object? refreshingTotal = freezed, + }) { + return _then(_$_CalendarState( + refreshing == freezed + ? _value.refreshing + : refreshing // ignore: cast_nullable_to_non_nullable + as bool, + refreshingCount == freezed + ? _value.refreshingCount + : refreshingCount // ignore: cast_nullable_to_non_nullable + as int, + refreshingTotal == freezed + ? _value.refreshingTotal + : refreshingTotal // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc + +class _$_CalendarState implements _CalendarState { + _$_CalendarState(this.refreshing, this.refreshingCount, this.refreshingTotal); + + @override + final bool refreshing; + @override + final int refreshingCount; + @override + final int refreshingTotal; + + @override + String toString() { + return 'CalendarState(refreshing: $refreshing, refreshingCount: $refreshingCount, refreshingTotal: $refreshingTotal)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_CalendarState && + const DeepCollectionEquality() + .equals(other.refreshing, refreshing) && + const DeepCollectionEquality() + .equals(other.refreshingCount, refreshingCount) && + const DeepCollectionEquality() + .equals(other.refreshingTotal, refreshingTotal)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(refreshing), + const DeepCollectionEquality().hash(refreshingCount), + const DeepCollectionEquality().hash(refreshingTotal)); + + @JsonKey(ignore: true) + @override + _$$_CalendarStateCopyWith<_$_CalendarState> get copyWith => + __$$_CalendarStateCopyWithImpl<_$_CalendarState>(this, _$identity); +} + +abstract class _CalendarState implements CalendarState { + factory _CalendarState(final bool refreshing, final int refreshingCount, + final int refreshingTotal) = _$_CalendarState; + + @override + bool get refreshing; + @override + int get refreshingCount; + @override + int get refreshingTotal; + @override + @JsonKey(ignore: true) + _$$_CalendarStateCopyWith<_$_CalendarState> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/src/ui/bloc/calendar_event.dart b/lib/src/ui/bloc/calendar_event.dart new file mode 100644 index 0000000..aaa005d --- /dev/null +++ b/lib/src/ui/bloc/calendar_event.dart @@ -0,0 +1,6 @@ +part of 'calendar_bloc.dart'; + +abstract class CalendarEvent {} + +/// Triggered by the UI when the user wants to refresh the airing anime list. +class RefreshPerformedEvent extends CalendarEvent {} diff --git a/lib/src/ui/bloc/calendar_state.dart b/lib/src/ui/bloc/calendar_state.dart new file mode 100644 index 0000000..bdc2741 --- /dev/null +++ b/lib/src/ui/bloc/calendar_state.dart @@ -0,0 +1,10 @@ +part of 'calendar_bloc.dart'; + +@freezed +class CalendarState with _$CalendarState { + factory CalendarState( + bool refreshing, + int refreshingCount, + int refreshingTotal, + ) = _CalendarState; +} diff --git a/lib/src/ui/bloc/details_bloc.dart b/lib/src/ui/bloc/details_bloc.dart index 095b551..87d27c5 100644 --- a/lib/src/ui/bloc/details_bloc.dart +++ b/lib/src/ui/bloc/details_bloc.dart @@ -28,6 +28,7 @@ class DetailsBloc extends Bloc { emit( state.copyWith( trackingType: TrackingMediumType.anime, + heroImagePrefix: event.heroImagePrefix, data: event.anime, ), ); diff --git a/lib/src/ui/bloc/details_bloc.freezed.dart b/lib/src/ui/bloc/details_bloc.freezed.dart index 34372e7..b3585f4 100644 --- a/lib/src/ui/bloc/details_bloc.freezed.dart +++ b/lib/src/ui/bloc/details_bloc.freezed.dart @@ -17,6 +17,7 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$DetailsState { TrackingMedium? get data => throw _privateConstructorUsedError; + String? get heroImagePrefix => throw _privateConstructorUsedError; TrackingMediumType get trackingType => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -29,7 +30,10 @@ abstract class $DetailsStateCopyWith<$Res> { factory $DetailsStateCopyWith( DetailsState value, $Res Function(DetailsState) then) = _$DetailsStateCopyWithImpl<$Res>; - $Res call({TrackingMedium? data, TrackingMediumType trackingType}); + $Res call( + {TrackingMedium? data, + String? heroImagePrefix, + TrackingMediumType trackingType}); } /// @nodoc @@ -43,6 +47,7 @@ class _$DetailsStateCopyWithImpl<$Res> implements $DetailsStateCopyWith<$Res> { @override $Res call({ Object? data = freezed, + Object? heroImagePrefix = freezed, Object? trackingType = freezed, }) { return _then(_value.copyWith( @@ -50,6 +55,10 @@ class _$DetailsStateCopyWithImpl<$Res> implements $DetailsStateCopyWith<$Res> { ? _value.data : data // ignore: cast_nullable_to_non_nullable as TrackingMedium?, + heroImagePrefix: heroImagePrefix == freezed + ? _value.heroImagePrefix + : heroImagePrefix // ignore: cast_nullable_to_non_nullable + as String?, trackingType: trackingType == freezed ? _value.trackingType : trackingType // ignore: cast_nullable_to_non_nullable @@ -65,7 +74,10 @@ abstract class _$$_DetailsStateCopyWith<$Res> _$_DetailsState value, $Res Function(_$_DetailsState) then) = __$$_DetailsStateCopyWithImpl<$Res>; @override - $Res call({TrackingMedium? data, TrackingMediumType trackingType}); + $Res call( + {TrackingMedium? data, + String? heroImagePrefix, + TrackingMediumType trackingType}); } /// @nodoc @@ -82,6 +94,7 @@ class __$$_DetailsStateCopyWithImpl<$Res> @override $Res call({ Object? data = freezed, + Object? heroImagePrefix = freezed, Object? trackingType = freezed, }) { return _then(_$_DetailsState( @@ -89,6 +102,10 @@ class __$$_DetailsStateCopyWithImpl<$Res> ? _value.data : data // ignore: cast_nullable_to_non_nullable as TrackingMedium?, + heroImagePrefix: heroImagePrefix == freezed + ? _value.heroImagePrefix + : heroImagePrefix // ignore: cast_nullable_to_non_nullable + as String?, trackingType: trackingType == freezed ? _value.trackingType : trackingType // ignore: cast_nullable_to_non_nullable @@ -100,17 +117,22 @@ class __$$_DetailsStateCopyWithImpl<$Res> /// @nodoc class _$_DetailsState implements _DetailsState { - _$_DetailsState({this.data, this.trackingType = TrackingMediumType.anime}); + _$_DetailsState( + {this.data, + this.heroImagePrefix, + this.trackingType = TrackingMediumType.anime}); @override final TrackingMedium? data; @override + final String? heroImagePrefix; + @override @JsonKey() final TrackingMediumType trackingType; @override String toString() { - return 'DetailsState(data: $data, trackingType: $trackingType)'; + return 'DetailsState(data: $data, heroImagePrefix: $heroImagePrefix, trackingType: $trackingType)'; } @override @@ -119,6 +141,8 @@ class _$_DetailsState implements _DetailsState { (other.runtimeType == runtimeType && other is _$_DetailsState && const DeepCollectionEquality().equals(other.data, data) && + const DeepCollectionEquality() + .equals(other.heroImagePrefix, heroImagePrefix) && const DeepCollectionEquality() .equals(other.trackingType, trackingType)); } @@ -127,6 +151,7 @@ class _$_DetailsState implements _DetailsState { int get hashCode => Object.hash( runtimeType, const DeepCollectionEquality().hash(data), + const DeepCollectionEquality().hash(heroImagePrefix), const DeepCollectionEquality().hash(trackingType)); @JsonKey(ignore: true) @@ -138,11 +163,14 @@ class _$_DetailsState implements _DetailsState { abstract class _DetailsState implements DetailsState { factory _DetailsState( {final TrackingMedium? data, + final String? heroImagePrefix, final TrackingMediumType trackingType}) = _$_DetailsState; @override TrackingMedium? get data; @override + String? get heroImagePrefix; + @override TrackingMediumType get trackingType; @override @JsonKey(ignore: true) diff --git a/lib/src/ui/bloc/details_event.dart b/lib/src/ui/bloc/details_event.dart index 2d535bc..8fc4ee4 100644 --- a/lib/src/ui/bloc/details_event.dart +++ b/lib/src/ui/bloc/details_event.dart @@ -3,10 +3,15 @@ part of 'details_bloc.dart'; abstract class DetailsEvent {} class AnimeDetailsRequestedEvent extends DetailsEvent { - AnimeDetailsRequestedEvent(this.anime); + AnimeDetailsRequestedEvent( + this.anime, { + this.heroImagePrefix, + }); /// The anime to show details about final AnimeTrackingData anime; + + final String? heroImagePrefix; } class MangaDetailsRequestedEvent extends DetailsEvent { diff --git a/lib/src/ui/bloc/details_state.dart b/lib/src/ui/bloc/details_state.dart index 01c95a2..7fe1d01 100644 --- a/lib/src/ui/bloc/details_state.dart +++ b/lib/src/ui/bloc/details_state.dart @@ -4,6 +4,7 @@ part of 'details_bloc.dart'; class DetailsState with _$DetailsState { factory DetailsState({ TrackingMedium? data, + String? heroImagePrefix, @Default(TrackingMediumType.anime) TrackingMediumType trackingType, }) = _DetailsState; } diff --git a/lib/src/ui/bloc/settings_bloc.dart b/lib/src/ui/bloc/settings_bloc.dart index 88956ee..79e732d 100644 --- a/lib/src/ui/bloc/settings_bloc.dart +++ b/lib/src/ui/bloc/settings_bloc.dart @@ -119,6 +119,9 @@ class SettingsBloc extends Bloc { // 0 means that MAL does not know totalEpisodes == 0 ? null : totalEpisodes, data.imageUrl, + // NOTE: When the calendar gets refreshed, this should also get cleared + true, + null, ), ); } diff --git a/lib/src/ui/constants.dart b/lib/src/ui/constants.dart index 8838633..9b60bdd 100644 --- a/lib/src/ui/constants.dart +++ b/lib/src/ui/constants.dart @@ -1,5 +1,6 @@ const animeListRoute = '/anime/list'; const animeSearchRoute = '/anime/search'; const detailsRoute = '/anime/details'; +const calendarRoute = '/calendar'; const aboutRoute = '/about'; const settingsRoute = '/settings'; diff --git a/lib/src/ui/helpers.dart b/lib/src/ui/helpers.dart new file mode 100644 index 0000000..8f985c4 --- /dev/null +++ b/lib/src/ui/helpers.dart @@ -0,0 +1,58 @@ +import 'package:anitrack/i18n/strings.g.dart'; +import 'package:anitrack/src/ui/constants.dart'; +import 'package:flutter/material.dart'; + +Widget getDrawer(BuildContext context) { + return Drawer( + child: ListView( + children: [ + const DrawerHeader( + decoration: BoxDecoration( + color: Color(0xffcf4aff), + ), + child: Text( + 'AniTrack', + style: TextStyle( + color: Color(0xff232323), + fontSize: 24, + ), + ), + ), + ListTile( + leading: const Icon(Icons.list), + title: Text(t.content.list), + onTap: () { + Navigator.of(context).pushNamedAndRemoveUntil( + animeListRoute, + (_) => false, + ); + }, + ), + ListTile( + leading: const Icon(Icons.calendar_today), + title: Text(t.calendar.calendar), + onTap: () { + Navigator.of(context).pushNamedAndRemoveUntil( + calendarRoute, + (_) => false, + ); + }, + ), + ListTile( + leading: const Icon(Icons.settings), + title: Text(t.settings.title), + onTap: () { + Navigator.of(context).pushNamed(settingsRoute); + }, + ), + ListTile( + leading: const Icon(Icons.info), + title: Text(t.about.title), + onTap: () { + Navigator.of(context).pushNamed(aboutRoute); + }, + ), + ], + ), + ); +} diff --git a/lib/src/ui/pages/anime_list.dart b/lib/src/ui/pages/anime_list.dart index 96e42fa..1d711ed 100644 --- a/lib/src/ui/pages/anime_list.dart +++ b/lib/src/ui/pages/anime_list.dart @@ -4,6 +4,7 @@ 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'; @@ -140,38 +141,7 @@ class AnimeListPageState extends State { _getPopupButton(context, state), ], ), - drawer: Drawer( - child: ListView( - children: [ - const DrawerHeader( - decoration: BoxDecoration( - color: Color(0xffcf4aff), - ), - child: Text( - 'AniTrack', - style: TextStyle( - color: Color(0xff232323), - fontSize: 24, - ), - ), - ), - ListTile( - leading: const Icon(Icons.settings), - title: Text(t.settings.title), - onTap: () { - Navigator.of(context).pushNamed(settingsRoute); - }, - ), - ListTile( - leading: const Icon(Icons.info), - title: Text(t.about.title), - onTap: () { - Navigator.of(context).pushNamed(aboutRoute); - }, - ), - ], - ), - ), + drawer: getDrawer(context), body: PageView( // Prevent swiping between pages // (https://github.com/flutter/flutter/issues/37510#issuecomment-612663656) diff --git a/lib/src/ui/pages/calendar.dart b/lib/src/ui/pages/calendar.dart new file mode 100644 index 0000000..9f387bb --- /dev/null +++ b/lib/src/ui/pages/calendar.dart @@ -0,0 +1,304 @@ +import 'package:anitrack/i18n/strings.g.dart'; +import 'package:anitrack/src/data/anime.dart'; +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/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:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +enum Weekday { + monday, + tuesday, + wednesday, + thursday, + friday, + saturday, + sunday, + unknown; + + String toName() { + switch (this) { + case Weekday.monday: + return t.calendar.days.monday; + case Weekday.tuesday: + return t.calendar.days.tuesday; + case Weekday.wednesday: + return t.calendar.days.wednesday; + case Weekday.thursday: + return t.calendar.days.thursday; + case Weekday.friday: + return t.calendar.days.friday; + case Weekday.saturday: + return t.calendar.days.saturday; + case Weekday.sunday: + return t.calendar.days.sunday; + case Weekday.unknown: + return t.calendar.days.unknown; + } + } +} + +extension AddIfExists on Map> { + void addOrSet(K key, V value) { + if (containsKey(key)) { + this[key]!.add(value); + } else { + this[key] = List.from([value]); + } + } +} + +class CalendarPage extends StatelessWidget { + const CalendarPage({super.key}); + + static MaterialPageRoute get route => MaterialPageRoute( + builder: (_) => const CalendarPage(), + settings: const RouteSettings( + name: calendarRoute, + ), + ); + + List _renderWeekdayList( + BuildContext context, + Weekday day, + Map> data, + ) { + if (!data.containsKey(day)) { + return const []; + } + + assert(data[day]!.isNotEmpty, 'There should be at least one anime'); + return [ + Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: 4, + ), + child: Text( + day.toName(), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 120 / (100 * (16 / 9)), + ), + itemCount: data[day]!.length, + itemBuilder: (context, index) { + final anime = data[day]![index]; + return GridItem( + child: AnimeCoverImage( + url: anime.thumbnailUrl, + hero: 'calendar_${anime.id}', + onTap: () { + context.read().add( + AnimeDetailsRequestedEvent( + anime, + heroImagePrefix: 'calendar_', + ), + ); + }, + ), + ); + }, + ), + ), + ]; + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, calendarState) => + BlocBuilder( + buildWhen: (previous, current) => !calendarState.refreshing, + builder: (context, state) { + final airingAnimeMap = >{}; + for (final anime in state.animes) { + if (!anime.airing) continue; + + final Weekday day; + 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); + } + + return Scaffold( + appBar: AppBar( + title: Text(t.calendar.calendar), + actions: [ + IconButton( + onPressed: () { + context.read().add(RefreshPerformedEvent()); + }, + icon: const Icon(Icons.refresh), + ), + ], + ), + drawer: getDrawer(context), + body: Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: ListView( + children: [ + // 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 SizedBox( + height: 16, + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: BlocBuilder( + builder: (context, state) { + if (!state.refreshing) { + return const SizedBox(); + } + + return const ModalBarrier( + dismissible: false, + color: Colors.black54, + ); + }, + ), + ), + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: BlocBuilder( + builder: (context, state) { + if (!state.refreshing) { + return const SizedBox(); + } + + return Center( + child: SizedBox( + width: 150, + height: 150, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Colors.grey.shade800, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.all(25), + child: CircularProgressIndicator(), + ), + Text( + t.settings.importIndicator( + current: state.refreshingCount, + total: state.refreshingTotal, + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/src/ui/pages/details.dart b/lib/src/ui/pages/details.dart index b89dcd2..f845b17 100644 --- a/lib/src/ui/pages/details.dart +++ b/lib/src/ui/pages/details.dart @@ -42,7 +42,7 @@ class DetailsPage extends StatelessWidget { children: [ AnimeCoverImage( url: state.data!.thumbnailUrl, - hero: state.data!.id, + hero: '${state.heroImagePrefix}${state.data!.id}', ), Expanded( child: Padding( @@ -69,10 +69,14 @@ class DetailsPage extends StatelessWidget { builder: (context) { return AlertDialog( title: Text( - t.details.removeTitle(title: state.data!.title), + t.details.removeTitle( + title: state.data!.title, + ), ), content: Text( - t.details.removeBody(title: state.data!.title), + t.details.removeBody( + title: state.data!.title, + ), ), actions: [ TextButton( @@ -83,14 +87,18 @@ class DetailsPage extends StatelessWidget { style: TextButton.styleFrom( foregroundColor: Colors.red, ), - child: Text(t.details.removeButton), + child: Text( + t.details.removeButton, + ), ), TextButton( onPressed: () { Navigator.of(context) .pop(false); }, - child: Text(t.details.cancelButton), + child: Text( + t.details.cancelButton, + ), ), ], ); diff --git a/lib/src/ui/widgets/grid_item.dart b/lib/src/ui/widgets/grid_item.dart index b9540f1..b99e7e6 100644 --- a/lib/src/ui/widgets/grid_item.dart +++ b/lib/src/ui/widgets/grid_item.dart @@ -4,15 +4,18 @@ import 'package:flutter/material.dart'; class GridItem extends StatefulWidget { const GridItem({ required this.child, - required this.plusCallback, - required this.minusCallback, + this.plusCallback, + this.minusCallback, + this.enableDrag = true, super.key, }); final Widget child; - final void Function() plusCallback; - final void Function() minusCallback; + final bool enableDrag; + + final void Function()? plusCallback; + final void Function()? minusCallback; @override GridItemState createState() => GridItemState(); @@ -26,16 +29,20 @@ class GridItemState extends State { Widget build(BuildContext context) { return GestureDetector( onHorizontalDragUpdate: (details) { + if (!widget.enableDrag) return; + setState(() { _offset += details.delta.dx; _translationX = 160 / (1 + exp(-1 * (1 / 30) * _offset)) - 80; }); }, onHorizontalDragEnd: (_) { + if (!widget.enableDrag) return; + if (_translationX <= -60) { - widget.plusCallback(); + widget.plusCallback!(); } else if (_translationX >= 60) { - widget.minusCallback(); + widget.minusCallback!(); } // Reset the view