feat: Implement a basic anime calendar

This commit is contained in:
PapaTutuWawa 2023-07-16 15:13:35 +02:00
parent fbe72d1232
commit 9fed2116b1
27 changed files with 852 additions and 65 deletions

View File

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

View File

@ -5,3 +5,5 @@ targets:
options:
input_directory: assets/i18n
output_directory: lib/i18n
fallback_strategy: base_locale
base_locale: en

View File

@ -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>(DetailsBloc());
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey));
GetIt.I.registerSingleton<SettingsBloc>(SettingsBloc());
GetIt.I.registerSingleton<CalendarBloc>(CalendarBloc());
// Load animes
GetIt.I.get<AnimeListBloc>().add(
@ -59,6 +62,9 @@ void main() async {
BlocProvider<SettingsBloc>(
create: (_) => GetIt.I.get<SettingsBloc>(),
),
BlocProvider<CalendarBloc>(
create: (_) => GetIt.I.get<CalendarBloc>(),
),
],
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:

View File

@ -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<bool, int> {
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

View File

@ -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<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$AnimeTrackingDataCopyWith<AnimeTrackingData> 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<String, dynamic> 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<String, dynamic> 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;

View File

@ -14,6 +14,8 @@ _$_AnimeTrackingData _$$_AnimeTrackingDataFromJson(Map<String, dynamic> 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<String, dynamic> _$$_AnimeTrackingDataToJson(
@ -25,4 +27,6 @@ Map<String, dynamic> _$$_AnimeTrackingDataToJson(
'episodesWatched': instance.episodesWatched,
'episodesTotal': instance.episodesTotal,
'thumbnailUrl': instance.thumbnailUrl,
'airing': const BoolConverter().toJson(instance.airing),
'broadcastDay': instance.broadcastDay,
};

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import 'package:anitrack/src/service/database.dart';
import 'package:sqflite/sqflite.dart';
Future<void> 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;',
);
}

View File

@ -270,6 +270,8 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
animes: _getFilteredAnime(),
),
);
await GetIt.I.get<DatabaseService>().updateAnime(event.anime);
}
Future<void> _onMangaUpdated(

View File

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

View File

@ -82,6 +82,8 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
anime.episodes,
anime.imageUrl,
anime.synopsis ?? '',
anime.airing,
anime.broadcast?.split(' ').first,
),
)
.toList(),
@ -104,6 +106,9 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
manga.chapters,
manga.imageUrl,
manga.synopsis ?? '',
// TODO(Unknown): Implement for Manga
false,
null,
),
)
.toList(),
@ -126,6 +131,8 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
0,
event.result.total,
event.result.thumbnailUrl,
event.result.isAiring,
event.result.broadcastDay,
),
)
: list.MangaAddedEvent(

View File

@ -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<CalendarEvent, CalendarState> {
CalendarBloc() : super(CalendarState(false, 0, 0)) {
on<RefreshPerformedEvent>(_onRefreshPerformed);
}
Future<void> _onRefreshPerformed(
RefreshPerformedEvent event,
Emitter<CalendarState> emit,
) async {
emit(
state.copyWith(
refreshing: true,
refreshingCount: 0,
refreshingTotal: 0,
),
);
final al = GetIt.I.get<AnimeListBloc>();
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<void>.delayed(const Duration(milliseconds: 500));
}
emit(state.copyWith(refreshing: false));
}
}

View File

@ -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>(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<CalendarState> 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;
}

View File

@ -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 {}

View File

@ -0,0 +1,10 @@
part of 'calendar_bloc.dart';
@freezed
class CalendarState with _$CalendarState {
factory CalendarState(
bool refreshing,
int refreshingCount,
int refreshingTotal,
) = _CalendarState;
}

View File

@ -28,6 +28,7 @@ class DetailsBloc extends Bloc<DetailsEvent, DetailsState> {
emit(
state.copyWith(
trackingType: TrackingMediumType.anime,
heroImagePrefix: event.heroImagePrefix,
data: event.anime,
),
);

View File

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

View File

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

View File

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

View File

@ -119,6 +119,9 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
// 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,
),
);
}

View File

@ -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';

58
lib/src/ui/helpers.dart Normal file
View File

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

View File

@ -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<AnimeListPage> {
_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)

View File

@ -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<K, V> on Map<K, List<V>> {
void addOrSet(K key, V value) {
if (containsKey(key)) {
this[key]!.add(value);
} else {
this[key] = List<V>.from([value]);
}
}
}
class CalendarPage extends StatelessWidget {
const CalendarPage({super.key});
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const CalendarPage(),
settings: const RouteSettings(
name: calendarRoute,
),
);
List<Widget> _renderWeekdayList(
BuildContext context,
Weekday day,
Map<Weekday, List<AnimeTrackingData>> 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<DetailsBloc>().add(
AnimeDetailsRequestedEvent(
anime,
heroImagePrefix: 'calendar_',
),
);
},
),
);
},
),
),
];
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CalendarBloc, CalendarState>(
builder: (context, calendarState) =>
BlocBuilder<AnimeListBloc, AnimeListState>(
buildWhen: (previous, current) => !calendarState.refreshing,
builder: (context, state) {
final airingAnimeMap = <Weekday, List<AnimeTrackingData>>{};
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<CalendarBloc>().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<CalendarBloc, CalendarState>(
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<CalendarBloc, CalendarState>(
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,
),
],
),
),
),
);
},
),
),
],
),
);
},
),
);
}
}

View File

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

View File

@ -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<GridItem> {
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