feat: Allow tracking who you are watching anime with

This commit is contained in:
2026-07-25 14:49:43 +02:00
parent 52890dd9ea
commit aa72095b98
13 changed files with 870 additions and 24 deletions

View File

@@ -45,7 +45,22 @@
"cancelButton": "Abbrechen", "cancelButton": "Abbrechen",
"readState": "Lese-Status", "readState": "Lese-Status",
"episodes": "Folgen", "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": { "data": {
"dropped": "Abgebrochen", "dropped": "Abgebrochen",

View File

@@ -50,6 +50,16 @@
"chapters": "Chapters", "chapters": "Chapters",
"volumesOwned": "Volumes owned", "volumesOwned": "Volumes owned",
"mal": "MAL", "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": { "details": {
"titleJa": "Japanese Title", "titleJa": "Japanese Title",
"textCopied": "Text copied to clipboard" "textCopied": "Text copied to clipboard"

View File

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

View File

@@ -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>(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<AnimeWatcher> get copyWith => _$AnimeWatcherCopyWithImpl<AnimeWatcher>(this as AnimeWatcher, _$identity);
/// Serializes this AnimeWatcher to a JSON map.
Map<String, dynamic> 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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<String, dynamic> 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<String, dynamic> 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

View File

@@ -0,0 +1,13 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'anime_watcher.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_AnimeWatcher _$AnimeWatcherFromJson(Map<String, dynamic> json) =>
_AnimeWatcher(json['name'] as String);
Map<String, dynamic> _$AnimeWatcherToJson(_AnimeWatcher instance) =>
<String, dynamic>{'name': instance.name};

View File

@@ -1,13 +1,17 @@
import 'dart:io'; import 'dart:io';
import 'package:anitrack/src/data/anime.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/manga.dart';
import 'package:anitrack/src/service/migrations/0000_airing.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/0000_score.dart';
import 'package:anitrack/src/service/migrations/0001_anime_watcher.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart';
const animeTable = 'Anime'; const animeTable = 'Anime';
const mangaTable = 'Manga'; const mangaTable = 'Manga';
const animeWatcherTable = 'AnimeWatchers';
const animeWatcherJoinTable = 'AnimeWatchersJoin';
extension BoolToInt on bool { extension BoolToInt on bool {
int toInt() { int toInt() {
@@ -51,11 +55,31 @@ Future<void> _createDatabase(Database db, int version) async {
score INTEGER 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 { class DatabaseService {
/// The actual sqlite database connection.
late final Database _db; late final Database _db;
/// Cached AnimeWatchers.
List<AnimeWatcher>? _watcherCache;
Future<void> initialize() async { Future<void> initialize() async {
// Allow initializing the database on Windows and Linux as well. // Allow initializing the database on Windows and Linux as well.
if (Platform.isLinux || Platform.isWindows) { if (Platform.isLinux || Platform.isWindows) {
@@ -65,7 +89,7 @@ class DatabaseService {
_db = await openDatabase( _db = await openDatabase(
'anitrack.db', 'anitrack.db',
version: 3, version: 4,
onConfigure: (db) async { onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign // In order to do schema changes during database upgrades, we disable foreign
// keys in the onConfigure phase, but re-enable them here. // keys in the onConfigure phase, but re-enable them here.
@@ -84,8 +108,12 @@ class DatabaseService {
if (oldVersion < 3) { if (oldVersion < 3) {
await migrateFromV2ToV3(db); await migrateFromV2ToV3(db);
} }
if (oldVersion < 4) {
await migrateFromV3ToV4(db);
}
}, },
); );
print(_db.path);
} }
Future<List<AnimeTrackingData>> loadAnimes() async { Future<List<AnimeTrackingData>> loadAnimes() async {
@@ -191,4 +219,96 @@ class DatabaseService {
.toList() .toList()
.first; .first;
} }
/// Queries all anime watchers.
///
/// Returns the list of AnimeWatchers in the database. May be empty.
Future<List<AnimeWatcher>> getAnimeWatchers() async {
if (_watcherCache != null) {
return _watcherCache!;
}
final dbResults = await _db.query(
animeWatcherTable,
);
final results = dbResults
.cast<Map<String, dynamic>>()
.map(AnimeWatcher.fromJson)
.toList();
_watcherCache = results;
return results;
}
/// Removes an AnimeWatcher from the database.
///
/// @watcher The watcher to be removed from the database.
Future<void> 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<void> addWatcher(AnimeWatcher watcher) async {
await _db.insert(
animeWatcherTable,
watcher.toJson(),
);
_watcherCache?.add(watcher);
}
Future<List<AnimeWatcher>> 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<String, dynamic>>()
.map(AnimeWatcher.fromJson)
.toList();
}
Future<void> associateWatcherWithAnime(
AnimeWatcher watcher,
AnimeTrackingData anime,
) async {
await _db.insert(
animeWatcherJoinTable,
{
'name': watcher.name,
'anime': anime.id,
},
);
}
Future<void> disassociateWatcherWithAnime(
AnimeWatcher watcher,
AnimeTrackingData anime,
) async {
await _db.delete(
animeWatcherJoinTable,
where: 'name = ? and anime = ?',
whereArgs: [watcher.name, anime.id],
);
}
} }

View File

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

View File

@@ -1,4 +1,5 @@
import 'package:anitrack/src/data/anime.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/manga.dart';
import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/data/type.dart';
import 'package:anitrack/src/service/database.dart'; import 'package:anitrack/src/service/database.dart';
@@ -19,17 +20,26 @@ class DetailsBloc extends Bloc<DetailsEvent, DetailsState> {
on<MangaDetailsRequestedEvent>(_onMangaRequested); on<MangaDetailsRequestedEvent>(_onMangaRequested);
on<DetailsUpdatedEvent>(_onDetailsUpdated); on<DetailsUpdatedEvent>(_onDetailsUpdated);
on<ItemRemovedEvent>(_onItemRemoved); on<ItemRemovedEvent>(_onItemRemoved);
on<AnimeWatcherAddedEvent>(_onAnimeWatcherAdded);
on<AnimeWatcherRemovedEvent>(_onAnimeWatcherRemoved);
on<AnimeWatcherDeletedEvent>(_onAnimeWatcherDeleted);
} }
Future<void> _onAnimeRequested( Future<void> _onAnimeRequested(
AnimeDetailsRequestedEvent event, AnimeDetailsRequestedEvent event,
Emitter<DetailsState> emit, Emitter<DetailsState> emit,
) async { ) async {
// Find out what watchers there are
final watchers = await GetIt.I.get<DatabaseService>().getWatchersForAnime(
event.anime,
);
emit( emit(
state.copyWith( state.copyWith(
trackingType: TrackingMediumType.anime, trackingType: TrackingMediumType.anime,
heroImagePrefix: event.heroImagePrefix, heroImagePrefix: event.heroImagePrefix,
data: event.anime, data: event.anime,
animeWatchers: watchers,
), ),
); );
@@ -117,4 +127,62 @@ class DetailsBloc extends Bloc<DetailsEvent, DetailsState> {
// Navigate back // Navigate back
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent()); GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
} }
Future<void> _onAnimeWatcherAdded(
AnimeWatcherAddedEvent event,
Emitter<DetailsState> emit,
) async {
await GetIt.I.get<DatabaseService>().associateWatcherWithAnime(
event.watcher,
state.data! as AnimeTrackingData,
);
emit(
state.copyWith(
animeWatchers: List.of([
...state.animeWatchers,
event.watcher,
]),
),
);
}
Future<void> _onAnimeWatcherRemoved(
AnimeWatcherRemovedEvent event,
Emitter<DetailsState> emit,
) async {
await GetIt.I.get<DatabaseService>().disassociateWatcherWithAnime(
event.watcher,
state.data! as AnimeTrackingData,
);
emit(
state.copyWith(
animeWatchers: state.animeWatchers
.where((watcher) => watcher.name != event.watcher.name)
.toList(),
),
);
}
Future<void> _onAnimeWatcherDeleted(
AnimeWatcherDeletedEvent event,
Emitter<DetailsState> emit,
) async {
await GetIt.I.get<DatabaseService>().deleteAnimeWatcher(event.watcher);
emit(
state.copyWith(
animeWatchers: state.animeWatchers
.where((watcher) => watcher.name != event.watcher.name)
.toList(),
),
);
}
Future<void> addAnimeWatcher(AnimeWatcher watcher) async {
await GetIt.I.get<DatabaseService>().addWatcher(watcher);
}
Future<List<AnimeWatcher>> getAllAnimeWatchers() async {
return GetIt.I.get<DatabaseService>().getAnimeWatchers();
}
} }

View File

@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$DetailsState { mixin _$DetailsState {
TrackingMedium? get data; String? get heroImagePrefix; TrackingMediumType get trackingType; TrackingMedium? get data; String? get heroImagePrefix; TrackingMediumType get trackingType; List<AnimeWatcher> get animeWatchers;
/// Create a copy of DetailsState /// Create a copy of DetailsState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +25,16 @@ $DetailsStateCopyWith<DetailsState> get copyWith => _$DetailsStateCopyWithImpl<D
@override @override
bool operator ==(Object other) { 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 @override
int get hashCode => Object.hash(runtimeType,data,heroImagePrefix,trackingType); int get hashCode => Object.hash(runtimeType,data,heroImagePrefix,trackingType,const DeepCollectionEquality().hash(animeWatchers));
@override @override
String toString() { 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; factory $DetailsStateCopyWith(DetailsState value, $Res Function(DetailsState) _then) = _$DetailsStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List<AnimeWatcher> animeWatchers
}); });
@@ -62,12 +62,13 @@ class _$DetailsStateCopyWithImpl<$Res>
/// Create a copy of DetailsState /// Create a copy of DetailsState
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable 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 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 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<AnimeWatcher>,
)); ));
} }
@@ -152,10 +153,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List<AnimeWatcher> animeWatchers)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _DetailsState() when $default != null: 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(); return orElse();
} }
@@ -173,10 +174,10 @@ return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List<AnimeWatcher> animeWatchers) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _DetailsState(): 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'); throw StateError('Unexpected subclass');
} }
@@ -193,10 +194,10 @@ return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List<AnimeWatcher> animeWatchers)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _DetailsState() when $default != null: 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; return null;
} }
@@ -208,12 +209,19 @@ return $default(_that.data,_that.heroImagePrefix,_that.trackingType);case _:
class _DetailsState implements DetailsState { class _DetailsState implements DetailsState {
_DetailsState({this.data, this.heroImagePrefix, this.trackingType = TrackingMediumType.anime}); _DetailsState({this.data, this.heroImagePrefix, this.trackingType = TrackingMediumType.anime, final List<AnimeWatcher> animeWatchers = const []}): _animeWatchers = animeWatchers;
@override final TrackingMedium? data; @override final TrackingMedium? data;
@override final String? heroImagePrefix; @override final String? heroImagePrefix;
@override@JsonKey() final TrackingMediumType trackingType; @override@JsonKey() final TrackingMediumType trackingType;
final List<AnimeWatcher> _animeWatchers;
@override@JsonKey() List<AnimeWatcher> get animeWatchers {
if (_animeWatchers is EqualUnmodifiableListView) return _animeWatchers;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_animeWatchers);
}
/// Create a copy of DetailsState /// Create a copy of DetailsState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -225,16 +233,16 @@ _$DetailsStateCopyWith<_DetailsState> get copyWith => __$DetailsStateCopyWithImp
@override @override
bool operator ==(Object other) { 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 @override
int get hashCode => Object.hash(runtimeType,data,heroImagePrefix,trackingType); int get hashCode => Object.hash(runtimeType,data,heroImagePrefix,trackingType,const DeepCollectionEquality().hash(_animeWatchers));
@override @override
String toString() { 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; factory _$DetailsStateCopyWith(_DetailsState value, $Res Function(_DetailsState) _then) = __$DetailsStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType TrackingMedium? data, String? heroImagePrefix, TrackingMediumType trackingType, List<AnimeWatcher> animeWatchers
}); });
@@ -262,12 +270,13 @@ class __$DetailsStateCopyWithImpl<$Res>
/// Create a copy of DetailsState /// Create a copy of DetailsState
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_DetailsState(
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable 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 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 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<AnimeWatcher>,
)); ));
} }

