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