diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index fb9b0c9..95653ea 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -32,5 +32,5 @@
android:value="2" />
-
+
diff --git a/lib/main.dart b/lib/main.dart
index c0184db..d8fd7c3 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -3,11 +3,13 @@ import 'package:anitrack/src/ui/bloc/anime_list_bloc.dart';
import 'package:anitrack/src/ui/bloc/anime_search_bloc.dart';
import 'package:anitrack/src/ui/bloc/details_bloc.dart';
import 'package:anitrack/src/ui/bloc/navigation_bloc.dart';
+import 'package:anitrack/src/ui/bloc/settings_bloc.dart';
import 'package:anitrack/src/ui/constants.dart';
import 'package:anitrack/src/ui/pages/about.dart';
import 'package:anitrack/src/ui/pages/anime_list.dart';
import 'package:anitrack/src/ui/pages/anime_search.dart';
import 'package:anitrack/src/ui/pages/details.dart';
+import 'package:anitrack/src/ui/pages/settings.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
@@ -28,6 +30,7 @@ void main() async {
GetIt.I.registerSingleton(AnimeSearchBloc());
GetIt.I.registerSingleton(DetailsBloc());
GetIt.I.registerSingleton(NavigationBloc(navKey));
+ GetIt.I.registerSingleton(SettingsBloc());
// Load animes
GetIt.I.get().add(
@@ -49,6 +52,9 @@ void main() async {
BlocProvider(
create: (_) => GetIt.I.get(),
),
+ BlocProvider(
+ create: (_) => GetIt.I.get(),
+ ),
],
child: MyApp(navKey),
),
@@ -89,6 +95,8 @@ class MyApp extends StatelessWidget {
return DetailsPage.route;
case aboutRoute:
return AboutPage.route;
+ case settingsRoute:
+ return SettingsPage.route;
}
return null;
diff --git a/lib/src/data/type.dart b/lib/src/data/type.dart
index 03e4659..16068ca 100644
--- a/lib/src/data/type.dart
+++ b/lib/src/data/type.dart
@@ -8,10 +8,22 @@ enum TrackingMediumType {
/// The state of the medium we're tracking, i.e. reading/watching, dropped, ...
enum MediumTrackingState {
+ /// Currently watching or reading
ongoing,
+
+ /// Done
completed,
+
+ /// Plan to watch or read
planned,
+
+ /// Dropped
dropped,
+
+ /// Paused
+ paused,
+
+ /// Meta state
all,
}
@@ -45,6 +57,8 @@ extension MediumStateExtension on MediumTrackingState {
return 2;
case MediumTrackingState.dropped:
return 3;
+ case MediumTrackingState.paused:
+ return 4;
case MediumTrackingState.all:
return -1;
}
@@ -75,6 +89,8 @@ extension MediumStateExtension on MediumTrackingState {
}
case MediumTrackingState.dropped:
return 'Dropped';
+ case MediumTrackingState.paused:
+ return 'Paused';
case MediumTrackingState.all:
return 'All';
}
@@ -96,6 +112,8 @@ class MediumTrackingStateConverter
return MediumTrackingState.planned;
case 3:
return MediumTrackingState.dropped;
+ case 4:
+ return MediumTrackingState.paused;
}
return MediumTrackingState.planned;
diff --git a/lib/src/service/database.dart b/lib/src/service/database.dart
index 50d14b1..0571929 100644
--- a/lib/src/service/database.dart
+++ b/lib/src/service/database.dart
@@ -1,5 +1,6 @@
import 'package:anitrack/src/data/anime.dart';
import 'package:anitrack/src/data/manga.dart';
+import 'package:anitrack/src/service/migrations/0000_score.dart';
import 'package:sqflite/sqflite.dart';
const animeTable = 'Anime';
@@ -14,7 +15,8 @@ Future _createDatabase(Database db, int version) async {
episodesTotal INTEGER,
episodesWatched INTEGER NOT NULL,
thumbnailUrl TEXT NOT NULL,
- title TEXT NOT NULL
+ title TEXT NOT NULL,
+ score INTEGER
)''',
);
await db.execute(
@@ -26,7 +28,8 @@ Future _createDatabase(Database db, int version) async {
chaptersRead INTEGER NOT NULL,
volumesOwned INTEGER NOT NULL,
thumbnailUrl TEXT NOT NULL,
- title TEXT NOT NULL
+ title TEXT NOT NULL,
+ score INTEGER
)''',
);
}
@@ -37,8 +40,23 @@ class DatabaseService {
Future initialize() async {
_db = await openDatabase(
'anitrack.db',
- version: 1,
+ version: 2,
+ onConfigure: (db) async {
+ // In order to do schema changes during database upgrades, we disable foreign
+ // keys in the onConfigure phase, but re-enable them here.
+ // See https://github.com/tekartik/sqflite/issues/624#issuecomment-813324273
+ // for the "solution".
+ await db.execute('PRAGMA foreign_keys = OFF');
+ },
+ onOpen: (db) async {
+ await db.execute('PRAGMA foreign_keys = ON');
+ },
onCreate: _createDatabase,
+ onUpgrade: (db, oldVersion, newVersion) async {
+ if (oldVersion < 2) {
+ await migrateFromV1ToV2(db);
+ }
+ },
);
}
@@ -64,6 +82,7 @@ class DatabaseService {
await _db.insert(
animeTable,
data.toJson(),
+ conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
@@ -88,6 +107,7 @@ class DatabaseService {
await _db.insert(
mangaTable,
data.toJson(),
+ conflictAlgorithm: ConflictAlgorithm.ignore,
);
}
diff --git a/lib/src/service/migrations/0000_score.dart b/lib/src/service/migrations/0000_score.dart
new file mode 100644
index 0000000..736b636
--- /dev/null
+++ b/lib/src/service/migrations/0000_score.dart
@@ -0,0 +1,11 @@
+import 'package:anitrack/src/service/database.dart';
+import 'package:sqflite/sqflite.dart';
+
+Future migrateFromV1ToV2(Database db) async {
+ await db.execute(
+ 'ALTER TABLE $animeTable ADD COLUMN score INTEGER DEFAULT NULL;',
+ );
+ await db.execute(
+ 'ALTER TABLE $mangaTable ADD COLUMN score INTEGER DEFAULT NULL;',
+ );
+}
diff --git a/lib/src/ui/bloc/settings_bloc.dart b/lib/src/ui/bloc/settings_bloc.dart
new file mode 100644
index 0000000..88956ee
--- /dev/null
+++ b/lib/src/ui/bloc/settings_bloc.dart
@@ -0,0 +1,205 @@
+import 'dart:async';
+import 'dart:convert';
+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:archive/archive_io.dart';
+import 'package:bloc/bloc.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:get_it/get_it.dart';
+import 'package:jikan_api/jikan_api.dart';
+import 'package:xml/xml.dart';
+
+part 'settings_state.dart';
+part 'settings_event.dart';
+part 'settings_bloc.freezed.dart';
+
+MediumTrackingState malStatusToTrackingState(String status) {
+ switch (status) {
+ case 'Completed':
+ return MediumTrackingState.completed;
+ case 'Reading':
+ case 'Watching':
+ return MediumTrackingState.ongoing;
+ case 'Plan to Read':
+ case 'Plan to Watch':
+ return MediumTrackingState.planned;
+ case 'Dropped':
+ return MediumTrackingState.dropped;
+ case 'On-Hold':
+ return MediumTrackingState.paused;
+ default:
+ assert(false, 'Invalid status $status');
+ return MediumTrackingState.planned;
+ }
+}
+
+class SettingsBloc extends Bloc {
+ SettingsBloc() : super(SettingsState()) {
+ on(_onAnimeListImported);
+ on(_onMangaListImported);
+ }
+
+ void _showLoadingSpinner(Emitter emit) {
+ emit(
+ state.copyWith(
+ importSpinnerVisible: true,
+ ),
+ );
+ }
+
+ void _hideLoadingSpinner(Emitter emit) {
+ emit(
+ state.copyWith(
+ importSpinnerVisible: false,
+ ),
+ );
+ }
+
+ Future _onAnimeListImported(
+ AnimeListImportedEvent event,
+ Emitter emit,
+ ) async {
+ assert(
+ event.type == ImportListType.mal,
+ 'Only MAL imports are currently supported',
+ );
+
+ _showLoadingSpinner(emit);
+ final inputStream = InputFileStream(event.path);
+ final listRaw = GZipDecoder().decodeBuffer(inputStream);
+ final listXml = utf8.decode(listRaw);
+ final document = XmlDocument.parse(listXml);
+ final mal = document.getElement('myanimelist');
+ if (mal == null) {
+ print('Invalid MAL list export');
+ _hideLoadingSpinner(emit);
+ return;
+ }
+
+ emit(
+ state.copyWith(
+ importCurrent: 0,
+ importTotal: mal.childElements.length - 1,
+ ),
+ );
+
+ for (final anime in mal.childElements) {
+ if (anime.qualifiedName == 'myinfo') {
+ continue;
+ }
+
+ emit(
+ state.copyWith(
+ importCurrent: state.importCurrent + 1,
+ ),
+ );
+
+ final title = anime.getElement('series_title')!.text;
+ final totalEpisodes =
+ int.parse(anime.getElement('series_episodes')!.text);
+ final id = anime.getElement('series_animedb_id')!.text;
+
+ print('Waiting 500ms to not hammer Jikan ($title)');
+ await Future.delayed(const Duration(milliseconds: 500));
+
+ // Query the MAL api
+ final data = await Jikan().getAnime(int.parse(id));
+
+ // Add the anime
+ await GetIt.I.get().addAnime(
+ AnimeTrackingData(
+ id,
+ malStatusToTrackingState(
+ anime.getElement('my_status')!.text,
+ ),
+ title,
+ int.parse(anime.getElement('my_watched_episodes')!.text),
+ // 0 means that MAL does not know
+ totalEpisodes == 0 ? null : totalEpisodes,
+ data.imageUrl,
+ ),
+ );
+ }
+
+ // Hide the spinner again
+ _hideLoadingSpinner(emit);
+ }
+
+ Future _onMangaListImported(
+ MangaListImportedEvent event,
+ Emitter emit,
+ ) async {
+ assert(
+ event.type == ImportListType.mal,
+ 'Only MAL imports are currently supported',
+ );
+
+ _showLoadingSpinner(emit);
+ final inputStream = InputFileStream(event.path);
+ final listRaw = GZipDecoder().decodeBuffer(inputStream);
+ final listXml = utf8.decode(listRaw);
+ final document = XmlDocument.parse(listXml);
+ final mal = document.getElement('myanimelist');
+ if (mal == null) {
+ print('Invalid MAL list export');
+ _hideLoadingSpinner(emit);
+ return;
+ }
+
+ emit(
+ state.copyWith(
+ importCurrent: 0,
+ importTotal: mal.childElements.length - 1,
+ ),
+ );
+
+ for (final manga in mal.childElements) {
+ if (manga.qualifiedName == 'myinfo') {
+ continue;
+ }
+
+ emit(
+ state.copyWith(
+ importCurrent: state.importCurrent + 1,
+ ),
+ );
+
+ final title = manga.getElement('manga_title')!.text;
+ final totalChapters = int.parse(manga.getElement('manga_chapters')!.text);
+ final id = manga.getElement('manga_mangadb_id')!.text;
+
+ print('Waiting 500ms to not hammer Jikan ($title)');
+ await Future.delayed(const Duration(milliseconds: 500));
+
+ // Query the MAL api
+ Manga data;
+ try {
+ data = await Jikan().getManga(int.parse(id));
+ } catch (_) {
+ print('API request failed');
+ continue;
+ }
+
+ // Add the manga
+ await GetIt.I.get().addManga(
+ MangaTrackingData(
+ id,
+ malStatusToTrackingState(
+ manga.getElement('my_status')!.text,
+ ),
+ title,
+ int.parse(manga.getElement('my_read_chapters')!.text),
+ 0,
+ // 0 means that MAL does not know
+ totalChapters == 0 ? null : totalChapters,
+ data.imageUrl,
+ ),
+ );
+ }
+
+ // Hide the spinner again
+ _hideLoadingSpinner(emit);
+ }
+}
diff --git a/lib/src/ui/bloc/settings_bloc.freezed.dart b/lib/src/ui/bloc/settings_bloc.freezed.dart
new file mode 100644
index 0000000..36a24c9
--- /dev/null
+++ b/lib/src/ui/bloc/settings_bloc.freezed.dart
@@ -0,0 +1,177 @@
+// coverage:ignore-file
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target
+
+part of 'settings_bloc.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+T _$identity(T value) => value;
+
+final _privateConstructorUsedError = UnsupportedError(
+ 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
+
+/// @nodoc
+mixin _$SettingsState {
+ bool get importSpinnerVisible => throw _privateConstructorUsedError;
+ int get importCurrent => throw _privateConstructorUsedError;
+ int get importTotal => throw _privateConstructorUsedError;
+
+ @JsonKey(ignore: true)
+ $SettingsStateCopyWith get copyWith =>
+ throw _privateConstructorUsedError;
+}
+
+/// @nodoc
+abstract class $SettingsStateCopyWith<$Res> {
+ factory $SettingsStateCopyWith(
+ SettingsState value, $Res Function(SettingsState) then) =
+ _$SettingsStateCopyWithImpl<$Res>;
+ $Res call({bool importSpinnerVisible, int importCurrent, int importTotal});
+}
+
+/// @nodoc
+class _$SettingsStateCopyWithImpl<$Res>
+ implements $SettingsStateCopyWith<$Res> {
+ _$SettingsStateCopyWithImpl(this._value, this._then);
+
+ final SettingsState _value;
+ // ignore: unused_field
+ final $Res Function(SettingsState) _then;
+
+ @override
+ $Res call({
+ Object? importSpinnerVisible = freezed,
+ Object? importCurrent = freezed,
+ Object? importTotal = freezed,
+ }) {
+ return _then(_value.copyWith(
+ importSpinnerVisible: importSpinnerVisible == freezed
+ ? _value.importSpinnerVisible
+ : importSpinnerVisible // ignore: cast_nullable_to_non_nullable
+ as bool,
+ importCurrent: importCurrent == freezed
+ ? _value.importCurrent
+ : importCurrent // ignore: cast_nullable_to_non_nullable
+ as int,
+ importTotal: importTotal == freezed
+ ? _value.importTotal
+ : importTotal // ignore: cast_nullable_to_non_nullable
+ as int,
+ ));
+ }
+}
+
+/// @nodoc
+abstract class _$$_SettingsStateCopyWith<$Res>
+ implements $SettingsStateCopyWith<$Res> {
+ factory _$$_SettingsStateCopyWith(
+ _$_SettingsState value, $Res Function(_$_SettingsState) then) =
+ __$$_SettingsStateCopyWithImpl<$Res>;
+ @override
+ $Res call({bool importSpinnerVisible, int importCurrent, int importTotal});
+}
+
+/// @nodoc
+class __$$_SettingsStateCopyWithImpl<$Res>
+ extends _$SettingsStateCopyWithImpl<$Res>
+ implements _$$_SettingsStateCopyWith<$Res> {
+ __$$_SettingsStateCopyWithImpl(
+ _$_SettingsState _value, $Res Function(_$_SettingsState) _then)
+ : super(_value, (v) => _then(v as _$_SettingsState));
+
+ @override
+ _$_SettingsState get _value => super._value as _$_SettingsState;
+
+ @override
+ $Res call({
+ Object? importSpinnerVisible = freezed,
+ Object? importCurrent = freezed,
+ Object? importTotal = freezed,
+ }) {
+ return _then(_$_SettingsState(
+ importSpinnerVisible: importSpinnerVisible == freezed
+ ? _value.importSpinnerVisible
+ : importSpinnerVisible // ignore: cast_nullable_to_non_nullable
+ as bool,
+ importCurrent: importCurrent == freezed
+ ? _value.importCurrent
+ : importCurrent // ignore: cast_nullable_to_non_nullable
+ as int,
+ importTotal: importTotal == freezed
+ ? _value.importTotal
+ : importTotal // ignore: cast_nullable_to_non_nullable
+ as int,
+ ));
+ }
+}
+
+/// @nodoc
+
+class _$_SettingsState implements _SettingsState {
+ _$_SettingsState(
+ {this.importSpinnerVisible = false,
+ this.importCurrent = 0,
+ this.importTotal = 0});
+
+ @override
+ @JsonKey()
+ final bool importSpinnerVisible;
+ @override
+ @JsonKey()
+ final int importCurrent;
+ @override
+ @JsonKey()
+ final int importTotal;
+
+ @override
+ String toString() {
+ return 'SettingsState(importSpinnerVisible: $importSpinnerVisible, importCurrent: $importCurrent, importTotal: $importTotal)';
+ }
+
+ @override
+ bool operator ==(dynamic other) {
+ return identical(this, other) ||
+ (other.runtimeType == runtimeType &&
+ other is _$_SettingsState &&
+ const DeepCollectionEquality()
+ .equals(other.importSpinnerVisible, importSpinnerVisible) &&
+ const DeepCollectionEquality()
+ .equals(other.importCurrent, importCurrent) &&
+ const DeepCollectionEquality()
+ .equals(other.importTotal, importTotal));
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ runtimeType,
+ const DeepCollectionEquality().hash(importSpinnerVisible),
+ const DeepCollectionEquality().hash(importCurrent),
+ const DeepCollectionEquality().hash(importTotal));
+
+ @JsonKey(ignore: true)
+ @override
+ _$$_SettingsStateCopyWith<_$_SettingsState> get copyWith =>
+ __$$_SettingsStateCopyWithImpl<_$_SettingsState>(this, _$identity);
+}
+
+abstract class _SettingsState implements SettingsState {
+ factory _SettingsState(
+ {final bool importSpinnerVisible,
+ final int importCurrent,
+ final int importTotal}) = _$_SettingsState;
+
+ @override
+ bool get importSpinnerVisible;
+ @override
+ int get importCurrent;
+ @override
+ int get importTotal;
+ @override
+ @JsonKey(ignore: true)
+ _$$_SettingsStateCopyWith<_$_SettingsState> get copyWith =>
+ throw _privateConstructorUsedError;
+}
diff --git a/lib/src/ui/bloc/settings_event.dart b/lib/src/ui/bloc/settings_event.dart
new file mode 100644
index 0000000..dbc3674
--- /dev/null
+++ b/lib/src/ui/bloc/settings_event.dart
@@ -0,0 +1,36 @@
+part of 'settings_bloc.dart';
+
+enum ImportListType {
+ // MyAnimeList
+ mal,
+}
+
+abstract class SettingsEvent {}
+
+/// Triggered when an anime list is imported
+class AnimeListImportedEvent extends SettingsEvent {
+ AnimeListImportedEvent(
+ this.path,
+ this.type,
+ );
+
+ /// The path to the list we're importing
+ final String path;
+
+ /// The type of list we're importing
+ final ImportListType type;
+}
+
+/// Triggered when a manga list is imported
+class MangaListImportedEvent extends SettingsEvent {
+ MangaListImportedEvent(
+ this.path,
+ this.type,
+ );
+
+ /// The path to the list we're importing
+ final String path;
+
+ /// The type of list we're importing
+ final ImportListType type;
+}
diff --git a/lib/src/ui/bloc/settings_state.dart b/lib/src/ui/bloc/settings_state.dart
new file mode 100644
index 0000000..dcf0a91
--- /dev/null
+++ b/lib/src/ui/bloc/settings_state.dart
@@ -0,0 +1,10 @@
+part of 'settings_bloc.dart';
+
+@freezed
+class SettingsState with _$SettingsState {
+ factory SettingsState({
+ @Default(false) bool importSpinnerVisible,
+ @Default(0) int importCurrent,
+ @Default(0) int importTotal,
+ }) = _SettingsState;
+}
diff --git a/lib/src/ui/constants.dart b/lib/src/ui/constants.dart
index aabfe51..8838633 100644
--- a/lib/src/ui/constants.dart
+++ b/lib/src/ui/constants.dart
@@ -2,3 +2,4 @@ const animeListRoute = '/anime/list';
const animeSearchRoute = '/anime/search';
const detailsRoute = '/anime/details';
const aboutRoute = '/about';
+const settingsRoute = '/settings';
diff --git a/lib/src/ui/pages/anime_list.dart b/lib/src/ui/pages/anime_list.dart
index 9df1228..69a153f 100644
--- a/lib/src/ui/pages/anime_list.dart
+++ b/lib/src/ui/pages/anime_list.dart
@@ -84,6 +84,10 @@ class AnimeListPageState extends State {
value: MediumTrackingState.dropped,
child: Text(MediumTrackingState.dropped.toNameString(type)),
),
+ PopupMenuItem(
+ value: MediumTrackingState.paused,
+ child: Text(MediumTrackingState.paused.toNameString(type)),
+ ),
const PopupMenuItem(
value: MediumTrackingState.all,
child: Text('All'),
@@ -150,6 +154,13 @@ class AnimeListPageState extends State {
),
),
),
+ ListTile(
+ leading: const Icon(Icons.settings),
+ title: const Text('Settings'),
+ onTap: () {
+ Navigator.of(context).pushNamed(settingsRoute);
+ },
+ ),
ListTile(
leading: const Icon(Icons.info),
title: const Text('About'),
diff --git a/lib/src/ui/pages/details.dart b/lib/src/ui/pages/details.dart
index fe9ebaf..90bb652 100644
--- a/lib/src/ui/pages/details.dart
+++ b/lib/src/ui/pages/details.dart
@@ -166,6 +166,11 @@ class DetailsPage extends StatelessWidget {
MediumTrackingState.dropped
.toNameString(state.trackingType),
),
+ SelectorItem(
+ MediumTrackingState.paused,
+ MediumTrackingState.paused
+ .toNameString(state.trackingType),
+ ),
],
initialValue: state.data!.state,
),
diff --git a/lib/src/ui/pages/settings.dart b/lib/src/ui/pages/settings.dart
new file mode 100644
index 0000000..363c101
--- /dev/null
+++ b/lib/src/ui/pages/settings.dart
@@ -0,0 +1,155 @@
+import 'package:anitrack/src/ui/bloc/settings_bloc.dart';
+import 'package:anitrack/src/ui/constants.dart';
+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';
+
+class SettingsPage extends StatelessWidget {
+ const SettingsPage({super.key});
+
+ static MaterialPageRoute get route => MaterialPageRoute(
+ builder: (_) => const SettingsPage(),
+ settings: const RouteSettings(
+ name: settingsRoute,
+ ),
+ );
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocBuilder(
+ builder: (context, state) {
+ return WillPopScope(
+ onWillPop: () async {
+ return !state.importSpinnerVisible;
+ },
+ child: Stack(
+ children: [
+ Positioned(
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ child: Scaffold(
+ appBar: AppBar(
+ title: const Text('Settings'),
+ ),
+ body: ListView(
+ children: [
+ ListTile(
+ title: const Text('Import anime list'),
+ subtitle: const Text(
+ 'Import anime list exported from MyAnimeList.',
+ ),
+ onTap: () async {
+ // Pick the file
+ final result = await FilePicker.platform.pickFiles();
+ if (result == null) return;
+
+ if (!result.files.first.path!.endsWith('.xml.gz')) {
+ await showDialog(
+ context: context,
+ builder: (_) => const AlertDialog(
+ title: Text('Invalid anime list'),
+ content: Text(
+ 'The selected file is not a MAL anime list. It lacks the ".xml.gz" suffix.',
+ ),
+ ),
+ );
+ return;
+ }
+
+ GetIt.I.get().add(
+ AnimeListImportedEvent(
+ result.files.first.path!,
+ ImportListType.mal,
+ ),
+ );
+ },
+ ),
+ ListTile(
+ title: const Text('Import manga list'),
+ subtitle: const Text(
+ 'Import manga list exported from MyAnimeList.',
+ ),
+ onTap: () async {
+ // Pick the file
+ final result = await FilePicker.platform.pickFiles();
+ if (result == null) return;
+
+ if (!result.files.first.path!.endsWith('.xml.gz')) {
+ await showDialog(
+ context: context,
+ builder: (_) => const AlertDialog(
+ title: Text('Invalid manga list'),
+ content: Text(
+ 'The selected file is not a MAL manga list. It lacks the ".xml.gz" suffix.',
+ ),
+ ),
+ );
+ return;
+ }
+
+ GetIt.I.get().add(
+ MangaListImportedEvent(
+ result.files.first.path!,
+ ImportListType.mal,
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ if (state.importSpinnerVisible)
+ const Positioned(
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ child: ModalBarrier(
+ dismissible: false,
+ color: Colors.black54,
+ ),
+ ),
+ if (state.importSpinnerVisible)
+ Positioned(
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ child: Center(
+ child: SizedBox(
+ width: 150,
+ height: 150,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(10),
+ color: Colors.grey.shade800,
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Padding(
+ padding: EdgeInsets.all(25),
+ child: CircularProgressIndicator(),
+ ),
+ Text(
+ '${state.importCurrent} of ${state.importTotal}',
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index 7ce94bf..6484cb3 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -18,13 +18,13 @@ packages:
source: hosted
version: "5.2.0"
archive:
- dependency: transitive
+ dependency: "direct main"
description:
name: archive
- sha256: d6347d54a2d8028e0437e3c099f66fdb8ae02c4720c1e7534c9f24c10351f85d
+ sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
url: "https://pub.dev"
source: hosted
- version: "3.3.6"
+ version: "3.3.7"
args:
dependency: transitive
description:
@@ -194,7 +194,7 @@ packages:
source: hosted
version: "4.4.0"
collection:
- dependency: transitive
+ dependency: "direct main"
description:
name: collection
sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0
@@ -265,6 +265,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.4"
+ file_picker:
+ dependency: "direct main"
+ description:
+ name: file_picker
+ sha256: dd328189f2f4ccea042bb5b382d5e981691cc74b5a3429b9317bff2b19704489
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.2.8"
fixnum:
dependency: transitive
description:
@@ -326,6 +334,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
+ flutter_plugin_android_lifecycle:
+ dependency: transitive
+ description:
+ name: flutter_plugin_android_lifecycle
+ sha256: c224ac897bed083dabf11f238dd11a239809b446740be0c2044608c50029ffdf
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.9"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -934,13 +950,13 @@ packages:
source: hosted
version: "0.2.0+3"
xml:
- dependency: transitive
+ dependency: "direct main"
description:
name: xml
- sha256: ac0e3f4bf00ba2708c33fbabbbe766300e509f8c82dbd4ab6525039813f7e2fb
+ sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5"
url: "https://pub.dev"
source: hosted
- version: "6.1.0"
+ version: "6.2.2"
yaml:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 24bc67a..6c2f485 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -2,16 +2,19 @@ name: anitrack
description: An anime and manga tracker
publish_to: 'none'
-version: 0.1.1+6
+version: 0.1.2+8
environment:
sdk: '>=2.18.4 <3.0.0'
dependencies:
+ archive: ^3.3.7
bloc: ^8.1.0
bottom_bar: ^2.0.3
cached_network_image: ^3.2.3
+ collection: ^1.17.0
cupertino_icons: ^1.0.2
+ file_picker: ^5.2.8
flutter:
sdk: flutter
flutter_bloc: ^8.1.1
@@ -22,6 +25,7 @@ dependencies:
sqflite: ^2.2.4+1
swipeable_tile: ^2.0.0+3
url_launcher: ^6.1.8
+ xml: ^6.2.2
dev_dependencies:
build_runner: ^2.1.11