From aa72095b98282f60fdbbb6982f214f0b2cd2072c Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sat, 25 Jul 2026 14:49:43 +0200 Subject: [PATCH] feat: Allow tracking who you are watching anime with --- assets/i18n/strings_de.i18n.json | 17 +- assets/i18n/strings_en.i18n.json | 10 + lib/src/data/anime_watcher.dart | 19 ++ lib/src/data/anime_watcher.freezed.dart | 279 ++++++++++++++++++ lib/src/data/anime_watcher.g.dart | 13 + lib/src/service/database.dart | 122 +++++++- .../migrations/0001_anime_watcher.dart | 21 ++ lib/src/ui/bloc/details_bloc.dart | 68 +++++ lib/src/ui/bloc/details_bloc.freezed.dart | 49 +-- lib/src/ui/bloc/details_event.dart | 21 ++ lib/src/ui/bloc/details_state.dart | 1 + lib/src/ui/pages/details.dart | 94 +++++- lib/src/ui/pages/details/watcher_sheet.dart | 180 +++++++++++ 13 files changed, 870 insertions(+), 24 deletions(-) create mode 100644 lib/src/data/anime_watcher.dart create mode 100644 lib/src/data/anime_watcher.freezed.dart create mode 100644 lib/src/data/anime_watcher.g.dart create mode 100644 lib/src/service/migrations/0001_anime_watcher.dart create mode 100644 lib/src/ui/pages/details/watcher_sheet.dart diff --git a/assets/i18n/strings_de.i18n.json b/assets/i18n/strings_de.i18n.json index 81ef487..4cf5b4f 100644 --- a/assets/i18n/strings_de.i18n.json +++ b/assets/i18n/strings_de.i18n.json @@ -45,7 +45,22 @@ "cancelButton": "Abbrechen", "readState": "Lese-Status", "episodes": "Folgen", - "chapters": "Kapitel" + "chapters": "Kapitel", + "mal": "MAL", + "watchingWith": { + "title": "Zusammen schauen mit", + "add": "Hinzufügen", + "name": "Name", + "alone": "Alleine" + }, + "watchers": { + "removeTitle": "Zuschauer entfernen", + "removeBody": "Bist Du dir sicher, dass du \"$watcher\" als Zuschauer entfernen möchtest?" + }, + "details": { + "titleJa": "Japanischer Titel", + "textCopied": "Text wurde in die Zwischenablage kopiert" + } }, "data": { "dropped": "Abgebrochen", diff --git a/assets/i18n/strings_en.i18n.json b/assets/i18n/strings_en.i18n.json index 569d432..102154d 100644 --- a/assets/i18n/strings_en.i18n.json +++ b/assets/i18n/strings_en.i18n.json @@ -50,6 +50,16 @@ "chapters": "Chapters", "volumesOwned": "Volumes owned", "mal": "MAL", + "watchingWith": { + "title": "Watching with", + "add": "Add", + "name": "Name", + "alone": "Watching alone" + }, + "watchers": { + "removeTitle": "Remove Watcher", + "removeBody": "Are you sure you want to remove \"$watcher\" as a watcher?" + }, "details": { "titleJa": "Japanese Title", "textCopied": "Text copied to clipboard" diff --git a/lib/src/data/anime_watcher.dart b/lib/src/data/anime_watcher.dart new file mode 100644 index 0000000..6b944f5 --- /dev/null +++ b/lib/src/data/anime_watcher.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'anime_watcher.freezed.dart'; +part 'anime_watcher.g.dart'; + +/// Data about someone to watch an anime with. +@freezed +abstract class AnimeWatcher with _$AnimeWatcher { + factory AnimeWatcher( + /// Name of the person to watch the anime with. + String name, + ) = _AnimeWatcher; + + AnimeWatcher._(); + + /// JSON + factory AnimeWatcher.fromJson(Map json) => + _$AnimeWatcherFromJson(json); +} diff --git a/lib/src/data/anime_watcher.freezed.dart b/lib/src/data/anime_watcher.freezed.dart new file mode 100644 index 0000000..31c85c0 --- /dev/null +++ b/lib/src/data/anime_watcher.freezed.dart @@ -0,0 +1,279 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// 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, unnecessary_question_mark + +part of 'anime_watcher.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$AnimeWatcher { + +/// Name of the person to watch the anime with. + String get name; +/// Create a copy of AnimeWatcher +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AnimeWatcherCopyWith get copyWith => _$AnimeWatcherCopyWithImpl(this as AnimeWatcher, _$identity); + + /// Serializes this AnimeWatcher to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AnimeWatcher&&(identical(other.name, name) || other.name == name)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name); + +@override +String toString() { + return 'AnimeWatcher(name: $name)'; +} + + +} + +/// @nodoc +abstract mixin class $AnimeWatcherCopyWith<$Res> { + factory $AnimeWatcherCopyWith(AnimeWatcher value, $Res Function(AnimeWatcher) _then) = _$AnimeWatcherCopyWithImpl; +@useResult +$Res call({ + String name +}); + + + + +} +/// @nodoc +class _$AnimeWatcherCopyWithImpl<$Res> + implements $AnimeWatcherCopyWith<$Res> { + _$AnimeWatcherCopyWithImpl(this._self, this._then); + + final AnimeWatcher _self; + final $Res Function(AnimeWatcher) _then; + +/// Create a copy of AnimeWatcher +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AnimeWatcher]. +extension AnimeWatcherPatterns on AnimeWatcher { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AnimeWatcher value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AnimeWatcher() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AnimeWatcher value) $default,){ +final _that = this; +switch (_that) { +case _AnimeWatcher(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AnimeWatcher value)? $default,){ +final _that = this; +switch (_that) { +case _AnimeWatcher() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AnimeWatcher() when $default != null: +return $default(_that.name);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name) $default,) {final _that = this; +switch (_that) { +case _AnimeWatcher(): +return $default(_that.name);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name)? $default,) {final _that = this; +switch (_that) { +case _AnimeWatcher() when $default != null: +return $default(_that.name);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AnimeWatcher extends AnimeWatcher { + _AnimeWatcher(this.name): super._(); + factory _AnimeWatcher.fromJson(Map json) => _$AnimeWatcherFromJson(json); + +/// Name of the person to watch the anime with. +@override final String name; + +/// Create a copy of AnimeWatcher +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AnimeWatcherCopyWith<_AnimeWatcher> get copyWith => __$AnimeWatcherCopyWithImpl<_AnimeWatcher>(this, _$identity); + +@override +Map toJson() { + return _$AnimeWatcherToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AnimeWatcher&&(identical(other.name, name) || other.name == name)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name); + +@override +String toString() { + return 'AnimeWatcher(name: $name)'; +} + + +} + +/// @nodoc +abstract mixin class _$AnimeWatcherCopyWith<$Res> implements $AnimeWatcherCopyWith<$Res> { + factory _$AnimeWatcherCopyWith(_AnimeWatcher value, $Res Function(_AnimeWatcher) _then) = __$AnimeWatcherCopyWithImpl; +@override @useResult +$Res call({ + String name +}); + + + + +} +/// @nodoc +class __$AnimeWatcherCopyWithImpl<$Res> + implements _$AnimeWatcherCopyWith<$Res> { + __$AnimeWatcherCopyWithImpl(this._self, this._then); + + final _AnimeWatcher _self; + final $Res Function(_AnimeWatcher) _then; + +/// Create a copy of AnimeWatcher +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,}) { + return _then(_AnimeWatcher( +null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/lib/src/data/anime_watcher.g.dart b/lib/src/data/anime_watcher.g.dart new file mode 100644 index 0000000..f2bdb94 --- /dev/null +++ b/lib/src/data/anime_watcher.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'anime_watcher.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_AnimeWatcher _$AnimeWatcherFromJson(Map json) => + _AnimeWatcher(json['name'] as String); + +Map _$AnimeWatcherToJson(_AnimeWatcher instance) => + {'name': instance.name}; diff --git a/lib/src/service/database.dart b/lib/src/service/database.dart index f81905a..707d492 100644 --- a/lib/src/service/database.dart +++ b/lib/src/service/database.dart @@ -1,13 +1,17 @@ import 'dart:io'; import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/data/anime_watcher.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:anitrack/src/service/migrations/0001_anime_watcher.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const animeTable = 'Anime'; const mangaTable = 'Manga'; +const animeWatcherTable = 'AnimeWatchers'; +const animeWatcherJoinTable = 'AnimeWatchersJoin'; extension BoolToInt on bool { int toInt() { @@ -51,11 +55,31 @@ Future _createDatabase(Database db, int version) async { score INTEGER )''', ); + await db.execute( + ''' + CREATE TABLE $animeWatcherTable( + name TEXT NOT NULL PRIMARY KEY, + )''', + ); + await db.execute( + ''' + CREATE TABLE $animeWatcherJoinTable( + name TEXT NOT NULL, + anime TEXT NOT NULL, + CONSTRAINT pk_watcher_join_table PRIMARY KEY(name, anime), + CONSTRAINT fk_watcher FOREIGN KEY (name) REFERENCES $animeWatcherTable(name), + CONSTRAINT fk_anime FOREIGN KEY (anime) REFERENCES $animeTable(id) + )''', + ); } class DatabaseService { + /// The actual sqlite database connection. late final Database _db; + /// Cached AnimeWatchers. + List? _watcherCache; + Future initialize() async { // Allow initializing the database on Windows and Linux as well. if (Platform.isLinux || Platform.isWindows) { @@ -65,7 +89,7 @@ class DatabaseService { _db = await openDatabase( 'anitrack.db', - version: 3, + version: 4, 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. @@ -84,8 +108,12 @@ class DatabaseService { if (oldVersion < 3) { await migrateFromV2ToV3(db); } + if (oldVersion < 4) { + await migrateFromV3ToV4(db); + } }, ); + print(_db.path); } Future> loadAnimes() async { @@ -191,4 +219,96 @@ class DatabaseService { .toList() .first; } + + /// Queries all anime watchers. + /// + /// Returns the list of AnimeWatchers in the database. May be empty. + Future> getAnimeWatchers() async { + if (_watcherCache != null) { + return _watcherCache!; + } + + final dbResults = await _db.query( + animeWatcherTable, + ); + + final results = dbResults + .cast>() + .map(AnimeWatcher.fromJson) + .toList(); + _watcherCache = results; + return results; + } + + /// Removes an AnimeWatcher from the database. + /// + /// @watcher The watcher to be removed from the database. + Future deleteAnimeWatcher(AnimeWatcher watcher) async { + await _db.delete( + animeWatcherJoinTable, + where: 'name = ?', + whereArgs: [watcher.name], + ); + await _db.delete( + animeWatcherTable, + where: 'name = ?', + whereArgs: [watcher.name], + ); + _watcherCache?.removeWhere((item) => item.name == watcher.name); + } + + /// Adds an AnimeWatcher to the database. + /// + /// @watcher The watcher to add to the database. + Future addWatcher(AnimeWatcher watcher) async { + await _db.insert( + animeWatcherTable, + watcher.toJson(), + ); + _watcherCache?.add(watcher); + } + + Future> getWatchersForAnime( + AnimeTrackingData anime, + ) async { + final dbResults = await _db.rawQuery( + ''' + SELECT * FROM $animeWatcherTable + JOIN $animeWatcherJoinTable ON $animeWatcherTable.name = $animeWatcherJoinTable.name + WHERE anime = ?; + ''', + [ + anime.id, + ], + ); + + return dbResults + .cast>() + .map(AnimeWatcher.fromJson) + .toList(); + } + + Future associateWatcherWithAnime( + AnimeWatcher watcher, + AnimeTrackingData anime, + ) async { + await _db.insert( + animeWatcherJoinTable, + { + 'name': watcher.name, + 'anime': anime.id, + }, + ); + } + + Future disassociateWatcherWithAnime( + AnimeWatcher watcher, + AnimeTrackingData anime, + ) async { + await _db.delete( + animeWatcherJoinTable, + where: 'name = ? and anime = ?', + whereArgs: [watcher.name, anime.id], + ); + } } diff --git a/lib/src/service/migrations/0001_anime_watcher.dart b/lib/src/service/migrations/0001_anime_watcher.dart new file mode 100644 index 0000000..70f5889 --- /dev/null +++ b/lib/src/service/migrations/0001_anime_watcher.dart @@ -0,0 +1,21 @@ +import 'package:anitrack/src/service/database.dart'; +import 'package:sqflite/sqflite.dart'; + +Future migrateFromV3ToV4(Database db) async { + await db.execute( + ''' + CREATE TABLE $animeWatcherTable( + name TEXT NOT NULL PRIMARY KEY + )''', + ); + await db.execute( + ''' + CREATE TABLE $animeWatcherJoinTable( + name TEXT NOT NULL, + anime TEXT NOT NULL, + CONSTRAINT pk_watcher_join_table PRIMARY KEY(name, anime), + CONSTRAINT fk_watcher FOREIGN KEY (name) REFERENCES $animeWatcherTable(name), + CONSTRAINT fk_anime FOREIGN KEY (anime) REFERENCES $animeTable(id), + )''', + ); +} diff --git a/lib/src/ui/bloc/details_bloc.dart b/lib/src/ui/bloc/details_bloc.dart index f7a9455..9224a59 100644 --- a/lib/src/ui/bloc/details_bloc.dart +++ b/lib/src/ui/bloc/details_bloc.dart @@ -1,4 +1,5 @@ import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/data/anime_watcher.dart'; import 'package:anitrack/src/data/manga.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/service/database.dart'; @@ -19,17 +20,26 @@ class DetailsBloc extends Bloc { on(_onMangaRequested); on(_onDetailsUpdated); on(_onItemRemoved); + on(_onAnimeWatcherAdded); + on(_onAnimeWatcherRemoved); + on(_onAnimeWatcherDeleted); } Future _onAnimeRequested( AnimeDetailsRequestedEvent event, Emitter emit, ) async { + // Find out what watchers there are + final watchers = await GetIt.I.get().getWatchersForAnime( + event.anime, + ); + emit( state.copyWith( trackingType: TrackingMediumType.anime, heroImagePrefix: event.heroImagePrefix, data: event.anime, + animeWatchers: watchers, ), ); @@ -117,4 +127,62 @@ class DetailsBloc extends Bloc { // Navigate back GetIt.I.get().add(PoppedRouteEvent()); } + + Future _onAnimeWatcherAdded( + AnimeWatcherAddedEvent event, + Emitter emit, + ) async { + await GetIt.I.get().associateWatcherWithAnime( + event.watcher, + state.data! as AnimeTrackingData, + ); + emit( + state.copyWith( + animeWatchers: List.of([ + ...state.animeWatchers, + event.watcher, + ]), + ), + ); + } + + Future _onAnimeWatcherRemoved( + AnimeWatcherRemovedEvent event, + Emitter emit, + ) async { + await GetIt.I.get().disassociateWatcherWithAnime( + event.watcher, + state.data! as AnimeTrackingData, + ); + emit( + state.copyWith( + animeWatchers: state.animeWatchers + .where((watcher) => watcher.name != event.watcher.name) + .toList(), + ), + ); + } + + Future _onAnimeWatcherDeleted( + AnimeWatcherDeletedEvent event, + Emitter emit, + ) async { + await GetIt.I.get().deleteAnimeWatcher(event.watcher); + + emit( + state.copyWith( + animeWatchers: state.animeWatchers + .where((watcher) => watcher.name != event.watcher.name) + .toList(), + ), + ); + } + + Future addAnimeWatcher(AnimeWatcher watcher) async { + await GetIt.I.get().addWatcher(watcher); + } + + Future> getAllAnimeWatchers() async { + return GetIt.I.get().getAnimeWatchers(); + } } diff --git a/lib/src/ui/bloc/details_bloc.freezed.dart b/lib/src/ui/bloc/details_bloc.freezed.dart index bea28a6..90e4ea0 100644 --- a/lib/src/ui/bloc/details_bloc.freezed.dart +++ b/lib/src/ui/bloc/details_bloc.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$DetailsState { - TrackingMedium? get data; String? get heroImagePrefix; TrackingMediumType get trackingType; + TrackingMedium? get data; String? get heroImagePrefix; TrackingMediumType get trackingType; List get animeWatchers; /// Create a copy of DetailsState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $DetailsStateCopyWith get copyWith => _$DetailsStateCopyWithImpl Object.hash(runtimeType,data,heroImagePrefix,trackingType); +int get hashCode => Object.hash(runtimeType,data,heroImagePrefix,trackingType,const DeepCollectionEquality().hash(animeWatchers)); @override String toString() { - return 'DetailsState(data: $data, heroImagePrefix: $heroImagePrefix, trackingType: $trackingType)'; + return 'DetailsState(data: $data, heroImagePrefix: $heroImagePrefix, trackingType: $trackingType, animeWatchers: $animeWatchers)'; } @@ -45,7 +45,7 @@ abstract mixin class $DetailsStateCopyWith<$Res> { factory $DetailsStateCopyWith(DetailsState value, $Res Function(DetailsState) _then) = _$DetailsStateCopyWithImpl; @useResult $Res call({ - TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType + TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List animeWatchers }); @@ -62,12 +62,13 @@ class _$DetailsStateCopyWithImpl<$Res> /// Create a copy of DetailsState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? heroImagePrefix = freezed,Object? trackingType = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? heroImagePrefix = freezed,Object? trackingType = null,Object? animeWatchers = null,}) { return _then(_self.copyWith( data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable as TrackingMedium?,heroImagePrefix: freezed == heroImagePrefix ? _self.heroImagePrefix : heroImagePrefix // ignore: cast_nullable_to_non_nullable as String?,trackingType: null == trackingType ? _self.trackingType : trackingType // ignore: cast_nullable_to_non_nullable -as TrackingMediumType, +as TrackingMediumType,animeWatchers: null == animeWatchers ? _self.animeWatchers : animeWatchers // ignore: cast_nullable_to_non_nullable +as List, )); } @@ -152,10 +153,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List animeWatchers)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _DetailsState() when $default != null: -return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _: +return $default(_that.data,_that.heroImagePrefix,_that.trackingType,_that.animeWatchers);case _: return orElse(); } @@ -173,10 +174,10 @@ return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List animeWatchers) $default,) {final _that = this; switch (_that) { case _DetailsState(): -return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _: +return $default(_that.data,_that.heroImagePrefix,_that.trackingType,_that.animeWatchers);case _: throw StateError('Unexpected subclass'); } @@ -193,10 +194,10 @@ return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List animeWatchers)? $default,) {final _that = this; switch (_that) { case _DetailsState() when $default != null: -return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _: +return $default(_that.data,_that.heroImagePrefix,_that.trackingType,_that.animeWatchers);case _: return null; } @@ -208,12 +209,19 @@ return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _: class _DetailsState implements DetailsState { - _DetailsState({this.data, this.heroImagePrefix, this.trackingType = TrackingMediumType.anime}); + _DetailsState({this.data, this.heroImagePrefix, this.trackingType = TrackingMediumType.anime, final List animeWatchers = const []}): _animeWatchers = animeWatchers; @override final TrackingMedium? data; @override final String? heroImagePrefix; @override@JsonKey() final TrackingMediumType trackingType; + final List _animeWatchers; +@override@JsonKey() List get animeWatchers { + if (_animeWatchers is EqualUnmodifiableListView) return _animeWatchers; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_animeWatchers); +} + /// Create a copy of DetailsState /// with the given fields replaced by the non-null parameter values. @@ -225,16 +233,16 @@ _$DetailsStateCopyWith<_DetailsState> get copyWith => __$DetailsStateCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _DetailsState&&(identical(other.data, data) || other.data == data)&&(identical(other.heroImagePrefix, heroImagePrefix) || other.heroImagePrefix == heroImagePrefix)&&(identical(other.trackingType, trackingType) || other.trackingType == trackingType)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _DetailsState&&(identical(other.data, data) || other.data == data)&&(identical(other.heroImagePrefix, heroImagePrefix) || other.heroImagePrefix == heroImagePrefix)&&(identical(other.trackingType, trackingType) || other.trackingType == trackingType)&&const DeepCollectionEquality().equals(other._animeWatchers, _animeWatchers)); } @override -int get hashCode => Object.hash(runtimeType,data,heroImagePrefix,trackingType); +int get hashCode => Object.hash(runtimeType,data,heroImagePrefix,trackingType,const DeepCollectionEquality().hash(_animeWatchers)); @override String toString() { - return 'DetailsState(data: $data, heroImagePrefix: $heroImagePrefix, trackingType: $trackingType)'; + return 'DetailsState(data: $data, heroImagePrefix: $heroImagePrefix, trackingType: $trackingType, animeWatchers: $animeWatchers)'; } @@ -245,7 +253,7 @@ abstract mixin class _$DetailsStateCopyWith<$Res> implements $DetailsStateCopyWi factory _$DetailsStateCopyWith(_DetailsState value, $Res Function(_DetailsState) _then) = __$DetailsStateCopyWithImpl; @override @useResult $Res call({ - TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType + TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List animeWatchers }); @@ -262,12 +270,13 @@ class __$DetailsStateCopyWithImpl<$Res> /// Create a copy of DetailsState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? heroImagePrefix = freezed,Object? trackingType = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? heroImagePrefix = freezed,Object? trackingType = null,Object? animeWatchers = null,}) { return _then(_DetailsState( data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable as TrackingMedium?,heroImagePrefix: freezed == heroImagePrefix ? _self.heroImagePrefix : heroImagePrefix // ignore: cast_nullable_to_non_nullable as String?,trackingType: null == trackingType ? _self.trackingType : trackingType // ignore: cast_nullable_to_non_nullable -as TrackingMediumType, +as TrackingMediumType,animeWatchers: null == animeWatchers ? _self._animeWatchers : animeWatchers // ignore: cast_nullable_to_non_nullable +as List, )); } diff --git a/lib/src/ui/bloc/details_event.dart b/lib/src/ui/bloc/details_event.dart index 8fc4ee4..a41ea28 100644 --- a/lib/src/ui/bloc/details_event.dart +++ b/lib/src/ui/bloc/details_event.dart @@ -36,3 +36,24 @@ class ItemRemovedEvent extends DetailsEvent { /// The type of medium of the item final TrackingMediumType trackingType; } + +class AnimeWatcherRemovedEvent extends DetailsEvent { + AnimeWatcherRemovedEvent(this.watcher); + + /// The watcher that was removed + final AnimeWatcher watcher; +} + +class AnimeWatcherAddedEvent extends DetailsEvent { + AnimeWatcherAddedEvent(this.watcher); + + /// The watcher that was added + final AnimeWatcher watcher; +} + +class AnimeWatcherDeletedEvent extends DetailsEvent { + AnimeWatcherDeletedEvent(this.watcher); + + /// The watcher that was deleted + final AnimeWatcher watcher; +} diff --git a/lib/src/ui/bloc/details_state.dart b/lib/src/ui/bloc/details_state.dart index f9ae5a2..e149d80 100644 --- a/lib/src/ui/bloc/details_state.dart +++ b/lib/src/ui/bloc/details_state.dart @@ -6,5 +6,6 @@ abstract class DetailsState with _$DetailsState { TrackingMedium? data, String? heroImagePrefix, @Default(TrackingMediumType.anime) TrackingMediumType trackingType, + @Default([]) List animeWatchers, }) = _DetailsState; } diff --git a/lib/src/ui/pages/details.dart b/lib/src/ui/pages/details.dart index ee8b985..17d5a03 100644 --- a/lib/src/ui/pages/details.dart +++ b/lib/src/ui/pages/details.dart @@ -1,9 +1,11 @@ import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/data/anime_watcher.dart'; import 'package:anitrack/src/data/manga.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/ui/bloc/details_bloc.dart'; import 'package:anitrack/src/ui/constants.dart'; +import 'package:anitrack/src/ui/pages/details/watcher_sheet.dart'; import 'package:anitrack/src/ui/widgets/dropdown.dart'; import 'package:anitrack/src/ui/widgets/image.dart'; import 'package:anitrack/src/ui/widgets/integer_input.dart'; @@ -357,6 +359,96 @@ class DetailsPage extends StatelessWidget { ), ), ),*/ + Padding( + padding: const EdgeInsetsGeometry.symmetric( + vertical: 8, + horizontal: 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + bottom: 8, + ), + child: Row( + children: [ + Text( + t.details.watchingWith.title, + style: Theme.of( + context, + ).textTheme.titleSmall, + ), + + Padding( + padding: const EdgeInsetsGeometry.only( + left: 4, + ), + child: IconButton( + onPressed: () async { + final bloc = context + .read(); + final watchers = await bloc + .getAllAnimeWatchers(); + final result = + await showModalBottomSheet< + AnimeWatcher? + >( + context: context, + builder: (ctx) => + DetailsWatcherSheet( + watchers: watchers, + ), + ); + if (result == null) { + return; + } + + // Do not allow adding a watcher twice. + if (state.animeWatchers.any( + (watcher) => + watcher.name == result.name, + )) { + return; + } + + context.read().add( + AnimeWatcherAddedEvent(result), + ); + }, + icon: const Icon(Icons.add), + ), + ), + ], + ), + ), + if (state.trackingType == TrackingMediumType.anime) + if (state.animeWatchers.isNotEmpty) + Wrap( + children: [ + for (final watcher in state.animeWatchers) + Padding( + padding: + const EdgeInsetsGeometry.symmetric( + horizontal: 4, + ), + child: InputChip( + label: Text(watcher.name), + onDeleted: () { + context.read().add( + AnimeWatcherRemovedEvent(watcher), + ); + }, + ), + ), + ], + ) + else + Text(t.details.watchingWith.alone), + ], + ), + ), Padding( padding: const EdgeInsets.symmetric( vertical: 8, @@ -377,7 +469,6 @@ class DetailsPage extends StatelessWidget { ), ), ); - break; case TrackingMediumType.manga: final data = state.data! as MangaTrackingData; context.read().add( @@ -387,7 +478,6 @@ class DetailsPage extends StatelessWidget { ), ), ); - break; } }, initialValue: diff --git a/lib/src/ui/pages/details/watcher_sheet.dart b/lib/src/ui/pages/details/watcher_sheet.dart new file mode 100644 index 0000000..f8dc326 --- /dev/null +++ b/lib/src/ui/pages/details/watcher_sheet.dart @@ -0,0 +1,180 @@ +import 'package:anitrack/i18n/strings.g.dart'; +import 'package:anitrack/src/data/anime_watcher.dart'; +import 'package:anitrack/src/ui/bloc/details_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DetailsWatcherSheet extends StatefulWidget { + const DetailsWatcherSheet({required this.watchers, super.key}); + + // The watchers to show. + final List watchers; + + @override + State createState() => DetailsWatcherSheetState(); +} + +class DetailsWatcherSheetState extends State { + /// List of all watchers. + late List _baseWatcherList; + + /// List of names to show. + late List _filteredWatchers; + + /// Controller for the text field. + final TextEditingController _controller = TextEditingController(); + + void _filterList() { + setState(() { + _filteredWatchers = widget.watchers + .where( + (item) => item.name.toLowerCase().contains( + _controller.text.toLowerCase(), + ), + ) + .toList(); + }); + } + + @override + void initState() { + super.initState(); + _baseWatcherList = widget.watchers; + _filteredWatchers = _baseWatcherList; + _controller.addListener(_filterList); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool _canAdd() { + return _controller.text.isNotEmpty && + _filteredWatchers.every( + (item) => item.name.toLowerCase() != _controller.text.toLowerCase(), + ); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 1, + builder: (ctx, scrollController) { + return Padding( + padding: const EdgeInsetsGeometry.only( + top: 18, + ), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: InputDecoration( + labelText: t.details.watchingWith.name, + ), + ), + ), + + Padding( + padding: const EdgeInsets.all(8), + child: ElevatedButton( + onPressed: _canAdd() + ? () async { + final watcher = AnimeWatcher( + _controller.text, + ); + await context + .read() + .addAnimeWatcher(watcher); + Navigator.of(context).pop(watcher); + } + : null, + child: Text(t.details.watchingWith.add), + ), + ), + ], + ), + ), + ), + + SliverList.builder( + itemBuilder: (ctx, idx) => ListTile( + title: Text(_filteredWatchers[idx].name), + onTap: () { + Navigator.of(context).pop(_filteredWatchers[idx]); + }, + trailing: IconButton( + icon: const Icon( + Icons.delete, + color: Colors.red, + ), + onPressed: () async { + final result = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text(t.details.watchers.removeTitle), + content: Text( + t.details.watchers.removeBody( + watcher: _filteredWatchers[idx].name, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of( + context, + ).pop(true); + }, + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + child: Text( + t.details.removeButton, + ), + ), + TextButton( + onPressed: () { + Navigator.of( + context, + ).pop(false); + }, + child: Text( + t.details.cancelButton, + ), + ), + ], + ), + ); + if (result == null || !result) { + return; + } + + final watcher = _filteredWatchers[idx]; + _baseWatcherList.remove(watcher); + _filterList(); + context.read().add( + AnimeWatcherDeletedEvent(watcher), + ); + }, + ), + ), + itemCount: _filteredWatchers.length, + ), + ], + ), + ); + }, + ); + } +}