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>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
</manifest>

View File

@ -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",

View File

@ -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);
const MediumTrackingState(this.id);
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;
}
/// 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;
return 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;
}
}
/// The id of the value.
final int id;
String toNameString(TrackingMediumType type) {
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<MediumTrackingState, int> {
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;
}

View File

@ -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<AnimeListEvent, AnimeListState> {
await GetIt.I.get<DatabaseService>().addAnime(event.data);
// Add it to the cache
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<AnimeListEvent, AnimeListState> {
await GetIt.I.get<DatabaseService>().addManga(event.data);
// Add it to the cache
// 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(

View File

@ -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

View File

@ -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<SettingsEvent, SettingsState> {
SettingsBloc() : super(SettingsState()) {
on<AnimeListImportedEvent>(_onAnimeListImported);
on<MangaListImportedEvent>(_onMangaListImported);
on<DataExportedEvent>(_onDataExported);
on<DataImportedEvent>(_onDataImported);
}
void _showLoadingSpinner(Emitter<SettingsState> emit) {
@ -205,4 +213,69 @@ class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
// Hide the spinner again
_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
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 [
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.ongoing,
child: Text(MediumTrackingState.ongoing.toNameString(type)),
child: Text(MediumTrackingState.ongoing.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.completed,
child: Text(MediumTrackingState.completed.toNameString(type)),
child: Text(MediumTrackingState.completed.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.planned,
child: Text(MediumTrackingState.planned.toNameString(type)),
child: Text(MediumTrackingState.planned.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.dropped,
child: Text(MediumTrackingState.dropped.toNameString(type)),
child: Text(MediumTrackingState.dropped.getName(type)),
),
PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.paused,
child: Text(MediumTrackingState.paused.toNameString(type)),
child: Text(MediumTrackingState.paused.getName(type)),
),
const PopupMenuItem<MediumTrackingState>(
value: MediumTrackingState.all,

View File

@ -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,

View File

@ -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<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
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:

View File

@ -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