feat: Implement importing/exporting data
This commit is contained in:
parent
9fed2116b1
commit
918e42b424
@ -33,4 +33,5 @@
|
||||
</application>
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
</manifest>
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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!,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
50
pubspec.lock
50
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:
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user