feat(all): Implement MAL import for anime and manga lists
This commit is contained in:
parent
d407a90724
commit
7530fe5b80
@ -32,5 +32,5 @@
|
|||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -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/anime_search_bloc.dart';
|
||||||
import 'package:anitrack/src/ui/bloc/details_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/navigation_bloc.dart';
|
||||||
|
import 'package:anitrack/src/ui/bloc/settings_bloc.dart';
|
||||||
import 'package:anitrack/src/ui/constants.dart';
|
import 'package:anitrack/src/ui/constants.dart';
|
||||||
import 'package:anitrack/src/ui/pages/about.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_list.dart';
|
||||||
import 'package:anitrack/src/ui/pages/anime_search.dart';
|
import 'package:anitrack/src/ui/pages/anime_search.dart';
|
||||||
import 'package:anitrack/src/ui/pages/details.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/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';
|
||||||
@ -28,6 +30,7 @@ void main() async {
|
|||||||
GetIt.I.registerSingleton<AnimeSearchBloc>(AnimeSearchBloc());
|
GetIt.I.registerSingleton<AnimeSearchBloc>(AnimeSearchBloc());
|
||||||
GetIt.I.registerSingleton<DetailsBloc>(DetailsBloc());
|
GetIt.I.registerSingleton<DetailsBloc>(DetailsBloc());
|
||||||
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey));
|
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navKey));
|
||||||
|
GetIt.I.registerSingleton<SettingsBloc>(SettingsBloc());
|
||||||
|
|
||||||
// Load animes
|
// Load animes
|
||||||
GetIt.I.get<AnimeListBloc>().add(
|
GetIt.I.get<AnimeListBloc>().add(
|
||||||
@ -49,6 +52,9 @@ void main() async {
|
|||||||
BlocProvider<NavigationBloc>(
|
BlocProvider<NavigationBloc>(
|
||||||
create: (_) => GetIt.I.get<NavigationBloc>(),
|
create: (_) => GetIt.I.get<NavigationBloc>(),
|
||||||
),
|
),
|
||||||
|
BlocProvider<SettingsBloc>(
|
||||||
|
create: (_) => GetIt.I.get<SettingsBloc>(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: MyApp(navKey),
|
child: MyApp(navKey),
|
||||||
),
|
),
|
||||||
@ -89,6 +95,8 @@ class MyApp extends StatelessWidget {
|
|||||||
return DetailsPage.route;
|
return DetailsPage.route;
|
||||||
case aboutRoute:
|
case aboutRoute:
|
||||||
return AboutPage.route;
|
return AboutPage.route;
|
||||||
|
case settingsRoute:
|
||||||
|
return SettingsPage.route;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
@ -8,10 +8,22 @@ 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
|
||||||
ongoing,
|
ongoing,
|
||||||
|
|
||||||
|
/// Done
|
||||||
completed,
|
completed,
|
||||||
|
|
||||||
|
/// Plan to watch or read
|
||||||
planned,
|
planned,
|
||||||
|
|
||||||
|
/// Dropped
|
||||||
dropped,
|
dropped,
|
||||||
|
|
||||||
|
/// Paused
|
||||||
|
paused,
|
||||||
|
|
||||||
|
/// Meta state
|
||||||
all,
|
all,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +57,8 @@ extension MediumStateExtension on MediumTrackingState {
|
|||||||
return 2;
|
return 2;
|
||||||
case MediumTrackingState.dropped:
|
case MediumTrackingState.dropped:
|
||||||
return 3;
|
return 3;
|
||||||
|
case MediumTrackingState.paused:
|
||||||
|
return 4;
|
||||||
case MediumTrackingState.all:
|
case MediumTrackingState.all:
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@ -75,6 +89,8 @@ extension MediumStateExtension on MediumTrackingState {
|
|||||||
}
|
}
|
||||||
case MediumTrackingState.dropped:
|
case MediumTrackingState.dropped:
|
||||||
return 'Dropped';
|
return 'Dropped';
|
||||||
|
case MediumTrackingState.paused:
|
||||||
|
return 'Paused';
|
||||||
case MediumTrackingState.all:
|
case MediumTrackingState.all:
|
||||||
return 'All';
|
return 'All';
|
||||||
}
|
}
|
||||||
@ -96,6 +112,8 @@ class MediumTrackingStateConverter
|
|||||||
return MediumTrackingState.planned;
|
return MediumTrackingState.planned;
|
||||||
case 3:
|
case 3:
|
||||||
return MediumTrackingState.dropped;
|
return MediumTrackingState.dropped;
|
||||||
|
case 4:
|
||||||
|
return MediumTrackingState.paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
return MediumTrackingState.planned;
|
return MediumTrackingState.planned;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
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/service/migrations/0000_score.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
const animeTable = 'Anime';
|
const animeTable = 'Anime';
|
||||||
@ -14,7 +15,8 @@ Future<void> _createDatabase(Database db, int version) async {
|
|||||||
episodesTotal INTEGER,
|
episodesTotal INTEGER,
|
||||||
episodesWatched INTEGER NOT NULL,
|
episodesWatched INTEGER NOT NULL,
|
||||||
thumbnailUrl TEXT NOT NULL,
|
thumbnailUrl TEXT NOT NULL,
|
||||||
title TEXT NOT NULL
|
title TEXT NOT NULL,
|
||||||
|
score INTEGER
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@ -26,7 +28,8 @@ Future<void> _createDatabase(Database db, int version) async {
|
|||||||
chaptersRead INTEGER NOT NULL,
|
chaptersRead INTEGER NOT NULL,
|
||||||
volumesOwned INTEGER NOT NULL,
|
volumesOwned INTEGER NOT NULL,
|
||||||
thumbnailUrl TEXT NOT NULL,
|
thumbnailUrl TEXT NOT NULL,
|
||||||
title TEXT NOT NULL
|
title TEXT NOT NULL,
|
||||||
|
score INTEGER
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -37,8 +40,23 @@ class DatabaseService {
|
|||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
_db = await openDatabase(
|
_db = await openDatabase(
|
||||||
'anitrack.db',
|
'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,
|
onCreate: _createDatabase,
|
||||||
|
onUpgrade: (db, oldVersion, newVersion) async {
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
await migrateFromV1ToV2(db);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +82,7 @@ class DatabaseService {
|
|||||||
await _db.insert(
|
await _db.insert(
|
||||||
animeTable,
|
animeTable,
|
||||||
data.toJson(),
|
data.toJson(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,6 +107,7 @@ class DatabaseService {
|
|||||||
await _db.insert(
|
await _db.insert(
|
||||||
mangaTable,
|
mangaTable,
|
||||||
data.toJson(),
|
data.toJson(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
11
lib/src/service/migrations/0000_score.dart
Normal file
11
lib/src/service/migrations/0000_score.dart
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import 'package:anitrack/src/service/database.dart';
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> 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;',
|
||||||
|
);
|
||||||
|
}
|
205
lib/src/ui/bloc/settings_bloc.dart
Normal file
205
lib/src/ui/bloc/settings_bloc.dart
Normal file
@ -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<SettingsEvent, SettingsState> {
|
||||||
|
SettingsBloc() : super(SettingsState()) {
|
||||||
|
on<AnimeListImportedEvent>(_onAnimeListImported);
|
||||||
|
on<MangaListImportedEvent>(_onMangaListImported);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLoadingSpinner(Emitter<SettingsState> emit) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
importSpinnerVisible: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hideLoadingSpinner(Emitter<SettingsState> emit) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
importSpinnerVisible: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onAnimeListImported(
|
||||||
|
AnimeListImportedEvent event,
|
||||||
|
Emitter<SettingsState> 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<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Query the MAL api
|
||||||
|
final data = await Jikan().getAnime(int.parse(id));
|
||||||
|
|
||||||
|
// Add the anime
|
||||||
|
await GetIt.I.get<DatabaseService>().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<void> _onMangaListImported(
|
||||||
|
MangaListImportedEvent event,
|
||||||
|
Emitter<SettingsState> 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<void>.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<DatabaseService>().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);
|
||||||
|
}
|
||||||
|
}
|
177
lib/src/ui/bloc/settings_bloc.freezed.dart
Normal file
177
lib/src/ui/bloc/settings_bloc.freezed.dart
Normal file
@ -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>(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<SettingsState> 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;
|
||||||
|
}
|
36
lib/src/ui/bloc/settings_event.dart
Normal file
36
lib/src/ui/bloc/settings_event.dart
Normal file
@ -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;
|
||||||
|
}
|
10
lib/src/ui/bloc/settings_state.dart
Normal file
10
lib/src/ui/bloc/settings_state.dart
Normal file
@ -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;
|
||||||
|
}
|
@ -2,3 +2,4 @@ const animeListRoute = '/anime/list';
|
|||||||
const animeSearchRoute = '/anime/search';
|
const animeSearchRoute = '/anime/search';
|
||||||
const detailsRoute = '/anime/details';
|
const detailsRoute = '/anime/details';
|
||||||
const aboutRoute = '/about';
|
const aboutRoute = '/about';
|
||||||
|
const settingsRoute = '/settings';
|
||||||
|
@ -84,6 +84,10 @@ class AnimeListPageState extends State<AnimeListPage> {
|
|||||||
value: MediumTrackingState.dropped,
|
value: MediumTrackingState.dropped,
|
||||||
child: Text(MediumTrackingState.dropped.toNameString(type)),
|
child: Text(MediumTrackingState.dropped.toNameString(type)),
|
||||||
),
|
),
|
||||||
|
PopupMenuItem<MediumTrackingState>(
|
||||||
|
value: MediumTrackingState.paused,
|
||||||
|
child: Text(MediumTrackingState.paused.toNameString(type)),
|
||||||
|
),
|
||||||
const PopupMenuItem<MediumTrackingState>(
|
const PopupMenuItem<MediumTrackingState>(
|
||||||
value: MediumTrackingState.all,
|
value: MediumTrackingState.all,
|
||||||
child: Text('All'),
|
child: Text('All'),
|
||||||
@ -150,6 +154,13 @@ class AnimeListPageState extends State<AnimeListPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.settings),
|
||||||
|
title: const Text('Settings'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pushNamed(settingsRoute);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.info),
|
leading: const Icon(Icons.info),
|
||||||
title: const Text('About'),
|
title: const Text('About'),
|
||||||
|
@ -166,6 +166,11 @@ class DetailsPage extends StatelessWidget {
|
|||||||
MediumTrackingState.dropped
|
MediumTrackingState.dropped
|
||||||
.toNameString(state.trackingType),
|
.toNameString(state.trackingType),
|
||||||
),
|
),
|
||||||
|
SelectorItem(
|
||||||
|
MediumTrackingState.paused,
|
||||||
|
MediumTrackingState.paused
|
||||||
|
.toNameString(state.trackingType),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
initialValue: state.data!.state,
|
initialValue: state.data!.state,
|
||||||
),
|
),
|
||||||
|
155
lib/src/ui/pages/settings.dart
Normal file
155
lib/src/ui/pages/settings.dart
Normal file
@ -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<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
|
builder: (_) => const SettingsPage(),
|
||||||
|
settings: const RouteSettings(
|
||||||
|
name: settingsRoute,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<SettingsBloc, SettingsState>(
|
||||||
|
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<void>(
|
||||||
|
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<SettingsBloc>().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<void>(
|
||||||
|
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<SettingsBloc>().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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
30
pubspec.lock
30
pubspec.lock
@ -18,13 +18,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "5.2.0"
|
version: "5.2.0"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: archive
|
name: archive
|
||||||
sha256: d6347d54a2d8028e0437e3c099f66fdb8ae02c4720c1e7534c9f24c10351f85d
|
sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.6"
|
version: "3.3.7"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -194,7 +194,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.4.0"
|
version: "4.4.0"
|
||||||
collection:
|
collection:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: collection
|
name: collection
|
||||||
sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0
|
sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0
|
||||||
@ -265,6 +265,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.4"
|
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:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -326,6 +334,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@ -934,13 +950,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0+3"
|
version: "0.2.0+3"
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: ac0e3f4bf00ba2708c33fbabbbe766300e509f8c82dbd4ab6525039813f7e2fb
|
sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "6.2.2"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -2,16 +2,19 @@ name: anitrack
|
|||||||
description: An anime and manga tracker
|
description: An anime and manga tracker
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 0.1.1+6
|
version: 0.1.2+8
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.18.4 <3.0.0'
|
sdk: '>=2.18.4 <3.0.0'
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
archive: ^3.3.7
|
||||||
bloc: ^8.1.0
|
bloc: ^8.1.0
|
||||||
bottom_bar: ^2.0.3
|
bottom_bar: ^2.0.3
|
||||||
cached_network_image: ^3.2.3
|
cached_network_image: ^3.2.3
|
||||||
|
collection: ^1.17.0
|
||||||
cupertino_icons: ^1.0.2
|
cupertino_icons: ^1.0.2
|
||||||
|
file_picker: ^5.2.8
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_bloc: ^8.1.1
|
flutter_bloc: ^8.1.1
|
||||||
@ -22,6 +25,7 @@ dependencies:
|
|||||||
sqflite: ^2.2.4+1
|
sqflite: ^2.2.4+1
|
||||||
swipeable_tile: ^2.0.0+3
|
swipeable_tile: ^2.0.0+3
|
||||||
url_launcher: ^6.1.8
|
url_launcher: ^6.1.8
|
||||||
|
xml: ^6.2.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.1.11
|
build_runner: ^2.1.11
|
||||||
|
Loading…
Reference in New Issue
Block a user