From 918e42b42433ccfe933c4f600af7b58dd8a36823 Mon Sep 17 00:00:00 2001 From: "Alexander \"PapaTutuWawa" Date: Sun, 16 Jul 2023 16:10:00 +0200 Subject: [PATCH] feat: Implement importing/exporting data --- android/app/src/main/AndroidManifest.xml | 1 + assets/i18n/strings_en.i18n.json | 10 ++- lib/src/data/type.dart | 100 ++++++++++------------- lib/src/ui/bloc/anime_list_bloc.dart | 24 +++++- lib/src/ui/bloc/anime_list_event.dart | 12 ++- lib/src/ui/bloc/settings_bloc.dart | 77 ++++++++++++++++- lib/src/ui/bloc/settings_event.dart | 16 ++++ lib/src/ui/pages/anime_list.dart | 10 +-- lib/src/ui/pages/details.dart | 10 +-- lib/src/ui/pages/settings.dart | 46 +++++++++++ pubspec.lock | 50 +++++++++++- pubspec.yaml | 3 + 12 files changed, 283 insertions(+), 76 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 95653ea..e2e0460 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,4 +33,5 @@ + diff --git a/assets/i18n/strings_en.i18n.json b/assets/i18n/strings_en.i18n.json index 5ecdc74..c745900 100644 --- a/assets/i18n/strings_en.i18n.json +++ b/assets/i18n/strings_en.i18n.json @@ -9,7 +9,15 @@ "importMangaDesc": "Import manga list exported from MyAnimeList.", "invalidMangaListTitle": "Invalid manga list", "invalidMangaListBody": "The selected file is not a MAL manga list. It lacks the \".xml.gz\" suffix.", - "importIndicator": "$current of $total" + "importIndicator": "$current of $total", + "exportData": "Export data", + "importData": "Import data", + "dataExportSuccess": "Data successfully exported", + "dataImportSuccess": "Data successfully imported", + "importInvalidData": { + "title": "Invalid AniTrack Data", + "content": "The selected file is not an AniTrack data export. It lacks the \".json.gz\" suffix." + } }, "about": { "title": "About", diff --git a/lib/src/data/type.dart b/lib/src/data/type.dart index 5eac9a4..5245ec0 100644 --- a/lib/src/data/type.dart +++ b/lib/src/data/type.dart @@ -10,62 +10,46 @@ enum TrackingMediumType { /// The state of the medium we're tracking, i.e. reading/watching, dropped, ... enum MediumTrackingState { /// Currently watching or reading - ongoing, + ongoing(0), /// Done - completed, + completed(1), /// Plan to watch or read - planned, + planned(2), /// Dropped - dropped, + dropped(3), /// Paused - paused, + paused(4), /// Meta state - all, -} + all(-1); -/// Interface for the Anime and Manga data classes -abstract class TrackingMedium { - /// The ID of the medium - final String id = ''; + const MediumTrackingState(this.id); - /// The title of the medium - final String title = ''; - - /// The URL of the cover image. - final String thumbnailUrl = ''; - - /// The tracking state - final MediumTrackingState state = MediumTrackingState.planned; -} - -extension MediumStateExtension on MediumTrackingState { - int toInteger() { - assert( - this != MediumTrackingState.all, - 'MediumTrackingState.all must not be serialized', - ); - switch (this) { - case MediumTrackingState.ongoing: - return 0; - case MediumTrackingState.completed: - return 1; - case MediumTrackingState.planned: - return 2; - case MediumTrackingState.dropped: - return 3; - case MediumTrackingState.paused: - return 4; - case MediumTrackingState.all: - return -1; + factory MediumTrackingState.fromInt(int id) { + switch (id) { + case 0: + return MediumTrackingState.ongoing; + case 1: + return MediumTrackingState.completed; + case 2: + return MediumTrackingState.planned; + case 3: + return MediumTrackingState.dropped; + case 4: + return MediumTrackingState.paused; } + + return MediumTrackingState.planned; } - String toNameString(TrackingMediumType type) { + /// The id of the value. + final int id; + + String getName(TrackingMediumType type) { assert( this != MediumTrackingState.all, 'MediumTrackingState.all must not be stringified', @@ -98,28 +82,28 @@ extension MediumStateExtension on MediumTrackingState { } } +/// Interface for the Anime and Manga data classes +abstract class TrackingMedium { + /// The ID of the medium + final String id = ''; + + /// The title of the medium + final String title = ''; + + /// The URL of the cover image. + final String thumbnailUrl = ''; + + /// The tracking state + final MediumTrackingState state = MediumTrackingState.planned; +} + class MediumTrackingStateConverter implements JsonConverter { const MediumTrackingStateConverter(); @override - MediumTrackingState fromJson(int json) { - switch (json) { - case 0: - return MediumTrackingState.ongoing; - case 1: - return MediumTrackingState.completed; - case 2: - return MediumTrackingState.planned; - case 3: - return MediumTrackingState.dropped; - case 4: - return MediumTrackingState.paused; - } - - return MediumTrackingState.planned; - } + MediumTrackingState fromJson(int json) => MediumTrackingState.fromInt(json); @override - int toJson(MediumTrackingState state) => state.toInteger(); + int toJson(MediumTrackingState state) => state.id; } diff --git a/lib/src/ui/bloc/anime_list_bloc.dart b/lib/src/ui/bloc/anime_list_bloc.dart index 83e9fee..6262519 100644 --- a/lib/src/ui/bloc/anime_list_bloc.dart +++ b/lib/src/ui/bloc/anime_list_bloc.dart @@ -3,6 +3,7 @@ import 'package:anitrack/src/data/manga.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/service/database.dart'; import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:get_it/get_it.dart'; @@ -63,7 +64,16 @@ class AnimeListBloc extends Bloc { await GetIt.I.get().addAnime(event.data); // Add it to the cache - _animes.add(event.data); + if (event.checkIfExists) { + final shouldAdd = + _animes.firstWhereOrNull((element) => element.id == event.data.id) == + null; + if (shouldAdd) { + _animes.add(event.data); + } + } else { + _animes.add(event.data); + } emit( state.copyWith( @@ -80,7 +90,17 @@ class AnimeListBloc extends Bloc { await GetIt.I.get().addManga(event.data); // Add it to the cache - _mangas.add(event.data); + // Add it to the cache + if (event.checkIfExists) { + final shouldAdd = + _mangas.firstWhereOrNull((element) => element.id == event.data.id) == + null; + if (shouldAdd) { + _mangas.add(event.data); + } + } else { + _mangas.add(event.data); + } emit( state.copyWith( diff --git a/lib/src/ui/bloc/anime_list_event.dart b/lib/src/ui/bloc/anime_list_event.dart index 68e2a24..40e2851 100644 --- a/lib/src/ui/bloc/anime_list_event.dart +++ b/lib/src/ui/bloc/anime_list_event.dart @@ -17,10 +17,14 @@ class AnimeEpisodeDecrementedEvent extends AnimeListEvent { } class AnimeAddedEvent extends AnimeListEvent { - AnimeAddedEvent(this.data); + AnimeAddedEvent(this.data, {this.checkIfExists = false}); /// The anime to add. final AnimeTrackingData data; + + /// If true, checks if the anime with the id is already in the list. + /// If it is, does nothing. + final bool checkIfExists; } /// Triggered when animes are to be loaded from the database @@ -59,10 +63,14 @@ class AnimeRemovedEvent extends AnimeListEvent { } class MangaAddedEvent extends AnimeListEvent { - MangaAddedEvent(this.data); + MangaAddedEvent(this.data, {this.checkIfExists = false}); /// The manga to add. final MangaTrackingData data; + + /// If true, checks if the manga with the id is already in the list. + /// If it is, does nothing. + final bool checkIfExists; } /// Triggered when the manga filter is changed diff --git a/lib/src/ui/bloc/settings_bloc.dart b/lib/src/ui/bloc/settings_bloc.dart index 79e732d..ca021bd 100644 --- a/lib/src/ui/bloc/settings_bloc.dart +++ b/lib/src/ui/bloc/settings_bloc.dart @@ -1,19 +1,25 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'package:anitrack/i18n/strings.g.dart'; import 'package:anitrack/src/data/anime.dart'; import 'package:anitrack/src/data/manga.dart'; import 'package:anitrack/src/data/type.dart'; import 'package:anitrack/src/service/database.dart'; +import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart'; +import 'package:archive/archive.dart' as archive; import 'package:archive/archive_io.dart'; import 'package:bloc/bloc.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:get_it/get_it.dart'; import 'package:jikan_api/jikan_api.dart'; +import 'package:path/path.dart' as path; import 'package:xml/xml.dart'; -part 'settings_state.dart'; -part 'settings_event.dart'; part 'settings_bloc.freezed.dart'; +part 'settings_event.dart'; +part 'settings_state.dart'; MediumTrackingState malStatusToTrackingState(String status) { switch (status) { @@ -39,6 +45,8 @@ class SettingsBloc extends Bloc { SettingsBloc() : super(SettingsState()) { on(_onAnimeListImported); on(_onMangaListImported); + on(_onDataExported); + on(_onDataImported); } void _showLoadingSpinner(Emitter emit) { @@ -205,4 +213,69 @@ class SettingsBloc extends Bloc { // Hide the spinner again _hideLoadingSpinner(emit); } + + Future _onDataExported( + DataExportedEvent event, + Emitter emit, + ) async { + final al = GetIt.I.get(); + final data = { + // TODO(Unknown): Track the version here to (maybe) to migrations + 'animes': al.state.animes.map((anime) => anime.toJson()).toList(), + 'mangas': al.state.mangas.map((manga) => manga.toJson()).toList(), + }; + final exportData = jsonEncode(data); + final date = DateTime.now(); + final outputPath = path.join( + event.path, + 'anitrack_${date.year}${date.month}${date.day}.json.gz', + ); + archive.GZipEncoder().encode( + InputStream(utf8.encode(exportData)), + output: OutputFileStream(outputPath), + ); + + await Fluttertoast.showToast( + msg: t.settings.dataExportSuccess, + ); + } + + Future _onDataImported( + DataImportedEvent event, + Emitter emit, + ) async { + final al = GetIt.I.get(); + final exportArchive = archive.GZipDecoder().decodeBytes( + await File(event.path).readAsBytes(), + ); + final json = jsonDecode(utf8.decode(exportArchive)) as Map; + + // Process anime + for (final animeRaw + in (json['animes']! as List).cast>()) { + final anime = AnimeTrackingData.fromJson( + animeRaw.cast(), + ); + + al.add( + AnimeAddedEvent(anime, checkIfExists: true), + ); + } + + // Process manga + for (final mangaRaw + in (json['mangas']! as List).cast>()) { + final manga = MangaTrackingData.fromJson( + mangaRaw.cast(), + ); + + al.add( + MangaAddedEvent(manga, checkIfExists: true), + ); + } + + await Fluttertoast.showToast( + msg: t.settings.dataImportSuccess, + ); + } } diff --git a/lib/src/ui/bloc/settings_event.dart b/lib/src/ui/bloc/settings_event.dart index dbc3674..f65f6c1 100644 --- a/lib/src/ui/bloc/settings_event.dart +++ b/lib/src/ui/bloc/settings_event.dart @@ -34,3 +34,19 @@ class MangaListImportedEvent extends SettingsEvent { /// The type of list we're importing final ImportListType type; } + +/// Triggered when a data export should be produced. +class DataExportedEvent extends SettingsEvent { + DataExportedEvent(this.path); + + /// The path where the export should be stored. + final String path; +} + +/// Triggered when a data export has been picked for import. +class DataImportedEvent extends SettingsEvent { + DataImportedEvent(this.path); + + /// The path of the data export to import. + final String path; +} diff --git a/lib/src/ui/pages/anime_list.dart b/lib/src/ui/pages/anime_list.dart index 1d711ed..d514395 100644 --- a/lib/src/ui/pages/anime_list.dart +++ b/lib/src/ui/pages/anime_list.dart @@ -72,23 +72,23 @@ class AnimeListPageState extends State { return [ PopupMenuItem( value: MediumTrackingState.ongoing, - child: Text(MediumTrackingState.ongoing.toNameString(type)), + child: Text(MediumTrackingState.ongoing.getName(type)), ), PopupMenuItem( value: MediumTrackingState.completed, - child: Text(MediumTrackingState.completed.toNameString(type)), + child: Text(MediumTrackingState.completed.getName(type)), ), PopupMenuItem( value: MediumTrackingState.planned, - child: Text(MediumTrackingState.planned.toNameString(type)), + child: Text(MediumTrackingState.planned.getName(type)), ), PopupMenuItem( value: MediumTrackingState.dropped, - child: Text(MediumTrackingState.dropped.toNameString(type)), + child: Text(MediumTrackingState.dropped.getName(type)), ), PopupMenuItem( value: MediumTrackingState.paused, - child: Text(MediumTrackingState.paused.toNameString(type)), + child: Text(MediumTrackingState.paused.getName(type)), ), const PopupMenuItem( value: MediumTrackingState.all, diff --git a/lib/src/ui/pages/details.dart b/lib/src/ui/pages/details.dart index f845b17..5336177 100644 --- a/lib/src/ui/pages/details.dart +++ b/lib/src/ui/pages/details.dart @@ -158,27 +158,27 @@ class DetailsPage extends StatelessWidget { SelectorItem( MediumTrackingState.ongoing, MediumTrackingState.ongoing - .toNameString(state.trackingType), + .getName(state.trackingType), ), SelectorItem( MediumTrackingState.completed, MediumTrackingState.completed - .toNameString(state.trackingType), + .getName(state.trackingType), ), SelectorItem( MediumTrackingState.planned, MediumTrackingState.planned - .toNameString(state.trackingType), + .getName(state.trackingType), ), SelectorItem( MediumTrackingState.dropped, MediumTrackingState.dropped - .toNameString(state.trackingType), + .getName(state.trackingType), ), SelectorItem( MediumTrackingState.paused, MediumTrackingState.paused - .toNameString(state.trackingType), + .getName(state.trackingType), ), ], initialValue: state.data!.state, diff --git a/lib/src/ui/pages/settings.dart b/lib/src/ui/pages/settings.dart index 2c21884..3450ced 100644 --- a/lib/src/ui/pages/settings.dart +++ b/lib/src/ui/pages/settings.dart @@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; +import 'package:permission_handler/permission_handler.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -91,6 +92,51 @@ class SettingsPage extends StatelessWidget { ); }, ), + ListTile( + title: Text(t.settings.exportData), + onTap: () async { + // Pick the file + final result = + await FilePicker.platform.getDirectoryPath(); + if (result == null) return; + + if (!(await Permission.manageExternalStorage + .request()) + .isGranted) return; + + GetIt.I.get().add( + DataExportedEvent( + result, + ), + ); + }, + ), + ListTile( + title: Text(t.settings.importData), + onTap: () async { + // Pick the file + final result = await FilePicker.platform.pickFiles(); + if (result == null) return; + + if (!result.files.first.path!.endsWith('.json.gz')) { + await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(t.settings.importInvalidData.title), + content: + Text(t.settings.importInvalidData.content), + ), + ); + return; + } + + GetIt.I.get().add( + DataImportedEvent( + result.files.first.path!, + ), + ); + }, + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index 2c8ae39..24e1150 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -360,6 +360,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c" + url: "https://pub.dev" + source: hosted + version: "8.2.2" freezed: dependency: "direct dev" description: @@ -561,7 +569,7 @@ packages: source: hosted version: "2.1.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b @@ -624,6 +632,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "63e5216aae014a72fe9579ccd027323395ce7a98271d9defa9d57320d001af81" + url: "https://pub.dev" + source: hosted + version: "10.4.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: c0c9754479a4c4b1c1f3862ddc11930c9b3f03bef2816bb4ea6eed1e13551d6f + url: "https://pub.dev" + source: hosted + version: "10.3.2" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + url: "https://pub.dev" + source: hosted + version: "9.1.4" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: "7c6b1500385dd1d2ca61bb89e2488ca178e274a69144d26bbd65e33eae7c02a9" + url: "https://pub.dev" + source: hosted + version: "3.11.3" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + url: "https://pub.dev" + source: hosted + version: "0.1.3" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e4eba01..eed2ae1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,10 +18,13 @@ dependencies: flutter: sdk: flutter flutter_bloc: ^8.1.1 + fluttertoast: ^8.2.2 freezed_annotation: 2.1.0 get_it: ^7.2.0 jikan_api: ^2.0.0 json_annotation: 4.6.0 + path: ^1.8.2 + permission_handler: ^10.4.3 slang: 3.19.0 slang_flutter: 3.19.0 sqflite: ^2.2.4+1