feat(meta): Implement manga tracking

This commit is contained in:
PapaTutuWawa 2023-02-04 12:48:57 +01:00
parent cd1291a192
commit 7af2277bb2
15 changed files with 883 additions and 126 deletions

70
lib/src/data/manga.dart Normal file
View File

@ -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<MangaTrackingState, int> {
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<String, dynamic> json) => _$MangaTrackingDataFromJson(json);
}

View File

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

30
lib/src/data/manga.g.dart Normal file
View File

@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'manga.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$_MangaTrackingData _$$_MangaTrackingDataFromJson(Map<String, dynamic> 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<String, dynamic> _$$_MangaTrackingDataToJson(
_$_MangaTrackingData instance) =>
<String, dynamic>{
'id': instance.id,
'state': const MangaTrackingStateConverter().toJson(instance.state),
'title': instance.title,
'chaptersRead': instance.chaptersRead,
'volumesOwned': instance.volumesOwned,
'chaptersTotal': instance.chaptersTotal,
'thumbnailUrl': instance.thumbnailUrl,
};

View File

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

View File

@ -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<void> _createDatabase(Database db, int version) async {
await db.execute(
@ -15,6 +17,18 @@ Future<void> _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<String, dynamic> anime) => AnimeTrackingData.fromJson(anime))
.toList();
}
Future<List<MangaTrackingData>> loadMangas() async {
final mangas = await _db.query(mangaTable);
return mangas
.cast<Map<String, dynamic>>()
.map((Map<String, dynamic> manga) => MangaTrackingData.fromJson(manga))
.toList();
}
Future<void> addAnime(AnimeTrackingData data) async {
await _db.insert(
@ -52,4 +75,20 @@ class DatabaseService {
whereArgs: [data.id],
);
}
Future<void> addManga(MangaTrackingData data) async {
await _db.insert(
mangaTable,
data.toJson(),
);
}
Future<void> updateManga(MangaTrackingData data) async {
await _db.update(
mangaTable,
data.toJson(),
where: 'id = ?',
whereArgs: [data.id],
);
}
}

View File

