feat(service): Add working database persistency

This commit is contained in:
PapaTutuWawa 2023-02-04 00:25:16 +01:00
parent 624c8bd78a
commit 432796d0c4
10 changed files with 538 additions and 67 deletions

View File

@ -4,16 +4,31 @@ import 'package:anitrack/src/ui/bloc/navigation_bloc.dart';
import 'package:anitrack/src/ui/constants.dart'; import 'package:anitrack/src/ui/constants.dart';
import 'package:anitrack/src/ui/pages/anime_list.dart'; import 'package:anitrack/src/ui/pages/anime_list.dart';
import 'package:anitrack/src/ui/pages/anime_search.dart'; import 'package:anitrack/src/ui/pages/anime_search.dart';
import 'package:anitrack/src/service/database.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
void main() { void main() async {
final navKey = GlobalKey<NavigatorState>(); final navKey = GlobalKey<NavigatorState>();
// Initialize the widgets binding for sqflite
WidgetsFlutterBinding.ensureInitialized();
// Initialize the database
final database = DatabaseService();
await database.initialize();
// Register singletons
GetIt.I.registerSingleton<DatabaseService>(database);
GetIt.I.registerSingleton<AnimeListBloc>(AnimeListBloc()); GetIt.I.registerSingleton<AnimeListBloc>(AnimeListBloc());
GetIt.I.registerSingleton<AnimeSearchBloc>(AnimeSearchBloc()); GetIt.I.registerSingleton<AnimeSearchBloc>(AnimeSearchBloc());
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey)); GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey));
// Load animes
GetIt.I.get<AnimeListBloc>().add(
AnimesLoadedEvent(),
);
runApp( runApp(
MultiBlocProvider( MultiBlocProvider(

View File

@ -1,50 +1,64 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'anime.freezed.dart';
part 'anime.g.dart';
/// The watch state of an anime /// The watch state of an anime
enum AnimeTrackingState { enum AnimeTrackingState {
watching, watching, // 0
completed, completed, // 1
planToWatch, planToWatch, // 2
dropped, dropped, // 3
}
extension AnimeTrackStateExtension on AnimeTrackingState {
int toInteger() {
switch (this) {
case AnimeTrackingState.watching: return 0;
case AnimeTrackingState.completed: return 1;
case AnimeTrackingState.planToWatch: return 2;
case AnimeTrackingState.dropped: return 3;
}
}
}
class AnimeTrackingStateConverter implements JsonConverter<AnimeTrackingState, int> {
const AnimeTrackingStateConverter();
@override
AnimeTrackingState fromJson(int json) {
switch (json) {
case 0: return AnimeTrackingState.watching;
case 1: return AnimeTrackingState.completed;
case 2: return AnimeTrackingState.planToWatch;
case 3: return AnimeTrackingState.dropped;
}
return AnimeTrackingState.planToWatch;
}
@override
int toJson(AnimeTrackingState state) => state.toInteger();
} }
/// Data about a tracked anime /// Data about a tracked anime
class AnimeTrackingData { @freezed
const AnimeTrackingData( class AnimeTrackingData with _$AnimeTrackingData{
this.id, factory AnimeTrackingData(
this.state, /// The ID of the anime
this.title, String id,
this.episodesWatched, /// The state of the anime
this.episodesTotal, @AnimeTrackingStateConverter() AnimeTrackingState state,
this.thumbnailUrl, /// The title of the anime
); String title,
/// Episodes in total.
/// The ID of the anime
final String id;
/// The state of the anime
final AnimeTrackingState state;
/// The title of the anime
final String title;
/// Episodes in total.
final int? episodesTotal;
/// Episodes watched.
final int episodesWatched;
/// URL to the thumbnail/cover art for the anime.
final String thumbnailUrl;
AnimeTrackingData copyWith(
int episodesWatched, int episodesWatched,
) { /// Episodes watched.
return AnimeTrackingData( int? episodesTotal,
id, /// URL to the thumbnail/cover art for the anime.
state, String thumbnailUrl,
title, ) = _AnimeTrackingData;
episodesWatched,
episodesTotal, /// JSON
thumbnailUrl, factory AnimeTrackingData.fromJson(Map<String, dynamic> json) => _$AnimeTrackingDataFromJson(json);
);
}
} }

View File

@ -0,0 +1,295 @@
// 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 'anime.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');
AnimeTrackingData _$AnimeTrackingDataFromJson(Map<String, dynamic> json) {
return _AnimeTrackingData.fromJson(json);
}
/// @nodoc
mixin _$AnimeTrackingData {
/// The ID of the anime
String get id => throw _privateConstructorUsedError;
/// The state of the anime
@AnimeTrackingStateConverter()
AnimeTrackingState get state => throw _privateConstructorUsedError;
/// The title of the anime
String get title => throw _privateConstructorUsedError;
/// Episodes in total.
int get episodesWatched => throw _privateConstructorUsedError;
/// Episodes watched.
int? get episodesTotal => throw _privateConstructorUsedError;
/// URL to the thumbnail/cover art for the anime.
String get thumbnailUrl => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$AnimeTrackingDataCopyWith<AnimeTrackingData> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AnimeTrackingDataCopyWith<$Res> {
factory $AnimeTrackingDataCopyWith(
AnimeTrackingData value, $Res Function(AnimeTrackingData) then) =
_$AnimeTrackingDataCopyWithImpl<$Res>;
$Res call(
{String id,
@AnimeTrackingStateConverter() AnimeTrackingState state,
String title,
int episodesWatched,
int? episodesTotal,
String thumbnailUrl});
}
/// @nodoc
class _$AnimeTrackingDataCopyWithImpl<$Res>
implements $AnimeTrackingDataCopyWith<$Res> {
_$AnimeTrackingDataCopyWithImpl(this._value, this._then);
final AnimeTrackingData _value;
// ignore: unused_field
final $Res Function(AnimeTrackingData) _then;
@override
$Res call({
Object? id = freezed,
Object? state = freezed,
Object? title = freezed,
Object? episodesWatched = freezed,
Object? episodesTotal = 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 AnimeTrackingState,
title: title == freezed
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
episodesWatched: episodesWatched == freezed
? _value.episodesWatched
: episodesWatched // ignore: cast_nullable_to_non_nullable
as int,
episodesTotal: episodesTotal == freezed
? _value.episodesTotal
: episodesTotal // 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 _$$_AnimeTrackingDataCopyWith<$Res>
implements $AnimeTrackingDataCopyWith<$Res> {
factory _$$_AnimeTrackingDataCopyWith(_$_AnimeTrackingData value,
$Res Function(_$_AnimeTrackingData) then) =
__$$_AnimeTrackingDataCopyWithImpl<$Res>;
@override
$Res call(
{String id,
@AnimeTrackingStateConverter() AnimeTrackingState state,
String title,
int episodesWatched,
int? episodesTotal,
String thumbnailUrl});
}
/// @nodoc
class __$$_AnimeTrackingDataCopyWithImpl<$Res>
extends _$AnimeTrackingDataCopyWithImpl<$Res>
implements _$$_AnimeTrackingDataCopyWith<$Res> {
__$$_AnimeTrackingDataCopyWithImpl(
_$_AnimeTrackingData _value, $Res Function(_$_AnimeTrackingData) _then)
: super(_value, (v) => _then(v as _$_AnimeTrackingData));
@override
_$_AnimeTrackingData get _value => super._value as _$_AnimeTrackingData;
@override
$Res call({
Object? id = freezed,
Object? state = freezed,
Object? title = freezed,
Object? episodesWatched = freezed,
Object? episodesTotal = freezed,
Object? thumbnailUrl = freezed,
}) {
return _then(_$_AnimeTrackingData(
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 AnimeTrackingState,
title == freezed
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
episodesWatched == freezed
? _value.episodesWatched
: episodesWatched // ignore: cast_nullable_to_non_nullable
as int,
episodesTotal == freezed
? _value.episodesTotal
: episodesTotal // ignore: cast_nullable_to_non_nullable
as int?,
thumbnailUrl == freezed
? _value.thumbnailUrl
: thumbnailUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$_AnimeTrackingData implements _AnimeTrackingData {
_$_AnimeTrackingData(this.id, @AnimeTrackingStateConverter() this.state,
this.title, this.episodesWatched, this.episodesTotal, this.thumbnailUrl);
factory _$_AnimeTrackingData.fromJson(Map<String, dynamic> json) =>
_$$_AnimeTrackingDataFromJson(json);
/// The ID of the anime
@override
final String id;
/// The state of the anime
@override
@AnimeTrackingStateConverter()
final AnimeTrackingState state;
/// The title of the anime
@override
final String title;
/// Episodes in total.
@override
final int episodesWatched;
/// Episodes watched.
@override
final int? episodesTotal;
/// URL to the thumbnail/cover art for the anime.
@override
final String thumbnailUrl;
@override
String toString() {
return 'AnimeTrackingData(id: $id, state: $state, title: $title, episodesWatched: $episodesWatched, episodesTotal: $episodesTotal, thumbnailUrl: $thumbnailUrl)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_AnimeTrackingData &&
const DeepCollectionEquality().equals(other.id, id) &&
const DeepCollectionEquality().equals(other.state, state) &&
const DeepCollectionEquality().equals(other.title, title) &&
const DeepCollectionEquality()
.equals(other.episodesWatched, episodesWatched) &&
const DeepCollectionEquality()
.equals(other.episodesTotal, episodesTotal) &&
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(episodesWatched),
const DeepCollectionEquality().hash(episodesTotal),
const DeepCollectionEquality().hash(thumbnailUrl));
@JsonKey(ignore: true)
@override
_$$_AnimeTrackingDataCopyWith<_$_AnimeTrackingData> get copyWith =>
__$$_AnimeTrackingDataCopyWithImpl<_$_AnimeTrackingData>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$_AnimeTrackingDataToJson(
this,
);
}
}
abstract class _AnimeTrackingData implements AnimeTrackingData {
factory _AnimeTrackingData(
final String id,
@AnimeTrackingStateConverter() final AnimeTrackingState state,
final String title,
final int episodesWatched,
final int? episodesTotal,
final String thumbnailUrl) = _$_AnimeTrackingData;
factory _AnimeTrackingData.fromJson(Map<String, dynamic> json) =
_$_AnimeTrackingData.fromJson;
@override
/// The ID of the anime
String get id;
@override
/// The state of the anime
@AnimeTrackingStateConverter()
AnimeTrackingState get state;
@override
/// The title of the anime
String get title;
@override
/// Episodes in total.
int get episodesWatched;
@override
/// Episodes watched.
int? get episodesTotal;
@override
/// URL to the thumbnail/cover art for the anime.
String get thumbnailUrl;
@override
@JsonKey(ignore: true)
_$$_AnimeTrackingDataCopyWith<_$_AnimeTrackingData> get copyWith =>
throw _privateConstructorUsedError;
}

28
lib/src/data/anime.g.dart Normal file
View File

@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'anime.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$_AnimeTrackingData _$$_AnimeTrackingDataFromJson(Map<String, dynamic> json) =>
_$_AnimeTrackingData(
json['id'] as String,
const AnimeTrackingStateConverter().fromJson(json['state'] as int),
json['title'] as String,
json['episodesWatched'] as int,
json['episodesTotal'] as int?,
json['thumbnailUrl'] as String,
);
Map<String, dynamic> _$$_AnimeTrackingDataToJson(
_$_AnimeTrackingData instance) =>
<String, dynamic>{
'id': instance.id,
'state': const AnimeTrackingStateConverter().toJson(instance.state),
'title': instance.title,
'episodesWatched': instance.episodesWatched,
'episodesTotal': instance.episodesTotal,
'thumbnailUrl': instance.thumbnailUrl,
};

View File

@ -0,0 +1,55 @@
import 'package:anitrack/src/data/anime.dart';
import 'package:sqflite/sqflite.dart';
const animeTable = 'Anime';
Future<void> _createDatabase(Database db, int version) async {
await db.execute(
'''
CREATE TABLE $animeTable(
id TEXT NOT NULL PRIMARY KEY,
state INTEGER NOT NULL,
episodesTotal INTEGER,
episodesWatched INTEGER NOT NULL,
thumbnailUrl TEXT NOT NULL,
title TEXT NOT NULL
)''',
);
}
class DatabaseService {
late final Database _db;
Future<void> initialize() async {
_db = await openDatabase(
'anitrack.db',
version: 1,
onCreate: _createDatabase,
);
}
Future<List<AnimeTrackingData>> loadAnimes() async {
final animes = await _db.query(animeTable);
return animes
.cast<Map<String, dynamic>>()
.map((Map<String, dynamic> anime) => AnimeTrackingData.fromJson(anime))
.toList();
}
Future<void> addAnime(AnimeTrackingData data) async {
await _db.insert(
animeTable,
data.toJson(),
);
}
Future<void> updateAnime(AnimeTrackingData data) async {
await _db.update(
animeTable,
data.toJson(),
where: 'id = ?',
whereArgs: [data.id],
);
}
}

View File

@ -1,7 +1,9 @@
import 'dart:math'; import 'dart:math';
import 'package:anitrack/src/data/anime.dart'; import 'package:anitrack/src/data/anime.dart';
import 'package:anitrack/src/service/database.dart';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
part 'anime_list_state.dart'; part 'anime_list_state.dart';
part 'anime_list_event.dart'; part 'anime_list_event.dart';
@ -12,10 +14,13 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
on<AnimeAddedEvent>(_onAnimeAdded); on<AnimeAddedEvent>(_onAnimeAdded);
on<AnimeEpisodeIncrementedEvent>(_onIncremented); on<AnimeEpisodeIncrementedEvent>(_onIncremented);
on<AnimeEpisodeDecrementedEvent>(_onDecremented); on<AnimeEpisodeDecrementedEvent>(_onDecremented);
on<AnimesLoadedEvent>(_onAnimesLoaded);
} }
// TODO: Remove
Future<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onAnimeAdded(AnimeAddedEvent event, Emitter<AnimeListState> emit) async {
// Add the anime to the database
await GetIt.I.get<DatabaseService>().addAnime(event.data);
emit( emit(
state.copyWith( state.copyWith(
animes: List.from([ animes: List.from([
@ -34,16 +39,18 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
if (anime.episodesTotal != null && anime.episodesWatched + 1 > anime.episodesTotal!) return; if (anime.episodesTotal != null && anime.episodesWatched + 1 > anime.episodesTotal!) return;
final newList = List<AnimeTrackingData>.from(state.animes); final newList = List<AnimeTrackingData>.from(state.animes);
newList[index] = anime.copyWith( final newAnime = anime.copyWith(
anime.episodesWatched + 1, episodesWatched: anime.episodesWatched + 1,
); );
newList[index] = newAnime;
emit( emit(
state.copyWith( state.copyWith(
animes: newList, animes: newList,
), ),
); );
print('${event.id} incremented');
await GetIt.I.get<DatabaseService>().updateAnime(newAnime);
} }
Future<void> _onDecremented(AnimeEpisodeDecrementedEvent event, Emitter<AnimeListState> emit) async { Future<void> _onDecremented(AnimeEpisodeDecrementedEvent event, Emitter<AnimeListState> emit) async {
@ -54,15 +61,25 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
if (anime.episodesWatched - 1 < 0) return; if (anime.episodesWatched - 1 < 0) return;
final newList = List<AnimeTrackingData>.from(state.animes); final newList = List<AnimeTrackingData>.from(state.animes);
newList[index] = anime.copyWith( final newAnime = anime.copyWith(
anime.episodesWatched - 1, episodesWatched: anime.episodesWatched - 1,
); );
newList[index] = newAnime;
emit( emit(
state.copyWith( state.copyWith(
animes: newList, animes: newList,
), ),
); );
print('${event.id} decremented');
await GetIt.I.get<DatabaseService>().updateAnime(newAnime);
}
Future<void> _onAnimesLoaded(AnimesLoadedEvent event, Emitter<AnimeListState> emit) async {
emit(
state.copyWith(
animes: await GetIt.I.get<DatabaseService>().loadAnimes(),
),
);
} }
} }

View File

@ -22,3 +22,6 @@ class AnimeAddedEvent extends AnimeListEvent {
/// The anime to add. /// The anime to add.
final AnimeTrackingData data; final AnimeTrackingData data;
} }
/// Triggered when animes are to be loaded from the database
class AnimesLoadedEvent extends AnimeListEvent {}

View File

@ -79,20 +79,27 @@ class AnimeSearchPage extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Align(
item.title, alignment: Alignment.centerLeft,
style: Theme.of(context).textTheme.titleLarge, child: Text(
maxLines: 2, item.title,
softWrap: true, style: Theme.of(context).textTheme.titleLarge,
overflow: TextOverflow.ellipsis, maxLines: 2,
softWrap: true,
overflow: TextOverflow.ellipsis,
),
), ),
Text( Align(
item.description, alignment: Alignment.centerLeft,
style: Theme.of(context).textTheme.bodyMedium, child: Text(
maxLines: 4, item.description,
softWrap: true, textAlign: TextAlign.justify,
overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium,
maxLines: 4,
softWrap: true,
overflow: TextOverflow.ellipsis,
),
), ),
], ],
), ),

View File

@ -292,12 +292,19 @@ packages:
source: hosted source: hosted
version: "0.6.5" version: "0.6.5"
json_annotation: json_annotation:
dependency: transitive dependency: "direct main"
description: description:
name: json_annotation name: json_annotation
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.8.0" version: "4.6.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "6.3.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@ -415,6 +422,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.2.7" version: "1.2.7"
source_helper:
dependency: transitive
description:
name: source_helper
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.3"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@ -422,6 +436,20 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
sqflite:
dependency: "direct main"
description:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.4+1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
url: "https://pub.dartlang.org"
source: hosted
version: "2.4.2+2"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -457,6 +485,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.0+3" version: "2.0.0+3"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
@ -515,4 +550,4 @@ packages:
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.18.4 <3.0.0" dart: ">=2.18.4 <3.0.0"
flutter: ">=1.16.0" flutter: ">=3.3.0"

View File

@ -16,7 +16,8 @@ dependencies:
freezed_annotation: 2.1.0 freezed_annotation: 2.1.0
get_it: ^7.2.0 get_it: ^7.2.0
jikan_api: ^2.0.0 jikan_api: ^2.0.0
json_annotation: 4.6.0
sqflite: ^2.2.4+1
swipeable_tile: ^2.0.0+3 swipeable_tile: ^2.0.0+3
dev_dependencies: dev_dependencies:
@ -25,6 +26,7 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^2.0.0 flutter_lints: ^2.0.0
freezed: ^2.1.0+1 freezed: ^2.1.0+1
json_serializable: ^6.3.1
flutter: flutter:
uses-material-design: true uses-material-design: true