View File

@@ -36,3 +36,24 @@ class ItemRemovedEvent extends DetailsEvent {
/// The type of medium of the item /// The type of medium of the item
final TrackingMediumType trackingType; 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;
}

View File

@@ -6,5 +6,6 @@ abstract class DetailsState with _$DetailsState {
TrackingMedium? data, TrackingMedium? data,
String? heroImagePrefix, String? heroImagePrefix,
@Default(TrackingMediumType.anime) TrackingMediumType trackingType, @Default(TrackingMediumType.anime) TrackingMediumType trackingType,
@Default([]) List<AnimeWatcher> animeWatchers,
}) = _DetailsState; }) = _DetailsState;
} }

View File

@@ -1,9 +1,11 @@
import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/i18n/strings.g.dart';
import 'package:anitrack/src/data/anime.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/manga.dart';
import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/data/type.dart';
import 'package:anitrack/src/ui/bloc/details_bloc.dart'; import 'package:anitrack/src/ui/bloc/details_bloc.dart';
import 'package:anitrack/src/ui/constants.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/dropdown.dart';
import 'package:anitrack/src/ui/widgets/image.dart'; import 'package:anitrack/src/ui/widgets/image.dart';
import 'package:anitrack/src/ui/widgets/integer_input.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<DetailsBloc>();
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<DetailsBloc>().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<DetailsBloc>().add(
AnimeWatcherRemovedEvent(watcher),
);
},
),
),
],
)
else
Text(t.details.watchingWith.alone),
],
),
),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 8, vertical: 8,
@@ -377,7 +469,6 @@ class DetailsPage extends StatelessWidget {
), ),
), ),
); );
break;
case TrackingMediumType.manga: case TrackingMediumType.manga:
final data = state.data! as MangaTrackingData; final data = state.data! as MangaTrackingData;
context.read<DetailsBloc>().add( context.read<DetailsBloc>().add(
@@ -387,7 +478,6 @@ class DetailsPage extends StatelessWidget {
), ),
), ),
); );
break;
} }
}, },
initialValue: initialValue:

View File

@@ -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<AnimeWatcher> watchers;
@override
State<StatefulWidget> createState() => DetailsWatcherSheetState();
}
class DetailsWatcherSheetState extends State<DetailsWatcherSheet> {
/// List of all watchers.
late List<AnimeWatcher> _baseWatcherList;
/// List of names to show.
late List<AnimeWatcher> _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<DetailsBloc>()
.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<bool>(
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<DetailsBloc>().add(
AnimeWatcherDeletedEvent(watcher),
);
},
),
),
itemCount: _filteredWatchers.length,
),
],
),
);
},
);
}
}