diff --git a/lib/main.dart b/lib/main.dart index 378e04c..33a9051 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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/pages/anime_list.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_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; -void main() { +void main() async { final navKey = GlobalKey(); + // Initialize the widgets binding for sqflite + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize the database + final database = DatabaseService(); + await database.initialize(); + + // Register singletons + GetIt.I.registerSingleton(database); GetIt.I.registerSingleton(AnimeListBloc()); GetIt.I.registerSingleton(AnimeSearchBloc()); GetIt.I.registerSingleton(NavigationBloc(navKey)); + + // Load animes + GetIt.I.get().add( + AnimesLoadedEvent(), + ); runApp( MultiBlocProvider( diff --git a/lib/src/data/anime.dart b/lib/src/data/anime.dart index 01aeb94..1a3bd02 100644 --- a/lib/src/data/anime.dart +++ b/lib/src/data/anime.dart @@ -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 enum AnimeTrackingState { - watching, - completed, - planToWatch, - dropped, + watching, // 0 + completed, // 1 + planToWatch, // 2 + 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 { + 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 -class AnimeTrackingData { - const AnimeTrackingData( - this.id, - this.state, - this.title, - this.episodesWatched, - this.episodesTotal, - this.thumbnailUrl, - ); - - /// 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( +@freezed +class AnimeTrackingData with _$AnimeTrackingData{ + factory AnimeTrackingData( + /// The ID of the anime + String id, + /// The state of the anime + @AnimeTrackingStateConverter() AnimeTrackingState state, + /// The title of the anime + String title, + /// Episodes in total. int episodesWatched, - ) { - return AnimeTrackingData( - id, - state, - title, - episodesWatched, - episodesTotal, - thumbnailUrl, - ); - } + /// Episodes watched. + int? episodesTotal, + /// URL to the thumbnail/cover art for the anime. + String thumbnailUrl, + ) = _AnimeTrackingData; + + /// JSON + factory AnimeTrackingData.fromJson(Map json) => _$AnimeTrackingDataFromJson(json); } diff --git a/lib/src/data/anime.freezed.dart b/lib/src/data/anime.freezed.dart new file mode 100644 index 0000000..8ba182f --- /dev/null +++ b/lib/src/data/anime.freezed.dart @@ -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 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 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 toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $AnimeTrackingDataCopyWith 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 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 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 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; +} diff --git a/lib/src/data/anime.g.dart b/lib/src/data/anime.g.dart new file mode 100644 index 0000000..4f1b630 --- /dev/null +++ b/lib/src/data/anime.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'anime.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$_AnimeTrackingData _$$_AnimeTrackingDataFromJson(Map 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 _$$_AnimeTrackingDataToJson( + _$_AnimeTrackingData instance) => + { + 'id': instance.id, + 'state': const AnimeTrackingStateConverter().toJson(instance.state), + 'title': instance.title, + 'episodesWatched': instance.episodesWatched, + 'episodesTotal': instance.episodesTotal, + 'thumbnailUrl': instance.thumbnailUrl, + }; diff --git a/lib/src/service/database.dart b/lib/src/service/database.dart new file mode 100644 index 0000000..a8c0922 --- /dev/null +++ b/lib/src/service/database.dart @@ -0,0 +1,55 @@ +import 'package:anitrack/src/data/anime.dart'; +import 'package:sqflite/sqflite.dart'; + +const animeTable = 'Anime'; + +Future _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 initialize() async { + _db = await openDatabase( + 'anitrack.db', + version: 1, + onCreate: _createDatabase, + ); + } + + Future> loadAnimes() async { + final animes = await _db.query(animeTable); + + return animes + .cast>() + .map((Map anime) => AnimeTrackingData.fromJson(anime)) + .toList(); + } + + Future addAnime(AnimeTrackingData data) async { + await _db.insert( + animeTable, + data.toJson(), + ); + } + + Future updateAnime(AnimeTrackingData data) async { + await _db.update( + animeTable, + data.toJson(), + where: 'id = ?', + whereArgs: [data.id], + ); + } +} diff --git a/lib/src/ui/bloc/anime_list_bloc.dart b/lib/src/ui/bloc/anime_list_bloc.dart index ccbdd76..a2bc4df 100644 --- a/lib/src/ui/bloc/anime_list_bloc.dart +++ b/lib/src/ui/bloc/anime_list_bloc.dart @@ -1,7 +1,9 @@ import 'dart:math'; import 'package:anitrack/src/data/anime.dart'; +import 'package:anitrack/src/service/database.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:get_it/get_it.dart'; part 'anime_list_state.dart'; part 'anime_list_event.dart'; @@ -12,10 +14,13 @@ class AnimeListBloc extends Bloc { on(_onAnimeAdded); on(_onIncremented); on(_onDecremented); + on(_onAnimesLoaded); } - // TODO: Remove Future _onAnimeAdded(AnimeAddedEvent event, Emitter emit) async { + // Add the anime to the database + await GetIt.I.get().addAnime(event.data); + emit( state.copyWith( animes: List.from([ @@ -34,16 +39,18 @@ class AnimeListBloc extends Bloc { if (anime.episodesTotal != null && anime.episodesWatched + 1 > anime.episodesTotal!) return; final newList = List.from(state.animes); - newList[index] = anime.copyWith( - anime.episodesWatched + 1, + final newAnime = anime.copyWith( + episodesWatched: anime.episodesWatched + 1, ); + newList[index] = newAnime; emit( state.copyWith( animes: newList, ), ); - print('${event.id} incremented'); + + await GetIt.I.get().updateAnime(newAnime); } Future _onDecremented(AnimeEpisodeDecrementedEvent event, Emitter emit) async { @@ -54,15 +61,25 @@ class AnimeListBloc extends Bloc { if (anime.episodesWatched - 1 < 0) return; final newList = List.from(state.animes); - newList[index] = anime.copyWith( - anime.episodesWatched - 1, + final newAnime = anime.copyWith( + episodesWatched: anime.episodesWatched - 1, ); + newList[index] = newAnime; emit( state.copyWith( animes: newList, ), ); - print('${event.id} decremented'); + + await GetIt.I.get().updateAnime(newAnime); + } + + Future _onAnimesLoaded(AnimesLoadedEvent event, Emitter emit) async { + emit( + state.copyWith( + animes: await GetIt.I.get().loadAnimes(), + ), + ); } } diff --git a/lib/src/ui/bloc/anime_list_event.dart b/lib/src/ui/bloc/anime_list_event.dart index 436df10..d9fd539 100644 --- a/lib/src/ui/bloc/anime_list_event.dart +++ b/lib/src/ui/bloc/anime_list_event.dart @@ -22,3 +22,6 @@ class AnimeAddedEvent extends AnimeListEvent { /// The anime to add. final AnimeTrackingData data; } + +/// Triggered when animes are to be loaded from the database +class AnimesLoadedEvent extends AnimeListEvent {} diff --git a/lib/src/ui/pages/anime_search.dart b/lib/src/ui/pages/anime_search.dart index 5102feb..618d58f 100644 --- a/lib/src/ui/pages/anime_search.dart +++ b/lib/src/ui/pages/anime_search.dart @@ -79,20 +79,27 @@ class AnimeSearchPage extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - item.title, - style: Theme.of(context).textTheme.titleLarge, - maxLines: 2, - softWrap: true, - overflow: TextOverflow.ellipsis, + Align( + alignment: Alignment.centerLeft, + child: Text( + item.title, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 2, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), ), - Text( - item.description, - style: Theme.of(context).textTheme.bodyMedium, - maxLines: 4, - softWrap: true, - overflow: TextOverflow.ellipsis, + Align( + alignment: Alignment.centerLeft, + child: Text( + item.description, + textAlign: TextAlign.justify, + style: Theme.of(context).textTheme.bodyMedium, + maxLines: 4, + softWrap: true, + overflow: TextOverflow.ellipsis, + ), ), ], ), diff --git a/pubspec.lock b/pubspec.lock index fb9f6bc..f14d170 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -292,12 +292,19 @@ packages: source: hosted version: "0.6.5" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation url: "https://pub.dartlang.org" 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: dependency: transitive description: @@ -415,6 +422,13 @@ packages: url: "https://pub.dartlang.org" source: hosted 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: dependency: transitive description: @@ -422,6 +436,20 @@ packages: url: "https://pub.dartlang.org" source: hosted 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: dependency: transitive description: @@ -457,6 +485,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0+3" + synchronized: + dependency: transitive + description: + name: synchronized + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" term_glyph: dependency: transitive description: @@ -515,4 +550,4 @@ packages: version: "3.1.1" sdks: dart: ">=2.18.4 <3.0.0" - flutter: ">=1.16.0" + flutter: ">=3.3.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3616f3b..cfdcfc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,8 @@ dependencies: freezed_annotation: 2.1.0 get_it: ^7.2.0 jikan_api: ^2.0.0 - + json_annotation: 4.6.0 + sqflite: ^2.2.4+1 swipeable_tile: ^2.0.0+3 dev_dependencies: @@ -25,6 +26,7 @@ dev_dependencies: sdk: flutter flutter_lints: ^2.0.0 freezed: ^2.1.0+1 + json_serializable: ^6.3.1 flutter: uses-material-design: true