@ -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<AnimeListEvent, AnimeListState> {
AnimeListBloc() : super(AnimeListState()) {
on<AnimeAddedEvent>(_onAnimeAdded);
on<AnimeEpisodeIncrementedEvent>(_onIncremented);
on<AnimeEpisodeDecrementedEvent>(_onDecremented);
on<MangaAddedEvent>(_onMangaAdded);
on<AnimeEpisodeIncrementedEvent>(_onAnimeIncremented);
on<AnimeEpisodeDecrementedEvent>(_onAnimeDecremented);
on<AnimesLoadedEvent>(_onAnimesLoaded);
on<AnimeFilterChangedEvent>(_onAnimesFiltered);
on<AnimeTrackingTypeChanged>(_onTrackingTypeChanged);
on<MangaFilterChangedEvent>(_onMangasFiltered);
on<MangaChapterIncrementedEvent>(_onMangaIncremented);
on<MangaChapterDecrementedEvent>(_onMangaDecremented);
}
Future<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeListState> emit) async {
@ -34,7 +39,21 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
);
}
Future<void> _onIncremented(AnimeEpisodeIncrementedEvent event, Emitter<AnimeListState> emit) async {
Future<void> _onMangaAdded(MangaAddedEvent event, Emitter<AnimeListState> emit) async {
// Add the manga to the database
await GetIt.I.get<DatabaseService>().addManga(event.data);
emit(
state.copyWith(
mangas: List.from([
...state.mangas,
event.data,
]),
),
);
}
Future<void> _onAnimeIncremented(AnimeEpisodeIncrementedEvent event, Emitter<AnimeListState> emit) async {
final index = state.animes.indexWhere((item) => item.id == event.id);
if (index == -1) return;
@ -56,7 +75,7 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
await GetIt.I.get<DatabaseService>().updateAnime(newAnime);
}
Future<void> _onDecremented(AnimeEpisodeDecrementedEvent event, Emitter<AnimeListState> emit) async {
Future<void> _onAnimeDecremented(AnimeEpisodeDecrementedEvent event, Emitter<AnimeListState> emit) async {
final index = state.animes.indexWhere((item) => item.id == event.id);
if (index == -1) return;
@ -82,6 +101,7 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
emit(
state.copyWith(
animes: await GetIt.I.get<DatabaseService>().loadAnimes(),
mangas: await GetIt.I.get<DatabaseService>().loadMangas(),
),
);
}
@ -89,11 +109,19 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
Future<void> _onAnimesFiltered(AnimeFilterChangedEvent event, Emitter<AnimeListState> emit) async {
emit(
state.copyWith(
filterState: event.filterState,
animeFilterState: event.filterState,
),
);
}
Future<void> _onMangasFiltered(MangaFilterChangedEvent event, Emitter<AnimeListState> emit) async {
emit(
state.copyWith(
mangaFilterState: event.filterState,
),
);
}
Future<void> _onTrackingTypeChanged(AnimeTrackingTypeChanged event, Emitter<AnimeListState> emit) async {
emit(
state.copyWith(
@ -101,4 +129,48 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
),
);
}
Future<void> _onMangaIncremented(MangaChapterIncrementedEvent event, Emitter<AnimeListState> 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<MangaTrackingData>.from(state.mangas);
final newManga = manga.copyWith(
chaptersRead: manga.chaptersRead + 1,
);
newList[index] = newManga;
emit(
state.copyWith(
mangas: newList,
),
);
await GetIt.I.get<DatabaseService>().updateManga(newManga);
}
Future<void> _onMangaDecremented(MangaChapterDecrementedEvent event, Emitter<AnimeListState> 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<MangaTrackingData>.from(state.mangas);
final newManga = manga.copyWith(
chaptersRead: manga.chaptersRead - 1,
);
newList[index] = newManga;
emit(
state.copyWith(
mangas: newList,
),
);
await GetIt.I.get<DatabaseService>().updateManga(newManga);
}
}

View File

@ -17,7 +17,9 @@ final _privateConstructorUsedError = UnsupportedError(
/// @nodoc
mixin _$AnimeListState {
List<AnimeTrackingData> get animes => throw _privateConstructorUsedError;
AnimeTrackingState get filterState => throw _privateConstructorUsedError;
List<MangaTrackingData> 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<AnimeTrackingData> animes,
AnimeTrackingState filterState,
List<MangaTrackingData> 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<AnimeTrackingData>,
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<MangaTrackingData>,
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<AnimeTrackingData> animes,
AnimeTrackingState filterState,
List<MangaTrackingData> 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<AnimeTrackingData>,
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<MangaTrackingData>,
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<AnimeTrackingData> animes = const [],
this.filterState = AnimeTrackingState.watching,
final List<MangaTrackingData> mangas = const [],
this.animeFilterState = AnimeTrackingState.watching,
this.mangaFilterState = MangaTrackingState.reading,
this.trackingType = TrackingMediumType.anime})
: _animes = animes;
: _animes = animes,
_mangas = mangas;
final List<AnimeTrackingData> _animes;
@override
@ -132,16 +161,27 @@ class _$_AnimeListState implements _AnimeListState {
return EqualUnmodifiableListView(_animes);
}
final List<MangaTrackingData> _mangas;
@override
@JsonKey()
final AnimeTrackingState filterState;
List<MangaTrackingData> 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<AnimeTrackingData> animes,
final AnimeTrackingState filterState,
final List<MangaTrackingData> mangas,
final AnimeTrackingState animeFilterState,
final MangaTrackingState mangaFilterState,
final TrackingMediumType trackingType}) = _$_AnimeListState;
@override
List<AnimeTrackingData> get animes;
@override
AnimeTrackingState get filterState;
List<MangaTrackingData> get mangas;
@override
AnimeTrackingState get animeFilterState;
@override
MangaTrackingState get mangaFilterState;
@override
TrackingMediumType get trackingType;
@override

View File

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

View File

@ -4,7 +4,9 @@ part of 'anime_list_bloc.dart';
class AnimeListState with _$AnimeListState {
factory AnimeListState({
@Default([]) List<AnimeTrackingData> animes,
@Default(AnimeTrackingState.watching) AnimeTrackingState filterState,
@Default([]) List<MangaTrackingData> mangas,
@Default(AnimeTrackingState.watching) AnimeTrackingState animeFilterState,
@Default(MangaTrackingState.reading) MangaTrackingState mangaFilterState,
@Default(TrackingMediumType.anime) TrackingMediumType trackingType,
}) = _AnimeListState;
}

View File

