feat: Implement importing/exporting data

This commit is contained in:
PapaTutuWawa 2023-07-16 16:10:00 +02:00
parent 9fed2116b1
commit 918e42b424
12 changed files with 283 additions and 76 deletions

View File

@ -33,4 +33,5 @@
</application> </application>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
</manifest> </manifest>

View File

@ -9,7 +9,15 @@
"importMangaDesc": "Import manga list exported from MyAnimeList.", "importMangaDesc": "Import manga list exported from MyAnimeList.",
"invalidMangaListTitle": "Invalid manga list", "invalidMangaListTitle": "Invalid manga list",
"invalidMangaListBody": "The selected file is not a MAL manga list. It lacks the \".xml.gz\" suffix.", "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": { "about": {
"title": "About", "title": "About",

View File

@ -10,62 +10,46 @@ enum TrackingMediumType {
/// The state of the medium we're tracking, i.e. reading/watching, dropped, ... /// The state of the medium we're tracking, i.e. reading/watching, dropped, ...
enum MediumTrackingState { enum MediumTrackingState {
/// Currently watching or reading /// Currently watching or reading
ongoing, ongoing(0),
/// Done /// Done
completed, completed(1),
/// Plan to watch or read /// Plan to watch or read
planned, planned(2),
/// Dropped /// Dropped
dropped, dropped(3),
/// Paused /// Paused
paused, paused(4),
/// Meta state /// Meta state
all, all(-1);
}
/// Interface for the Anime and Manga data classes const MediumTrackingState(this.id);
abstract class TrackingMedium {
/// The ID of the medium
final String id = '';
/// The title of the medium factory MediumTrackingState.fromInt(int id) {
final String title = ''; switch (id) {
case 0:
/// The URL of the cover image. return MediumTrackingState.ongoing;
final String thumbnailUrl = ''; case 1:
return MediumTrackingState.completed;
/// The tracking state case 2:
final MediumTrackingState state = MediumTrackingState.planned; return MediumTrackingState.planned;
} case 3:
return MediumTrackingState.dropped;
extension MediumStateExtension on MediumTrackingState { case 4:
int toInteger() { return MediumTrackingState.paused;
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;
} }
return MediumTrackingState.planned;
} }
String toNameString(TrackingMediumType type) { /// The id of the value.
final int id;
String getName(TrackingMediumType type) {
assert( assert(
this != MediumTrackingState.all, this != MediumTrackingState.all,
'MediumTrackingState.all must not be stringified', '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 class MediumTrackingStateConverter
implements JsonConverter<MediumTrackingState, int> { implements JsonConverter<MediumTrackingState, int> {
const MediumTrackingStateConverter(); const MediumTrackingStateConverter();
@override @override
MediumTrackingState fromJson(int json) { MediumTrackingState fromJson(int json) => MediumTrackingState.fromInt(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;
}
@override @override
int toJson(MediumTrackingState state) => state.toInteger(); int toJson(MediumTrackingState state) => state.id;
} }

View File

@ -3,6 +3,7 @@ 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';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@ -63,7 +64,16 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
await GetIt.I.get<DatabaseService>().addAnime(event.data); await GetIt.I.get<DatabaseService>().addAnime(event.data);
// Add it to the cache // 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( emit(
state.copyWith( state.copyWith(
@ -80,7 +90,17 @@ class AnimeListBloc extends Bloc<AnimeListEvent, AnimeListState> {
await GetIt.I.get<DatabaseService>().addManga(event.data); await GetIt.I.get<DatabaseService>().addManga(event.data);
// Add it to the cache // 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( emit(
state.copyWith( state.copyWith(

View File

@ -17,10 +17,14 @@ class AnimeEpisodeDecrementedEvent extends AnimeListEvent {
} }
class AnimeAddedEvent extends AnimeListEvent { class AnimeAddedEvent extends AnimeListEvent {
AnimeAddedEvent(this.data); AnimeAddedEvent(this.data, {this.checkIfExists = false});
/// The anime to add. /// The anime to add.
final AnimeTrackingData data; 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 /// Triggered when animes are to be loaded from the database
@ -59,10 +63,14 @@ class AnimeRemovedEvent extends AnimeListEvent {
} }
class MangaAddedEvent extends AnimeListEvent { class MangaAddedEvent extends AnimeListEvent {
MangaAddedEvent(this.data); MangaAddedEvent(this.data, {this.checkIfExists = false});
/// The manga to add. /// The manga to add.
final MangaTrackingData data; 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 /// Triggered when the manga filter is changed

View File

@ -1,19 +1,25 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; 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/anime.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';
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:archive/archive_io.dart';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:jikan_api/jikan_api.dart'; import 'package:jikan_api/jikan_api.dart';
import 'package:path/path.dart' as path;
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
part 'settings_state.dart';
part 'settings_event.dart';
part 'settings_bloc.freezed.dart'; part 'settings_bloc.freezed.dart';
part 'settings_event.dart';
part 'settings_state.dart';
MediumTrackingState malStatusToTrackingState(String status) { MediumTrackingState malStatusToTrackingState(String status) {
switch (status) { switch (status) {
@ -39,6 +45,8 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
SettingsBloc() : super(SettingsState()) { SettingsBloc() : super(SettingsState()) {
on<AnimeListImportedEvent>(_onAnimeListImported); on<AnimeListImportedEvent>(_onAnimeListImported);
on<MangaListImportedEvent>(_onMangaListImported); on<MangaListImportedEvent>(_onMangaListImported);
on<DataExportedEvent>(_onDataExported);
on<DataImportedEvent>(_onDataImported);
} }
void _showLoadingSpinner(Emitter<SettingsState> emit) { void _showLoadingSpinner(Emitter<SettingsState> emit) {
@ -205,4 +213,69 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
// Hide the spinner again // Hide the spinner again
_hideLoadingSpinner(emit); _hideLoadingSpinner(emit);
} }
Future<void> _onDataExported(
DataExportedEvent event,
Emitter<SettingsState> emit,
) async {
final al = GetIt.I.get<AnimeListBloc>();
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<void> _onDataImported(
DataImportedEvent event,
Emitter<SettingsState> emit,
) async {
final al = GetIt.I.get<AnimeListBloc>();
final exportArchive = archive.GZipDecoder().decodeBytes(
await File(event.path).readAsBytes(),
);
final json = jsonDecode(utf8.decode(exportArchive)) as Map<String, dynamic>;
// Process anime
for (final animeRaw
in (json['animes']! as List<dynamic>).cast<Map<dynamic, dynamic>>()) {
final anime = AnimeTrackingData.fromJson(
animeRaw.cast<String, dynamic>(),
);
al.add(
AnimeAddedEvent(anime, checkIfExists: true),
);
}
// Process manga
for (final mangaRaw
in (json['mangas']! as List<dynamic>).cast<Map<dynamic, dynamic>>()) {
final manga = MangaTrackingData.fromJson(
mangaRaw.cast<String, dynamic>(),
);
al.add(
MangaAddedEvent(manga, checkIfExists: true),
);
}
await Fluttertoast.showToast(
msg: t.settings.dataImportSuccess,
);
}
} }

View File

@ -34,3 +34,19 @@ class MangaListImportedEvent extends SettingsEvent {
/// The type of list we're importing /// The type of list we're importing
final ImportListType type; 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;
}

View File

@ -72,23 +72,23 @@ class AnimeListPageState extends State<AnimeListPage> {
return [ return [
PopupMenuItem<MediumTrackingState>( PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.ongoing, value: MediumTrackingState.ongoing,
child: Text(MediumTrackingState.ongoing.toNameString(type)), child: Text(MediumTrackingState.ongoing.getName(type)),
), ),
PopupMenuItem<MediumTrackingState>( PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.completed, value: MediumTrackingState.completed,
child: Text(MediumTrackingState.completed.toNameString(type)), child: Text(MediumTrackingState.completed.getName(type)),
), ),
PopupMenuItem<MediumTrackingState>( PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.planned, value: MediumTrackingState.planned,
child: Text(MediumTrackingState.planned.toNameString(type)), child: Text(MediumTrackingState.planned.getName(type)),
), ),
PopupMenuItem<MediumTrackingState>( PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.dropped, value: MediumTrackingState.dropped,
child: Text(MediumTrackingState.dropped.toNameString(type)), child: Text(MediumTrackingState.dropped.getName(type)),
), ),
PopupMenuItem<MediumTrackingState>( PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.paused, value: MediumTrackingState.paused,
child: Text(MediumTrackingState.paused.toNameString(type)), child: Text(MediumTrackingState.paused.getName(type)),
), ),
const PopupMenuItem<MediumTrackingState>( const PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.all, value: MediumTrackingState.all,

View File

@ -158,27 +158,27 @@ class DetailsPage extends StatelessWidget {
SelectorItem( SelectorItem(
MediumTrackingState.ongoing, MediumTrackingState.ongoing,
MediumTrackingState.ongoing MediumTrackingState.ongoing
.toNameString(state.trackingType), .getName(state.trackingType),
), ),
SelectorItem( SelectorItem(
MediumTrackingState.completed, MediumTrackingState.completed,
MediumTrackingState.completed MediumTrackingState.completed
.toNameString(state.trackingType), .getName(state.trackingType),
), ),
SelectorItem( SelectorItem(
MediumTrackingState.planned, MediumTrackingState.planned,
MediumTrackingState.planned MediumTrackingState.planned
.toNameString(state.trackingType), .getName(state.trackingType),
), ),
SelectorItem( SelectorItem(
MediumTrackingState.dropped, MediumTrackingState.dropped,
MediumTrackingState.dropped MediumTrackingState.dropped
.toNameString(state.trackingType), .getName(state.trackingType),
), ),
SelectorItem( SelectorItem(
MediumTrackingState.paused, MediumTrackingState.paused,
MediumTrackingState.paused MediumTrackingState.paused
.toNameString(state.trackingType), .getName(state.trackingType),
), ),
], ],
initialValue: state.data!.state, initialValue: state.data!.state,

View File

@ -5,6 +5,7 @@ import 'package:file_picker/file_picker.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';
import 'package:permission_handler/permission_handler.dart';
class SettingsPage extends StatelessWidget { class SettingsPage extends StatelessWidget {
const SettingsPage({super.key}); 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<SettingsBloc>().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<void>(
context: context,
builder: (_) => AlertDialog(
title: Text(t.settings.importInvalidData.title),
content:
Text(t.settings.importInvalidData.content),
),
);
return;
}
GetIt.I.get<SettingsBloc>().add(
DataImportedEvent(
result.files.first.path!,
),
);
},
),
], ],
), ),
), ),

View File

@ -360,6 +360,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fluttertoast:
dependency: "direct main"
description:
name: fluttertoast
sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c"
url: "https://pub.dev"
source: hosted
version: "8.2.2"
freezed: freezed:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -561,7 +569,7 @@ packages:
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
path: path:
dependency: transitive dependency: "direct main"
description: description:
name: path name: path
sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b
@ -624,6 +632,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.1" 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: petitparser:
dependency: transitive dependency: transitive
description: description:

View File

@ -18,10 +18,13 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_bloc: ^8.1.1 flutter_bloc: ^8.1.1
fluttertoast: ^8.2.2
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 json_annotation: 4.6.0
path: ^1.8.2
permission_handler: ^10.4.3
slang: 3.19.0 slang: 3.19.0
slang_flutter: 3.19.0 slang_flutter: 3.19.0
sqflite: ^2.2.4+1 sqflite: ^2.2.4+1