diff --git a/lib/src/data/manga.dart b/lib/src/data/manga.dart new file mode 100644 index 0000000..bf0ec0d --- /dev/null +++ b/lib/src/data/manga.dart @@ -0,0 +1,70 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'manga.freezed.dart'; +part 'manga.g.dart'; + +/// The watch state of an manga +enum MangaTrackingState { + reading, // 0 + completed, // 1 + planToWatch, // 2 + dropped, // 3 + /// This is a pseudo state, i.e. it should never be set + all, // -1 +} + +extension MangaTrackStateExtension on MangaTrackingState { + int toInteger() { + assert(this != MangaTrackingState.all, 'MangaTrackingState.all must not be serialized'); + switch (this) { + case MangaTrackingState.reading: return 0; + case MangaTrackingState.completed: return 1; + case MangaTrackingState.planToWatch: return 2; + case MangaTrackingState.dropped: return 3; + case MangaTrackingState.all: return -1; + } + } +} + +class MangaTrackingStateConverter implements JsonConverter { + const MangaTrackingStateConverter(); + + @override + MangaTrackingState fromJson(int json) { + switch (json) { + case 0: return MangaTrackingState.reading; + case 1: return MangaTrackingState.completed; + case 2: return MangaTrackingState.planToWatch; + case 3: return MangaTrackingState.dropped; + } + + return MangaTrackingState.planToWatch; + } + + @override + int toJson(MangaTrackingState state) => state.toInteger(); +} + +/// Data about a tracked anime +@freezed +class MangaTrackingData with _$MangaTrackingData{ + factory MangaTrackingData( + /// The ID of the manga + String id, + /// The state of the manga + @MangaTrackingStateConverter() MangaTrackingState state, + /// The title of the manga + String title, + /// Chapters read. + int chaptersRead, + /// Chapters read. + int volumesOwned, + /// Episodes watched. + int? chaptersTotal, + /// URL to the thumbnail/cover art for the manga. + String thumbnailUrl, + ) = _MangaTrackingData; + + /// JSON + factory MangaTrackingData.fromJson(Map json) => _$MangaTrackingDataFromJson(json); +} diff --git a/lib/src/data/manga.freezed.dart b/lib/src/data/manga.freezed.dart new file mode 100644 index 0000000..4b95b1a --- /dev/null +++ b/lib/src/data/manga.freezed.dart @@ -0,0 +1,328 @@ +// 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 'manga.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'); + +MangaTrackingData _$MangaTrackingDataFromJson(Map json) { + return _MangaTrackingData.fromJson(json); +} + +/// @nodoc +mixin _$MangaTrackingData { + /// The ID of the manga + String get id => throw _privateConstructorUsedError; + + /// The state of the manga + @MangaTrackingStateConverter() + MangaTrackingState get state => throw _privateConstructorUsedError; + + /// The title of the manga + String get title => throw _privateConstructorUsedError; + + /// Chapters read. + int get chaptersRead => throw _privateConstructorUsedError; + + /// Chapters read. + int get volumesOwned => throw _privateConstructorUsedError; + + /// Episodes watched. + int? get chaptersTotal => throw _privateConstructorUsedError; + + /// URL to the thumbnail/cover art for the manga. + String get thumbnailUrl => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $MangaTrackingDataCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $MangaTrackingDataCopyWith<$Res> { + factory $MangaTrackingDataCopyWith( + MangaTrackingData value, $Res Function(MangaTrackingData) then) = + _$MangaTrackingDataCopyWithImpl<$Res>; + $Res call( + {String id, + @MangaTrackingStateConverter() MangaTrackingState state, + String title, + int chaptersRead, + int volumesOwned, + int? chaptersTotal, + String thumbnailUrl}); +} + +/// @nodoc +class _$MangaTrackingDataCopyWithImpl<$Res> + implements $MangaTrackingDataCopyWith<$Res> { + _$MangaTrackingDataCopyWithImpl(this._value, this._then); + + final MangaTrackingData _value; + // ignore: unused_field + final $Res Function(MangaTrackingData) _then; + + @override + $Res call({ + Object? id = freezed, + Object? state = freezed, + Object? title = freezed, + Object? chaptersRead = freezed, + Object? volumesOwned = freezed, + Object? chaptersTotal = freezed, + Object? thumbnailUrl = freezed, + }) { + return _then(_value.copyWith( + id: id == freezed + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + state: state == freezed + ? _value.state + : state // ignore: cast_nullable_to_non_nullable + as MangaTrackingState, + title: title == freezed + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + chaptersRead: chaptersRead == freezed + ? _value.chaptersRead + : chaptersRead // ignore: cast_nullable_to_non_nullable + as int, + volumesOwned: volumesOwned == freezed + ? _value.volumesOwned + : volumesOwned // ignore: cast_nullable_to_non_nullable + as int, + chaptersTotal: chaptersTotal == freezed + ? _value.chaptersTotal + : chaptersTotal // ignore: cast_nullable_to_non_nullable + as int?, + thumbnailUrl: thumbnailUrl == freezed + ? _value.thumbnailUrl + : thumbnailUrl // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +abstract class _$$_MangaTrackingDataCopyWith<$Res> + implements $MangaTrackingDataCopyWith<$Res> { + factory _$$_MangaTrackingDataCopyWith(_$_MangaTrackingData value, + $Res Function(_$_MangaTrackingData) then) = + __$$_MangaTrackingDataCopyWithImpl<$Res>; + @override + $Res call( + {String id, + @MangaTrackingStateConverter() MangaTrackingState state, + String title, + int chaptersRead, + int volumesOwned, + int? chaptersTotal, + String thumbnailUrl}); +} + +/// @nodoc +class __$$_MangaTrackingDataCopyWithImpl<$Res> + extends _$MangaTrackingDataCopyWithImpl<$Res> + implements _$$_MangaTrackingDataCopyWith<$Res> { + __$$_MangaTrackingDataCopyWithImpl( + _$_MangaTrackingData _value, $Res Function(_$_MangaTrackingData) _then) + : super(_value, (v) => _then(v as _$_MangaTrackingData)); + + @override + _$_MangaTrackingData get _value => super._value as _$_MangaTrackingData; + + @override + $Res call({ + Object? id = freezed, + Object? state = freezed, + Object? title = freezed, + Object? chaptersRead = freezed, + Object? volumesOwned = freezed, + Object? chaptersTotal = freezed, + Object? thumbnailUrl = freezed, + }) { + return _then(_$_MangaTrackingData( + id == freezed + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + state == freezed + ? _value.state + : state // ignore: cast_nullable_to_non_nullable + as MangaTrackingState, + title == freezed + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + chaptersRead == freezed + ? _value.chaptersRead + : chaptersRead // ignore: cast_nullable_to_non_nullable + as int, + volumesOwned == freezed + ? _value.volumesOwned + : volumesOwned // ignore: cast_nullable_to_non_nullable + as int, + chaptersTotal == freezed + ? _value.chaptersTotal + : chaptersTotal // ignore: cast_nullable_to_non_nullable + as int?, + thumbnailUrl == freezed + ? _value.thumbnailUrl + : thumbnailUrl // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$_MangaTrackingData implements _MangaTrackingData { + _$_MangaTrackingData( + this.id, + @MangaTrackingStateConverter() this.state, + this.title, + this.chaptersRead, + this.volumesOwned, + this.chaptersTotal, + this.thumbnailUrl); + + factory _$_MangaTrackingData.fromJson(Map json) => + _$$_MangaTrackingDataFromJson(json); + + /// The ID of the manga + @override + final String id; + + /// The state of the manga + @override + @MangaTrackingStateConverter() + final MangaTrackingState state; + + /// The title of the manga + @override + final String title; + + /// Chapters read. + @override + final int chaptersRead; + + /// Chapters read. + @override + final int volumesOwned; + + /// Episodes watched. + @override + final int? chaptersTotal; + + /// URL to the thumbnail/cover art for the manga. + @override + final String thumbnailUrl; + + @override + String toString() { + return 'MangaTrackingData(id: $id, state: $state, title: $title, chaptersRead: $chaptersRead, volumesOwned: $volumesOwned, chaptersTotal: $chaptersTotal, thumbnailUrl: $thumbnailUrl)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_MangaTrackingData && + const DeepCollectionEquality().equals(other.id, id) && + const DeepCollectionEquality().equals(other.state, state) && + const DeepCollectionEquality().equals(other.title, title) && + const DeepCollectionEquality() + .equals(other.chaptersRead, chaptersRead) && + const DeepCollectionEquality() + .equals(other.volumesOwned, volumesOwned) && + const DeepCollectionEquality() + .equals(other.chaptersTotal, chaptersTotal) && + const DeepCollectionEquality() + .equals(other.thumbnailUrl, thumbnailUrl)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(id), + const DeepCollectionEquality().hash(state), + const DeepCollectionEquality().hash(title), + const DeepCollectionEquality().hash(chaptersRead), + const DeepCollectionEquality().hash(volumesOwned), + const DeepCollectionEquality().hash(chaptersTotal), + const DeepCollectionEquality().hash(thumbnailUrl)); + + @JsonKey(ignore: true) + @override + _$$_MangaTrackingDataCopyWith<_$_MangaTrackingData> get copyWith => + __$$_MangaTrackingDataCopyWithImpl<_$_MangaTrackingData>( + this, _$identity); + + @override + Map toJson() { + return _$$_MangaTrackingDataToJson( + this, + ); + } +} + +abstract class _MangaTrackingData implements MangaTrackingData { + factory _MangaTrackingData( + final String id, + @MangaTrackingStateConverter() final MangaTrackingState state, + final String title, + final int chaptersRead, + final int volumesOwned, + final int? chaptersTotal, + final String thumbnailUrl) = _$_MangaTrackingData; + + factory _MangaTrackingData.fromJson(Map json) = + _$_MangaTrackingData.fromJson; + + @override + + /// The ID of the manga + String get id; + @override + + /// The state of the manga + @MangaTrackingStateConverter() + MangaTrackingState get state; + @override + + /// The title of the manga + String get title; + @override + + /// Chapters read. + int get chaptersRead; + @override + + /// Chapters read. + int get volumesOwned; + @override + + /// Episodes watched. + int? get chaptersTotal; + @override + + /// URL to the thumbnail/cover art for the manga. + String get thumbnailUrl; + @override + @JsonKey(ignore: true) + _$$_MangaTrackingDataCopyWith<_$_MangaTrackingData> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/src/data/manga.g.dart b/lib/src/data/manga.g.dart new file mode 100644 index 0000000..063c83a --- /dev/null +++ b/lib/src/data/manga.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'manga.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$_MangaTrackingData _$$_MangaTrackingDataFromJson(Map json) => + _$_MangaTrackingData( + json['id'] as String, + const MangaTrackingStateConverter().fromJson(json['state'] as int), + json['title'] as String, + json['chaptersRead'] as int, + json['volumesOwned'] as int, + json['chaptersTotal'] as int?, + json['thumbnailUrl'] as String, + ); + +Map _$$_MangaTrackingDataToJson( + _$_MangaTrackingData instance) => + { + 'id': instance.id, + 'state': const MangaTrackingStateConverter().toJson(instance.state), + 'title': instance.title, + 'chaptersRead': instance.chaptersRead, + 'volumesOwned': instance.volumesOwned, + 'chaptersTotal': instance.chaptersTotal, + 'thumbnailUrl': instance.thumbnailUrl, + }; diff --git a/lib/src/data/search_result.dart b/lib/src/data/search_result.dart index 6d29867..a757d27 100644 --- a/lib/src/data/search_result.dart +++ b/lib/src/data/search_result.dart @@ -1,8 +1,8 @@ -class AnimeSearchResult { - const AnimeSearchResult( +class SearchResult { + const SearchResult( this.title, this.id, - this.episodesTotal, + this.total, this.thumbnailUrl, this.description, ); @@ -16,9 +16,9 @@ class AnimeSearchResult { /// The URL to a thumbnail image. final String thumbnailUrl; - /// The amount of total episodes. If null, it means that there is not total amount - /// of episodes set. - final int? episodesTotal; + /// The amount of total episodes or chapters. If null, it means that there is not + /// total amount of episodes set. + final int? total; /// The description of the anime final String description; diff --git a/lib/src/service/database.dart b/lib/src/service/database.dart index a8c0922..def694b 100644 --- a/lib/src/service/database.dart +++ b/lib/src/service/database.dart @@ -1,7 +1,9 @@ import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/data/manga.dart'; import 'package:sqflite/sqflite.dart'; const animeTable = 'Anime'; +const mangaTable = 'Manga'; Future _createDatabase(Database db, int version) async { await db.execute( @@ -15,6 +17,18 @@ Future _createDatabase(Database db, int version) async { title TEXT NOT NULL )''', ); + await db.execute( + ''' + CREATE TABLE $mangaTable( + id TEXT NOT NULL PRIMARY KEY, + state INTEGER NOT NULL, + chaptersTotal INTEGER, + chaptersRead INTEGER NOT NULL, + volumesOwned INTEGER NOT NULL, + thumbnailUrl TEXT NOT NULL, + title TEXT NOT NULL + )''', + ); } class DatabaseService { @@ -36,6 +50,15 @@ class DatabaseService { .map((Map anime) => AnimeTrackingData.fromJson(anime)) .toList(); } + + Future> loadMangas() async { + final mangas = await _db.query(mangaTable); + + return mangas + .cast>() + .map((Map manga) => MangaTrackingData.fromJson(manga)) + .toList(); + } Future addAnime(AnimeTrackingData data) async { await _db.insert( @@ -52,4 +75,20 @@ class DatabaseService { whereArgs: [data.id], ); } + + Future addManga(MangaTrackingData data) async { + await _db.insert( + mangaTable, + data.toJson(), + ); + } + + Future updateManga(MangaTrackingData data) async { + await _db.update( + mangaTable, + data.toJson(), + where: 'id = ?', + whereArgs: [data.id], + ); + } } diff --git a/lib/src/ui/bloc/anime_list_bloc.dart b/lib/src/ui/bloc/anime_list_bloc.dart index 3b478d8..cf23472 100644 --- a/lib/src/ui/bloc/anime_list_bloc.dart +++ b/lib/src/ui/bloc/anime_list_bloc.dart @@ -1,5 +1,6 @@ import 'dart:math'; import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/data/manga.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/service/database.dart'; import 'package:bloc/bloc.dart'; @@ -13,11 +14,15 @@ part 'anime_list_bloc.freezed.dart'; class AnimeListBloc extends Bloc { AnimeListBloc() : super(AnimeListState()) { on(_onAnimeAdded); - on(_onIncremented); - on(_onDecremented); + on(_onMangaAdded); + on(_onAnimeIncremented); + on(_onAnimeDecremented); on(_onAnimesLoaded); on(_onAnimesFiltered); on(_onTrackingTypeChanged); + on(_onMangasFiltered); + on(_onMangaIncremented); + on(_onMangaDecremented); } Future _onAnimeAdded(AnimeAddedEvent event, Emitter emit) async { @@ -34,7 +39,21 @@ class AnimeListBloc extends Bloc { ); } - Future _onIncremented(AnimeEpisodeIncrementedEvent event, Emitter emit) async { + Future _onMangaAdded(MangaAddedEvent event, Emitter emit) async { + // Add the manga to the database + await GetIt.I.get().addManga(event.data); + + emit( + state.copyWith( + mangas: List.from([ + ...state.mangas, + event.data, + ]), + ), + ); + } + + Future _onAnimeIncremented(AnimeEpisodeIncrementedEvent event, Emitter emit) async { final index = state.animes.indexWhere((item) => item.id == event.id); if (index == -1) return; @@ -56,7 +75,7 @@ class AnimeListBloc extends Bloc { await GetIt.I.get().updateAnime(newAnime); } - Future _onDecremented(AnimeEpisodeDecrementedEvent event, Emitter emit) async { + Future _onAnimeDecremented(AnimeEpisodeDecrementedEvent event, Emitter emit) async { final index = state.animes.indexWhere((item) => item.id == event.id); if (index == -1) return; @@ -82,6 +101,7 @@ class AnimeListBloc extends Bloc { emit( state.copyWith( animes: await GetIt.I.get().loadAnimes(), + mangas: await GetIt.I.get().loadMangas(), ), ); } @@ -89,11 +109,19 @@ class AnimeListBloc extends Bloc { Future _onAnimesFiltered(AnimeFilterChangedEvent event, Emitter emit) async { emit( state.copyWith( - filterState: event.filterState, + animeFilterState: event.filterState, ), ); } + Future _onMangasFiltered(MangaFilterChangedEvent event, Emitter emit) async { + emit( + state.copyWith( + mangaFilterState: event.filterState, + ), + ); + } + Future _onTrackingTypeChanged(AnimeTrackingTypeChanged event, Emitter emit) async { emit( state.copyWith( @@ -101,4 +129,48 @@ class AnimeListBloc extends Bloc { ), ); } + + Future _onMangaIncremented(MangaChapterIncrementedEvent event, Emitter emit) async { + final index = state.mangas.indexWhere((item) => item.id == event.id); + if (index == -1) return; + + final manga = state.mangas[index]; + if (manga.chaptersTotal != null && manga.chaptersRead + 1 > manga.chaptersTotal!) return; + + final newList = List.from(state.mangas); + final newManga = manga.copyWith( + chaptersRead: manga.chaptersRead + 1, + ); + newList[index] = newManga; + + emit( + state.copyWith( + mangas: newList, + ), + ); + + await GetIt.I.get().updateManga(newManga); + } + + Future _onMangaDecremented(MangaChapterDecrementedEvent event, Emitter emit) async { + final index = state.mangas.indexWhere((item) => item.id == event.id); + if (index == -1) return; + + final manga = state.mangas[index]; + if (manga.chaptersRead - 1 < 0) return; + + final newList = List.from(state.mangas); + final newManga = manga.copyWith( + chaptersRead: manga.chaptersRead - 1, + ); + newList[index] = newManga; + + emit( + state.copyWith( + mangas: newList, + ), + ); + + await GetIt.I.get().updateManga(newManga); + } } diff --git a/lib/src/ui/bloc/anime_list_bloc.freezed.dart b/lib/src/ui/bloc/anime_list_bloc.freezed.dart index a7ad45e..b9ccd32 100644 --- a/lib/src/ui/bloc/anime_list_bloc.freezed.dart +++ b/lib/src/ui/bloc/anime_list_bloc.freezed.dart @@ -17,7 +17,9 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$AnimeListState { List get animes => throw _privateConstructorUsedError; - AnimeTrackingState get filterState => throw _privateConstructorUsedError; + List get mangas => throw _privateConstructorUsedError; + AnimeTrackingState get animeFilterState => throw _privateConstructorUsedError; + MangaTrackingState get mangaFilterState => throw _privateConstructorUsedError; TrackingMediumType get trackingType => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -32,7 +34,9 @@ abstract class $AnimeListStateCopyWith<$Res> { _$AnimeListStateCopyWithImpl<$Res>; $Res call( {List animes, - AnimeTrackingState filterState, + List mangas, + AnimeTrackingState animeFilterState, + MangaTrackingState mangaFilterState, TrackingMediumType trackingType}); } @@ -48,7 +52,9 @@ class _$AnimeListStateCopyWithImpl<$Res> @override $Res call({ Object? animes = freezed, - Object? filterState = freezed, + Object? mangas = freezed, + Object? animeFilterState = freezed, + Object? mangaFilterState = freezed, Object? trackingType = freezed, }) { return _then(_value.copyWith( @@ -56,10 +62,18 @@ class _$AnimeListStateCopyWithImpl<$Res> ? _value.animes : animes // ignore: cast_nullable_to_non_nullable as List, - filterState: filterState == freezed - ? _value.filterState - : filterState // ignore: cast_nullable_to_non_nullable + mangas: mangas == freezed + ? _value.mangas + : mangas // ignore: cast_nullable_to_non_nullable + as List, + animeFilterState: animeFilterState == freezed + ? _value.animeFilterState + : animeFilterState // ignore: cast_nullable_to_non_nullable as AnimeTrackingState, + mangaFilterState: mangaFilterState == freezed + ? _value.mangaFilterState + : mangaFilterState // ignore: cast_nullable_to_non_nullable + as MangaTrackingState, trackingType: trackingType == freezed ? _value.trackingType : trackingType // ignore: cast_nullable_to_non_nullable @@ -77,7 +91,9 @@ abstract class _$$_AnimeListStateCopyWith<$Res> @override $Res call( {List animes, - AnimeTrackingState filterState, + List mangas, + AnimeTrackingState animeFilterState, + MangaTrackingState mangaFilterState, TrackingMediumType trackingType}); } @@ -95,7 +111,9 @@ class __$$_AnimeListStateCopyWithImpl<$Res> @override $Res call({ Object? animes = freezed, - Object? filterState = freezed, + Object? mangas = freezed, + Object? animeFilterState = freezed, + Object? mangaFilterState = freezed, Object? trackingType = freezed, }) { return _then(_$_AnimeListState( @@ -103,10 +121,18 @@ class __$$_AnimeListStateCopyWithImpl<$Res> ? _value._animes : animes // ignore: cast_nullable_to_non_nullable as List, - filterState: filterState == freezed - ? _value.filterState - : filterState // ignore: cast_nullable_to_non_nullable + mangas: mangas == freezed + ? _value._mangas + : mangas // ignore: cast_nullable_to_non_nullable + as List, + animeFilterState: animeFilterState == freezed + ? _value.animeFilterState + : animeFilterState // ignore: cast_nullable_to_non_nullable as AnimeTrackingState, + mangaFilterState: mangaFilterState == freezed + ? _value.mangaFilterState + : mangaFilterState // ignore: cast_nullable_to_non_nullable + as MangaTrackingState, trackingType: trackingType == freezed ? _value.trackingType : trackingType // ignore: cast_nullable_to_non_nullable @@ -120,9 +146,12 @@ class __$$_AnimeListStateCopyWithImpl<$Res> class _$_AnimeListState implements _AnimeListState { _$_AnimeListState( {final List animes = const [], - this.filterState = AnimeTrackingState.watching, + final List mangas = const [], + this.animeFilterState = AnimeTrackingState.watching, + this.mangaFilterState = MangaTrackingState.reading, this.trackingType = TrackingMediumType.anime}) - : _animes = animes; + : _animes = animes, + _mangas = mangas; final List _animes; @override @@ -132,16 +161,27 @@ class _$_AnimeListState implements _AnimeListState { return EqualUnmodifiableListView(_animes); } + final List _mangas; @override @JsonKey() - final AnimeTrackingState filterState; + List get mangas { + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_mangas); + } + + @override + @JsonKey() + final AnimeTrackingState animeFilterState; + @override + @JsonKey() + final MangaTrackingState mangaFilterState; @override @JsonKey() final TrackingMediumType trackingType; @override String toString() { - return 'AnimeListState(animes: $animes, filterState: $filterState, trackingType: $trackingType)'; + return 'AnimeListState(animes: $animes, mangas: $mangas, animeFilterState: $animeFilterState, mangaFilterState: $mangaFilterState, trackingType: $trackingType)'; } @override @@ -150,8 +190,11 @@ class _$_AnimeListState implements _AnimeListState { (other.runtimeType == runtimeType && other is _$_AnimeListState && const DeepCollectionEquality().equals(other._animes, _animes) && + const DeepCollectionEquality().equals(other._mangas, _mangas) && const DeepCollectionEquality() - .equals(other.filterState, filterState) && + .equals(other.animeFilterState, animeFilterState) && + const DeepCollectionEquality() + .equals(other.mangaFilterState, mangaFilterState) && const DeepCollectionEquality() .equals(other.trackingType, trackingType)); } @@ -160,7 +203,9 @@ class _$_AnimeListState implements _AnimeListState { int get hashCode => Object.hash( runtimeType, const DeepCollectionEquality().hash(_animes), - const DeepCollectionEquality().hash(filterState), + const DeepCollectionEquality().hash(_mangas), + const DeepCollectionEquality().hash(animeFilterState), + const DeepCollectionEquality().hash(mangaFilterState), const DeepCollectionEquality().hash(trackingType)); @JsonKey(ignore: true) @@ -172,13 +217,19 @@ class _$_AnimeListState implements _AnimeListState { abstract class _AnimeListState implements AnimeListState { factory _AnimeListState( {final List animes, - final AnimeTrackingState filterState, + final List mangas, + final AnimeTrackingState animeFilterState, + final MangaTrackingState mangaFilterState, final TrackingMediumType trackingType}) = _$_AnimeListState; @override List get animes; @override - AnimeTrackingState get filterState; + List get mangas; + @override + AnimeTrackingState get animeFilterState; + @override + MangaTrackingState get mangaFilterState; @override TrackingMediumType get trackingType; @override diff --git a/lib/src/ui/bloc/anime_list_event.dart b/lib/src/ui/bloc/anime_list_event.dart index 3ee321b..4707526 100644 --- a/lib/src/ui/bloc/anime_list_event.dart +++ b/lib/src/ui/bloc/anime_list_event.dart @@ -26,7 +26,7 @@ class AnimeAddedEvent extends AnimeListEvent { /// Triggered when animes are to be loaded from the database class AnimesLoadedEvent extends AnimeListEvent {} -/// Triggered when the filter is changed +/// Triggered when the anime filter is changed class AnimeFilterChangedEvent extends AnimeListEvent { AnimeFilterChangedEvent(this.filterState); @@ -41,3 +41,32 @@ class AnimeTrackingTypeChanged extends AnimeListEvent { /// The type we switched to final TrackingMediumType type; } + +class MangaAddedEvent extends AnimeListEvent { + MangaAddedEvent(this.data); + + /// The manga to add. + final MangaTrackingData data; +} + +/// Triggered when the manga filter is changed +class MangaFilterChangedEvent extends AnimeListEvent { + MangaFilterChangedEvent(this.filterState); + + /// The state to filter + final MangaTrackingState filterState; +} + +class MangaChapterIncrementedEvent extends AnimeListEvent { + MangaChapterIncrementedEvent(this.id); + + /// The ID of the anime + final String id; +} + +class MangaChapterDecrementedEvent extends AnimeListEvent { + MangaChapterDecrementedEvent(this.id); + + /// The ID of the anime + final String id; +} diff --git a/lib/src/ui/bloc/anime_list_state.dart b/lib/src/ui/bloc/anime_list_state.dart index e00992e..efc1398 100644 --- a/lib/src/ui/bloc/anime_list_state.dart +++ b/lib/src/ui/bloc/anime_list_state.dart @@ -4,7 +4,9 @@ part of 'anime_list_bloc.dart'; class AnimeListState with _$AnimeListState { factory AnimeListState({ @Default([]) List animes, - @Default(AnimeTrackingState.watching) AnimeTrackingState filterState, + @Default([]) List mangas, + @Default(AnimeTrackingState.watching) AnimeTrackingState animeFilterState, + @Default(MangaTrackingState.reading) MangaTrackingState mangaFilterState, @Default(TrackingMediumType.anime) TrackingMediumType trackingType, }) = _AnimeListState; } diff --git a/lib/src/ui/bloc/anime_search_bloc.dart b/lib/src/ui/bloc/anime_search_bloc.dart index 9865bd1..7def042 100644 --- a/lib/src/ui/bloc/anime_search_bloc.dart +++ b/lib/src/ui/bloc/anime_search_bloc.dart @@ -1,5 +1,7 @@ import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/data/manga.dart'; import 'package:anitrack/src/data/search_result.dart'; +import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/ui/constants.dart'; import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart' as list; import 'package:anitrack/src/ui/bloc/navigation_bloc.dart'; @@ -17,7 +19,7 @@ class AnimeSearchBloc extends Bloc { on(_onRequested); on(_onQueryChanged); on(_onQuerySubmitted); - on(_onAnimeAdded); + on(_onResultTapped); } Future _onRequested(AnimeSearchRequestedEvent event, Emitter emit) async { @@ -26,6 +28,7 @@ class AnimeSearchBloc extends Bloc { searchQuery: '', working: false, searchResults: [], + trackingType: event.type, ), ); @@ -52,42 +55,70 @@ class AnimeSearchBloc extends Bloc { working: true, ), ); - - final result = await Jikan().searchAnime( - query: state.searchQuery, - ); - emit( - state.copyWith( - working: false, - ), - ); - - emit( - state.copyWith( - searchResults: result.map((Anime anime) => AnimeSearchResult( - anime.title, - anime.malId.toString(), - anime.episodes, - anime.imageUrl, - anime.synopsis ?? '', - ),).toList(), - ), - ); + if (state.trackingType == TrackingMediumType.anime) { + // Anime + final result = await Jikan().searchAnime( + query: state.searchQuery, + ); + + emit( + state.copyWith( + working: false, + searchResults: result.map((Anime anime) => SearchResult( + anime.title, + anime.malId.toString(), + anime.episodes, + anime.imageUrl, + anime.synopsis ?? '', + ),).toList(), + ), + ); + } else { + // Manga + final result = await Jikan().searchManga( + query: state.searchQuery, + ); + + emit( + state.copyWith( + working: false, + searchResults: result.map((Manga manga) => SearchResult( + manga.title, + manga.malId.toString(), + manga.chapters, + manga.imageUrl, + manga.synopsis ?? '', + ),).toList(), + ), + ); + } } - Future _onAnimeAdded(AnimeAddedEvent event, Emitter emit) async { + Future _onResultTapped(ResultTappedEvent event, Emitter emit) async { GetIt.I.get().add( - list.AnimeAddedEvent( - AnimeTrackingData( - event.result.id, - AnimeTrackingState.watching, - event.result.title, - 0, - event.result.episodesTotal, - event.result.thumbnailUrl, - ), - ), + state.trackingType == TrackingMediumType.anime ? + list.AnimeAddedEvent( + AnimeTrackingData( + event.result.id, + AnimeTrackingState.watching, + event.result.title, + 0, + event.result.total, + event.result.thumbnailUrl, + ), + ) : + list.MangaAddedEvent( + MangaTrackingData( + event.result.id, + MangaTrackingState.reading, + event.result.title, + 0, + 0, + event.result.total, + event.result.thumbnailUrl, + ), + ) ); GetIt.I.get().add( diff --git a/lib/src/ui/bloc/anime_search_bloc.freezed.dart b/lib/src/ui/bloc/anime_search_bloc.freezed.dart index 8758683..e6f00ea 100644 --- a/lib/src/ui/bloc/anime_search_bloc.freezed.dart +++ b/lib/src/ui/bloc/anime_search_bloc.freezed.dart @@ -16,10 +16,10 @@ final _privateConstructorUsedError = UnsupportedError( /// @nodoc mixin _$AnimeSearchState { + TrackingMediumType get trackingType => throw _privateConstructorUsedError; String get searchQuery => throw _privateConstructorUsedError; bool get working => throw _privateConstructorUsedError; - List get searchResults => - throw _privateConstructorUsedError; + List get searchResults => throw _privateConstructorUsedError; @JsonKey(ignore: true) $AnimeSearchStateCopyWith get copyWith => @@ -32,9 +32,10 @@ abstract class $AnimeSearchStateCopyWith<$Res> { AnimeSearchState value, $Res Function(AnimeSearchState) then) = _$AnimeSearchStateCopyWithImpl<$Res>; $Res call( - {String searchQuery, + {TrackingMediumType trackingType, + String searchQuery, bool working, - List searchResults}); + List searchResults}); } /// @nodoc @@ -48,11 +49,16 @@ class _$AnimeSearchStateCopyWithImpl<$Res> @override $Res call({ + Object? trackingType = freezed, Object? searchQuery = freezed, Object? working = freezed, Object? searchResults = freezed, }) { return _then(_value.copyWith( + trackingType: trackingType == freezed + ? _value.trackingType + : trackingType // ignore: cast_nullable_to_non_nullable + as TrackingMediumType, searchQuery: searchQuery == freezed ? _value.searchQuery : searchQuery // ignore: cast_nullable_to_non_nullable @@ -64,7 +70,7 @@ class _$AnimeSearchStateCopyWithImpl<$Res> searchResults: searchResults == freezed ? _value.searchResults : searchResults // ignore: cast_nullable_to_non_nullable - as List, + as List, )); } } @@ -77,9 +83,10 @@ abstract class _$$_AnimeSearchStateCopyWith<$Res> __$$_AnimeSearchStateCopyWithImpl<$Res>; @override $Res call( - {String searchQuery, + {TrackingMediumType trackingType, + String searchQuery, bool working, - List searchResults}); + List searchResults}); } /// @nodoc @@ -95,11 +102,16 @@ class __$$_AnimeSearchStateCopyWithImpl<$Res> @override $Res call({ + Object? trackingType = freezed, Object? searchQuery = freezed, Object? working = freezed, Object? searchResults = freezed, }) { return _then(_$_AnimeSearchState( + trackingType: trackingType == freezed + ? _value.trackingType + : trackingType // ignore: cast_nullable_to_non_nullable + as TrackingMediumType, searchQuery: searchQuery == freezed ? _value.searchQuery : searchQuery // ignore: cast_nullable_to_non_nullable @@ -111,7 +123,7 @@ class __$$_AnimeSearchStateCopyWithImpl<$Res> searchResults: searchResults == freezed ? _value._searchResults : searchResults // ignore: cast_nullable_to_non_nullable - as List, + as List, )); } } @@ -120,28 +132,32 @@ class __$$_AnimeSearchStateCopyWithImpl<$Res> class _$_AnimeSearchState implements _AnimeSearchState { _$_AnimeSearchState( - {this.searchQuery = '', + {this.trackingType = TrackingMediumType.anime, + this.searchQuery = '', this.working = false, - final List searchResults = const []}) + final List searchResults = const []}) : _searchResults = searchResults; + @override + @JsonKey() + final TrackingMediumType trackingType; @override @JsonKey() final String searchQuery; @override @JsonKey() final bool working; - final List _searchResults; + final List _searchResults; @override @JsonKey() - List get searchResults { + List get searchResults { // ignore: implicit_dynamic_type return EqualUnmodifiableListView(_searchResults); } @override String toString() { - return 'AnimeSearchState(searchQuery: $searchQuery, working: $working, searchResults: $searchResults)'; + return 'AnimeSearchState(trackingType: $trackingType, searchQuery: $searchQuery, working: $working, searchResults: $searchResults)'; } @override @@ -149,6 +165,8 @@ class _$_AnimeSearchState implements _AnimeSearchState { return identical(this, other) || (other.runtimeType == runtimeType && other is _$_AnimeSearchState && + const DeepCollectionEquality() + .equals(other.trackingType, trackingType) && const DeepCollectionEquality() .equals(other.searchQuery, searchQuery) && const DeepCollectionEquality().equals(other.working, working) && @@ -159,6 +177,7 @@ class _$_AnimeSearchState implements _AnimeSearchState { @override int get hashCode => Object.hash( runtimeType, + const DeepCollectionEquality().hash(trackingType), const DeepCollectionEquality().hash(searchQuery), const DeepCollectionEquality().hash(working), const DeepCollectionEquality().hash(_searchResults)); @@ -171,16 +190,19 @@ class _$_AnimeSearchState implements _AnimeSearchState { abstract class _AnimeSearchState implements AnimeSearchState { factory _AnimeSearchState( - {final String searchQuery, + {final TrackingMediumType trackingType, + final String searchQuery, final bool working, - final List searchResults}) = _$_AnimeSearchState; + final List searchResults}) = _$_AnimeSearchState; + @override + TrackingMediumType get trackingType; @override String get searchQuery; @override bool get working; @override - List get searchResults; + List get searchResults; @override @JsonKey(ignore: true) _$$_AnimeSearchStateCopyWith<_$_AnimeSearchState> get copyWith => diff --git a/lib/src/ui/bloc/anime_search_event.dart b/lib/src/ui/bloc/anime_search_event.dart index 53ce855..721e4f4 100644 --- a/lib/src/ui/bloc/anime_search_event.dart +++ b/lib/src/ui/bloc/anime_search_event.dart @@ -2,7 +2,12 @@ part of 'anime_search_bloc.dart'; abstract class AnimeSearchEvent {} -class AnimeSearchRequestedEvent extends AnimeSearchEvent {} +class AnimeSearchRequestedEvent extends AnimeSearchEvent { + AnimeSearchRequestedEvent(this.type); + + /// The tracking type for which we want to search + TrackingMediumType type; +} /// Triggered when the search query is changed. class SearchQueryChangedEvent extends AnimeSearchEvent { @@ -16,9 +21,9 @@ class SearchQueryChangedEvent extends AnimeSearchEvent { class SearchQuerySubmittedEvent extends AnimeSearchEvent {} /// Triggered when an anime is added to the tracking list -class AnimeAddedEvent extends AnimeSearchEvent { - AnimeAddedEvent(this.result); +class ResultTappedEvent extends AnimeSearchEvent { + ResultTappedEvent(this.result); /// The search result to add. - final AnimeSearchResult result; + final SearchResult result; } diff --git a/lib/src/ui/bloc/anime_search_state.dart b/lib/src/ui/bloc/anime_search_state.dart index de19f8e..b6ad766 100644 --- a/lib/src/ui/bloc/anime_search_state.dart +++ b/lib/src/ui/bloc/anime_search_state.dart @@ -3,8 +3,9 @@ part of 'anime_search_bloc.dart'; @freezed class AnimeSearchState with _$AnimeSearchState { factory AnimeSearchState({ + @Default(TrackingMediumType.anime) TrackingMediumType trackingType, @Default('') String searchQuery, @Default(false) bool working, - @Default([]) List searchResults, + @Default([]) List searchResults, }) = _AnimeSearchState; } diff --git a/lib/src/ui/pages/anime_list.dart b/lib/src/ui/pages/anime_list.dart index 27a6c93..8ec2ed0 100644 --- a/lib/src/ui/pages/anime_list.dart +++ b/lib/src/ui/pages/anime_list.dart @@ -1,4 +1,5 @@ import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/data/manga.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart'; import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart'; @@ -24,6 +25,79 @@ class AnimeListPage extends StatelessWidget { case TrackingMediumType.manga: return 'Manga'; } } + + Widget _getPopupButton(BuildContext context, AnimeListState state) { + switch (state.trackingType) { + case TrackingMediumType.anime: + return PopupMenuButton( + icon: Icon( + Icons.filter_list, + ), + initialValue: state.animeFilterState, + onSelected: (filterState) { + context.read().add( + AnimeFilterChangedEvent(filterState), + ); + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: AnimeTrackingState.watching, + child: Text('Watching'), + ), + const PopupMenuItem( + value: AnimeTrackingState.completed, + child: Text('Completed'), + ), + const PopupMenuItem( + value: AnimeTrackingState.planToWatch, + child: Text('Plan to watch'), + ), + const PopupMenuItem( + value: AnimeTrackingState.dropped, + child: Text('Dropped'), + ), + const PopupMenuItem( + value: AnimeTrackingState.all, + child: Text('All'), + ), + ], + ); + case TrackingMediumType.manga: + return PopupMenuButton( + icon: Icon( + Icons.filter_list, + ), + initialValue: state.mangaFilterState, + onSelected: (filterState) { + context.read().add( + MangaFilterChangedEvent(filterState), + ); + }, + itemBuilder: (_) => [ + const PopupMenuItem( + value: MangaTrackingState.reading, + child: Text('Reading'), + ), + const PopupMenuItem( + value: MangaTrackingState.completed, + child: Text('Completed'), + ), + const PopupMenuItem( + value: MangaTrackingState.planToWatch, + child: Text('Plan to watch'), + ), + const PopupMenuItem( + value: MangaTrackingState.dropped, + child: Text('Dropped'), + ), + const PopupMenuItem( + value: MangaTrackingState.all, + child: Text('All'), + ), + ], + ); + } + } @override Widget build(BuildContext context) { @@ -35,39 +109,7 @@ class AnimeListPage extends StatelessWidget { _getPageTitle(state.trackingType) ), actions: [ - PopupMenuButton( - icon: Icon( - Icons.filter_list, - ), - initialValue: state.filterState, - onSelected: (filterState) { - context.read().add( - AnimeFilterChangedEvent(filterState), - ); - }, - itemBuilder: (_) => [ - const PopupMenuItem( - value: AnimeTrackingState.watching, - child: Text('Watching'), - ), - const PopupMenuItem( - value: AnimeTrackingState.completed, - child: Text('Completed'), - ), - const PopupMenuItem( - value: AnimeTrackingState.planToWatch, - child: Text('Plan to watch'), - ), - const PopupMenuItem( - value: AnimeTrackingState.dropped, - child: Text('Dropped'), - ), - const PopupMenuItem( - value: AnimeTrackingState.all, - child: Text('All'), - ), - ], - ), + _getPopupButton(context, state), ], ), body: PageView( @@ -77,8 +119,8 @@ class AnimeListPage extends StatelessWidget { itemCount: state.animes.length, itemBuilder: (context, index) { final anime = state.animes[index]; - if (state.filterState != AnimeTrackingState.all) { - if (anime.state != state.filterState) return Container(); + if (state.animeFilterState != AnimeTrackingState.all) { + if (anime.state != state.animeFilterState) return Container(); } return ListItem( @@ -103,13 +145,42 @@ class AnimeListPage extends StatelessWidget { ); }, ), - Placeholder(), + ListView.builder( + itemCount: state.mangas.length, + itemBuilder: (context, index) { + final manga = state.mangas[index]; + if (state.mangaFilterState != MangaTrackingState.all) { + if (manga.state != state.mangaFilterState) return Container(); + } + + return ListItem( + title: manga.title, + thumbnailUrl: manga.thumbnailUrl, + extra: [ + Text( + '${manga.chaptersRead}/${manga.chaptersTotal ?? "???"}', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + onLeftSwipe: () { + context.read().add( + MangaChapterDecrementedEvent(state.mangas[index].id), + ); + }, + onRightSwipe: () { + context.read().add( + MangaChapterIncrementedEvent(state.mangas[index].id), + ); + }, + ); + }, + ), ], ), floatingActionButton: FloatingActionButton( onPressed: () { context.read().add( - AnimeSearchRequestedEvent(), + AnimeSearchRequestedEvent(state.trackingType), ); }, tooltip: 'Increment', @@ -120,7 +191,6 @@ class AnimeListPage extends StatelessWidget { 0 : 1, onTap: (int index) { - _controller.jumpToPage(index); context.read().add( AnimeTrackingTypeChanged( index == 0 ? @@ -128,6 +198,8 @@ class AnimeListPage extends StatelessWidget { TrackingMediumType.manga, ), ); + + _controller.jumpToPage(index); }, items: [ BottomBarItem( diff --git a/lib/src/ui/pages/anime_search.dart b/lib/src/ui/pages/anime_search.dart index f8ae66e..cee0b27 100644 --- a/lib/src/ui/pages/anime_search.dart +++ b/lib/src/ui/pages/anime_search.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; import 'package:anitrack/src/ui/widgets/list_item.dart'; @@ -20,7 +21,11 @@ class AnimeSearchPage extends StatelessWidget { builder: (context, state) { return Scaffold( appBar: AppBar( - title: Text('Anime Search'), + title: Text( + state.trackingType == TrackingMediumType.anime ? + 'Anime Search' : + 'Manga Search', + ), ), body: Column( children: [ @@ -60,7 +65,7 @@ class AnimeSearchPage extends StatelessWidget { return InkWell( onTap: () { context.read().add( - AnimeAddedEvent(item), + ResultTappedEvent(item), ); }, child: ListItem(