@ -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<AnimeSearchEvent, AnimeSearchState> {
on<AnimeSearchRequestedEvent>(_onRequested);
on<SearchQueryChangedEvent>(_onQueryChanged);
on<SearchQuerySubmittedEvent>(_onQuerySubmitted);
on<AnimeAddedEvent>(_onAnimeAdded);
on<ResultTappedEvent>(_onResultTapped);
}
Future<void> _onRequested(AnimeSearchRequestedEvent event, Emitter<AnimeSearchState> emit) async {
@ -26,6 +28,7 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
searchQuery: '',
working: false,
searchResults: [],
trackingType: event.type,
),
);
@ -52,42 +55,70 @@ class AnimeSearchBloc extends Bloc<AnimeSearchEvent, AnimeSearchState> {
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<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeSearchState> emit) async {
Future<void> _onResultTapped(ResultTappedEvent event, Emitter<AnimeSearchState> emit) async {
GetIt.I.get<list.AnimeListBloc>().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<NavigationBloc>().add(

View File

@ -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<AnimeSearchResult> get searchResults =>
throw _privateConstructorUsedError;
List<SearchResult> get searchResults => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$AnimeSearchStateCopyWith<AnimeSearchState> 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<AnimeSearchResult> searchResults});
List<SearchResult> 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<AnimeSearchResult>,
as List<SearchResult>,
));
}
}
@ -77,9 +83,10 @@ abstract class _$$_AnimeSearchStateCopyWith<$Res>
__$$_AnimeSearchStateCopyWithImpl<$Res>;
@override
$Res call(
{String searchQuery,
{TrackingMediumType trackingType,
String searchQuery,
bool working,
List<AnimeSearchResult> searchResults});
List<SearchResult> 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<AnimeSearchResult>,
as List<SearchResult>,
));
}
}
@ -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<AnimeSearchResult> searchResults = const []})
final List<SearchResult> searchResults = const []})
: _searchResults = searchResults;
@override
@JsonKey()
final TrackingMediumType trackingType;
@override
@JsonKey()
final String searchQuery;
@override
@JsonKey()
final bool working;
final List<AnimeSearchResult> _searchResults;
final List<SearchResult> _searchResults;
@override
@JsonKey()
List<AnimeSearchResult> get searchResults {
List<SearchResult> 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<AnimeSearchResult> searchResults}) = _$_AnimeSearchState;
final List<SearchResult> searchResults}) = _$_AnimeSearchState;
@override
TrackingMediumType get trackingType;
@override
String get searchQuery;
@override
bool get working;
@override
List<AnimeSearchResult> get searchResults;
List<SearchResult> get searchResults;
@override
@JsonKey(ignore: true)
_$$_AnimeSearchStateCopyWith<_$_AnimeSearchState> get copyWith =>

View File

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

View File

@ -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<AnimeSearchResult> searchResults,
@Default([]) List<SearchResult> searchResults,
}) = _AnimeSearchState;
}

View File

@ -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<AnimeListBloc>().add(
AnimeFilterChangedEvent(filterState),
);
},
itemBuilder: (_) => [
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.watching,
child: Text('Watching'),
),
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.completed,
child: Text('Completed'),
),
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.planToWatch,
child: Text('Plan to watch'),
),
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.dropped,
child: Text('Dropped'),
),
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.all,
child: Text('All'),
),
],
);
case TrackingMediumType.manga:
return PopupMenuButton(
icon: Icon(
Icons.filter_list,
),
initialValue: state.mangaFilterState,
onSelected: (filterState) {
context.read<AnimeListBloc>().add(
MangaFilterChangedEvent(filterState),
);
},
itemBuilder: (_) => [
const PopupMenuItem<MangaTrackingState>(
value: MangaTrackingState.reading,
child: Text('Reading'),
),
const PopupMenuItem<MangaTrackingState>(
value: MangaTrackingState.completed,
child: Text('Completed'),
),
const PopupMenuItem<MangaTrackingState>(
value: MangaTrackingState.planToWatch,
child: Text('Plan to watch'),
),
const PopupMenuItem<MangaTrackingState>(
value: MangaTrackingState.dropped,
child: Text('Dropped'),
),
const PopupMenuItem<MangaTrackingState>(
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<AnimeListBloc>().add(
AnimeFilterChangedEvent(filterState),
);
},
itemBuilder: (_) => [
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.watching,
child: Text('Watching'),
),
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.completed,
child: Text('Completed'),
),
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.planToWatch,
child: Text('Plan to watch'),
),
const PopupMenuItem<AnimeTrackingState>(
value: AnimeTrackingState.dropped,
child: Text('Dropped'),
),
const PopupMenuItem<AnimeTrackingState>(
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<AnimeListBloc>().add(
MangaChapterDecrementedEvent(state.mangas[index].id),
);
},
onRightSwipe: () {
context.read<AnimeListBloc>().add(
MangaChapterIncrementedEvent(state.mangas[index].id),
);
},
);
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<AnimeSearchBloc>().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<AnimeListBloc>().add(
AnimeTrackingTypeChanged(
index == 0 ?
@ -128,6 +198,8 @@ class AnimeListPage extends StatelessWidget {
TrackingMediumType.manga,
),
);
_controller.jumpToPage(index);
},
items: <BottomBarItem>[
BottomBarItem(

View File

@ -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<AnimeSearchBloc>().add(
AnimeAddedEvent(item),
ResultTappedEvent(item),
);
},
child: ListItem(