mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-01-11 22:40:36 +00:00
Merge pull request #446 from NBA2K1/main
Refactor code, fix AniList/MAL bugs, add local scan (#148, #349)
This commit is contained in:
commit
e46975e381
52 changed files with 1767 additions and 2301 deletions
|
|
@ -153,55 +153,76 @@ class MBridge {
|
|||
}
|
||||
|
||||
///Read values in parsed JSON object and return resut to List<String>
|
||||
static const $Function jsonPathToList = $Function(_jsonPathToList);
|
||||
static final $Function jsonPathToList = $Function(
|
||||
(runtime, thisObj, List<$Value?> args) =>
|
||||
_jsonPathToStringOrList(runtime, thisObj, true, args),
|
||||
);
|
||||
|
||||
static $Value? _jsonPathToList(_, __, List<$Value?> args) {
|
||||
String source = args[0]!.$reified;
|
||||
String expression = args[1]!.$reified;
|
||||
int type = args[2]!.$reified;
|
||||
static $Value? _jsonPathToStringOrList(
|
||||
Object? runtime, // unused by bridge
|
||||
Object? thisObj, // unused by bridge
|
||||
bool toList, // new flag: list vs. single-string
|
||||
List<$Value?> args, // [ sourceJson, jsonPathExpr ]
|
||||
) {
|
||||
final source = args[0]!.$reified;
|
||||
final expression = args[1]!.$reified;
|
||||
|
||||
dynamic decoded;
|
||||
try {
|
||||
//Check jsonDecode(source) is list value
|
||||
if (jsonDecode(source) is List) {
|
||||
List<dynamic> values = [];
|
||||
final val = jsonDecode(source) as List;
|
||||
for (var element in val) {
|
||||
final mMap = element as Map?;
|
||||
Map<String, dynamic> map = {};
|
||||
if (mMap != null) {
|
||||
map = mMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
values.add(map);
|
||||
}
|
||||
List<String> list = [];
|
||||
for (var data in values) {
|
||||
final jsonRes = JsonPath(expression).read(data);
|
||||
String val = "";
|
||||
|
||||
//Get jsonRes first string value
|
||||
if (type == 0) {
|
||||
val = jsonRes.first.value.toString();
|
||||
}
|
||||
//Decode jsonRes first map value
|
||||
else {
|
||||
val = jsonEncode(jsonRes.first.value);
|
||||
}
|
||||
list.add(val);
|
||||
}
|
||||
return $List.wrap(list.map((e) => $String(e)).toList());
|
||||
}
|
||||
// else jsonDecode(source) is Map value
|
||||
else {
|
||||
var map = json.decode(source);
|
||||
var values = JsonPath(expression).readValues(map);
|
||||
return $List.wrap(
|
||||
values.map((e) {
|
||||
return $String(e == null ? "{}" : json.encode(e));
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
decoded = jsonDecode(source);
|
||||
} catch (_) {
|
||||
return $List.wrap([]);
|
||||
return toList ? $List.wrap(<$Value>[]) : $String('');
|
||||
}
|
||||
|
||||
// Normalize a JSON element (either Map or List) into a Map<String, dynamic>
|
||||
Map<String, dynamic> normalize(dynamic elt) {
|
||||
if (elt is Map) {
|
||||
return elt.map((k, v) => MapEntry(k.toString(), v));
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
|
||||
/// Common JSONPath read logic
|
||||
List<dynamic> extractList(Map<String, dynamic> dataMap) {
|
||||
// readValues returns all matches; .read returns Match objects
|
||||
final matches = JsonPath(expression).read(dataMap);
|
||||
return matches.map((m) => m.value).toList();
|
||||
}
|
||||
|
||||
if (decoded is List) {
|
||||
// Branch: JSON root is a list → always return a List<$String>
|
||||
final out = <$Value>[];
|
||||
for (var elt in decoded) {
|
||||
final map = normalize(elt);
|
||||
final extracted = extractList(map);
|
||||
if (toList) {
|
||||
// join into JSON strings per element
|
||||
out.addAll(extracted.map((e) => $String(jsonEncode(e))));
|
||||
} else if (extracted.isNotEmpty) {
|
||||
// only first match as string
|
||||
out.add($String(extracted.first.toString()));
|
||||
} else {
|
||||
out.add($String(''));
|
||||
}
|
||||
}
|
||||
return $List.wrap(out);
|
||||
} else if (decoded is Map) {
|
||||
// Branch: JSON root is object
|
||||
final map = normalize(decoded);
|
||||
final extracted = extractList(map);
|
||||
if (toList) {
|
||||
return $List.wrap(
|
||||
extracted
|
||||
.map((e) => $String(e == null ? '{}' : jsonEncode(e)))
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return $String(extracted.isNotEmpty ? extracted.first.toString() : '');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: neither List nor Map
|
||||
return toList ? $List.wrap(<$Value>[]) : $String('');
|
||||
}
|
||||
|
||||
///GetMapValue
|
||||
|
|
@ -218,54 +239,10 @@ class MBridge {
|
|||
}
|
||||
|
||||
///Read values in parsed JSON object and return resut to String
|
||||
static const $Function jsonPathToString = $Function(_jsonPathToString);
|
||||
|
||||
static $Value? _jsonPathToString(_, __, List<$Value?> args) {
|
||||
String source = args[0]!.$reified;
|
||||
String expression = args[1]!.$reified;
|
||||
String join = args[2]!.$reified;
|
||||
try {
|
||||
List<dynamic> values = [];
|
||||
|
||||
//Check jsonDecode(source) is list value
|
||||
if (jsonDecode(source) is List) {
|
||||
final val = jsonDecode(source) as List;
|
||||
for (var element in val) {
|
||||
final mMap = element as Map?;
|
||||
Map<String, dynamic> map = {};
|
||||
if (mMap != null) {
|
||||
map = mMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
values.add(map);
|
||||
}
|
||||
}
|
||||
// else jsonDecode(source) is Map value
|
||||
else {
|
||||
final mMap = jsonDecode(source) as Map?;
|
||||
Map<String, dynamic> map = {};
|
||||
if (mMap != null) {
|
||||
map = mMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
values.add(map);
|
||||
}
|
||||
|
||||
List<String> listRg = [];
|
||||
|
||||
for (var data in values) {
|
||||
final jsonRes = JsonPath(expression).readValues(data);
|
||||
List list = [];
|
||||
|
||||
for (var element in jsonRes) {
|
||||
list.add(element);
|
||||
}
|
||||
//join the list into listRg
|
||||
listRg.add(list.join(join));
|
||||
}
|
||||
return $String(listRg.first);
|
||||
} catch (_) {
|
||||
return $String("");
|
||||
}
|
||||
}
|
||||
static final $Function jsonPathToString = $Function(
|
||||
(runtime, thisObj, List<$Value?> args) =>
|
||||
_jsonPathToStringOrList(runtime, thisObj, false, args),
|
||||
);
|
||||
|
||||
//Parse a list of dates to millisecondsSinceEpoch
|
||||
static List parseDates(
|
||||
|
|
@ -348,19 +325,18 @@ class MBridge {
|
|||
return await FilemoonExtractor().videosFromUrl(url, prefix, suffix);
|
||||
}
|
||||
|
||||
static Map<String, String> decodeHeaders(String? headers) =>
|
||||
headers == null ? {} : (jsonDecode(headers) as Map).toMapStringString!;
|
||||
|
||||
static Future<List<Video>> mp4UploadExtractor(
|
||||
String url,
|
||||
String? headers,
|
||||
String prefix,
|
||||
String suffix,
|
||||
) async {
|
||||
Map<String, String> newHeaders = {};
|
||||
if (headers != null) {
|
||||
newHeaders = (jsonDecode(headers) as Map).toMapStringString!;
|
||||
}
|
||||
return await Mp4uploadExtractor().videosFromUrl(
|
||||
url,
|
||||
newHeaders,
|
||||
decodeHeaders(headers),
|
||||
prefix: prefix,
|
||||
suffix: suffix,
|
||||
);
|
||||
|
|
@ -615,13 +591,8 @@ class MBridge {
|
|||
String? headers,
|
||||
String prefix,
|
||||
) async {
|
||||
Map<String, String> newHeaders = {};
|
||||
if (headers != null) {
|
||||
newHeaders = (jsonDecode(headers) as Map).toMapStringString!;
|
||||
}
|
||||
|
||||
return await SendvidExtractor(
|
||||
newHeaders,
|
||||
decodeHeaders(headers),
|
||||
).videosFromUrl(url, prefix: prefix);
|
||||
}
|
||||
|
||||
|
|
@ -639,13 +610,9 @@ class MBridge {
|
|||
String? name,
|
||||
String prefix,
|
||||
) async {
|
||||
Map<String, String> newHeaders = {};
|
||||
if (headers != null) {
|
||||
newHeaders = (jsonDecode(headers) as Map).toMapStringString!;
|
||||
}
|
||||
return await YourUploadExtractor().videosFromUrl(
|
||||
url,
|
||||
newHeaders,
|
||||
decodeHeaders(headers),
|
||||
prefix: prefix,
|
||||
name: name ?? "YourUpload",
|
||||
);
|
||||
|
|
@ -687,15 +654,11 @@ class MBridge {
|
|||
List<Track>? subtitles,
|
||||
List<Track>? audios,
|
||||
) {
|
||||
Map<String, String> newHeaders = {};
|
||||
if (headers != null) {
|
||||
newHeaders = (jsonDecode(headers) as Map).toMapStringString!;
|
||||
}
|
||||
return Video(
|
||||
url,
|
||||
quality,
|
||||
originalUrl,
|
||||
headers: newHeaders,
|
||||
headers: decodeHeaders(headers),
|
||||
subtitles: subtitles ?? [],
|
||||
audios: audios ?? [],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "فتح مدخل عشوائي",
|
||||
"import": "استيراد",
|
||||
"filter": "مرشح",
|
||||
"ignore_filters": "تجاهل مرشح",
|
||||
"downloaded": "تم التحميل",
|
||||
"unread": "غير مقروء",
|
||||
"started": "بدأ",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Zufälligen Eintrag öffnen",
|
||||
"import": "Importieren",
|
||||
"filter": "Filter",
|
||||
"ignore_filters": "Filter ignorieren",
|
||||
"downloaded": "Heruntergeladen",
|
||||
"unread": "Ungelesen",
|
||||
"unwatched": "Nicht angeschaut",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Open random entry",
|
||||
"import": "Import",
|
||||
"filter": "Filter",
|
||||
"ignore_filters": "Ignore Filters",
|
||||
"downloaded": "Downloaded",
|
||||
"unread": "Unread",
|
||||
"unwatched": "Unwatched",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Abrir entrada aleatoria",
|
||||
"import": "Importar",
|
||||
"filter": "Filtrar",
|
||||
"ignore_filters": "Ignorar filtros",
|
||||
"downloaded": "Descargado",
|
||||
"unread": "Sin leer",
|
||||
"started": "Comenzado",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Abrir entrada aleatoria",
|
||||
"import": "Importar",
|
||||
"filter": "Filtrar",
|
||||
"ignore_filters": "Ignorar filtros",
|
||||
"downloaded": "Descargado",
|
||||
"unread": "Sin leer",
|
||||
"started": "Comenzado",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Ouvrir une entrée au hasard",
|
||||
"import": "Importer",
|
||||
"filter": "Filtre",
|
||||
"ignore_filters": "Ignorer les filtres",
|
||||
"downloaded": "Téléchargé",
|
||||
"unread": "Non lus",
|
||||
"started": "Commencé",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Buka Entri Acak",
|
||||
"import": "Impor",
|
||||
"filter": "Filter",
|
||||
"ignore_filters": "Abaikan filter",
|
||||
"downloaded": "Telah Diunduh",
|
||||
"unread": "Belum Dibaca",
|
||||
"started": "Dimulai",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Apri voce casuale",
|
||||
"import": "Importa",
|
||||
"filter": "Filtro",
|
||||
"ignore_filters": "Ignora filtri",
|
||||
"downloaded": "Scaricato",
|
||||
"unread": "Non letto",
|
||||
"started": "Iniziato",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Abrir entrada aleatória",
|
||||
"import": "Importar",
|
||||
"filter": "Filtro",
|
||||
"ignore_filters": "Ignorar filtros",
|
||||
"downloaded": "Baixados",
|
||||
"unread": "Não lidos",
|
||||
"started": "Iniciados",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Abrir entrada aleatória",
|
||||
"import": "Importar",
|
||||
"filter": "Filtro",
|
||||
"ignore_filters": "Ignorar filtros",
|
||||
"downloaded": "Baixado",
|
||||
"unread": "Não lido",
|
||||
"started": "Iniciado",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Открыть случайную запись",
|
||||
"import": "Импорт",
|
||||
"filter": "Фильтр",
|
||||
"ignore_filters": "Игнорировать фильтры",
|
||||
"downloaded": "Загружено",
|
||||
"unread": "Непрочитанное",
|
||||
"started": "Начато",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
"open_random_entry": "สุ่มอ่าน",
|
||||
"import": "นำเข้า",
|
||||
"filter": "ตัวกรอง",
|
||||
"ignore_filters": "ไม่สนใจ\nตัวกรอง",
|
||||
"@ignore_filters": {
|
||||
"description": "Manual line break added for better rendering of the ignore filters text on mobile."
|
||||
},
|
||||
"downloaded": "ดาวน์โหลดแล้ว",
|
||||
"unread": "ยังไม่อ่าน",
|
||||
"started": "เริ่มแล้ว",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"open_random_entry": "Rastgele Giriş Aç",
|
||||
"import": "İçe Aktar",
|
||||
"filter": "Filtre",
|
||||
"ignore_filters": "Filtreleri yok say",
|
||||
"downloaded": "İndirildi",
|
||||
"unread": "Okunmamış",
|
||||
"started": "Başladı",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
"open_random_entry": "随机打开条目",
|
||||
"import": "导入",
|
||||
"filter": "筛选",
|
||||
"ignore_filters": "忽略\n筛选",
|
||||
"@ignore_filters": {
|
||||
"description": "Manual line break added for better rendering of the ignore filters text on mobile."
|
||||
},
|
||||
"downloaded": "已下载",
|
||||
"unread": "未读",
|
||||
"started": "已开始",
|
||||
|
|
|
|||
|
|
@ -165,6 +165,12 @@ abstract class AppLocalizations {
|
|||
/// **'Filter'**
|
||||
String get filter;
|
||||
|
||||
/// No description provided for @ignore_filters.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Ignore Filters'**
|
||||
String get ignore_filters;
|
||||
|
||||
/// No description provided for @downloaded.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'مرشح';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'تجاهل مرشح';
|
||||
|
||||
@override
|
||||
String get downloaded => 'تم التحميل';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Filter';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Filter ignorieren';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Heruntergeladen';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Filter';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Ignore Filters';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Downloaded';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Filtrar';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Ignorar filtros';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Descargado';
|
||||
|
||||
|
|
@ -1466,6 +1469,9 @@ class AppLocalizationsEs419 extends AppLocalizationsEs {
|
|||
@override
|
||||
String get filter => 'Filtrar';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Ignorar filtros';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Descargado';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Filtre';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Ignorer les filtres';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Téléchargé';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Filter';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Abaikan filter';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Telah Diunduh';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Filtro';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Ignora filtri';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Scaricato';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Filtro';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Ignorar filtros';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Baixados';
|
||||
|
||||
|
|
@ -1466,6 +1469,9 @@ class AppLocalizationsPtBr extends AppLocalizationsPt {
|
|||
@override
|
||||
String get filter => 'Filtro';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Ignorar filtros';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Baixado';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Фильтр';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Игнорировать фильтры';
|
||||
|
||||
@override
|
||||
String get downloaded => 'Загружено';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsTh extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'ตัวกรอง';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'ไม่สนใจ\nตัวกรอง';
|
||||
|
||||
@override
|
||||
String get downloaded => 'ดาวน์โหลดแล้ว';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsTr extends AppLocalizations {
|
|||
@override
|
||||
String get filter => 'Filtre';
|
||||
|
||||
@override
|
||||
String get ignore_filters => 'Filtreleri yok say';
|
||||
|
||||
@override
|
||||
String get downloaded => 'İndirildi';
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||
@override
|
||||
String get filter => '筛选';
|
||||
|
||||
@override
|
||||
String get ignore_filters => '忽略\n筛选';
|
||||
|
||||
@override
|
||||
String get downloaded => '已下载';
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import 'package:mangayomi/l10n/generated/app_localizations.dart';
|
|||
import 'package:mangayomi/src/rust/frb_generated.dart';
|
||||
import 'package:mangayomi/utils/url_protocol/api.dart';
|
||||
import 'package:mangayomi/modules/more/settings/appearance/providers/theme_provider.dart';
|
||||
import 'package:mangayomi/modules/library/providers/file_scanner.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
|
@ -54,10 +55,10 @@ void main(List<String> args) async {
|
|||
isar = await StorageProvider().initDB(null, inspector: kDebugMode);
|
||||
|
||||
runApp(const ProviderScope(child: MyApp()));
|
||||
unawaited(postLaunchInit()); // Defer non-essential async operations
|
||||
unawaited(_postLaunchInit()); // Defer non-essential async operations
|
||||
}
|
||||
|
||||
Future<void> postLaunchInit() async {
|
||||
Future<void> _postLaunchInit() async {
|
||||
await StorageProvider().requestPermission();
|
||||
await StorageProvider().deleteBtDirectory();
|
||||
}
|
||||
|
|
@ -79,6 +80,7 @@ class _MyAppState extends ConsumerState<MyApp> {
|
|||
super.initState();
|
||||
initializeDateFormatting();
|
||||
_initDeepLinks();
|
||||
unawaited(ref.read(scanLocalLibraryProvider.future));
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (ref.read(clearChapterCacheOnAppLaunchStateProvider)) {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ enum TrackStatus {
|
|||
onHold,
|
||||
dropped,
|
||||
planToRead,
|
||||
rereading,
|
||||
reReading,
|
||||
watching,
|
||||
planToWatch,
|
||||
reWatching,
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ const _TrackstatusEnumValueMap = {
|
|||
'onHold': 2,
|
||||
'dropped': 3,
|
||||
'planToRead': 4,
|
||||
'rereading': 5,
|
||||
'reReading': 5,
|
||||
'watching': 6,
|
||||
'planToWatch': 7,
|
||||
'reWatching': 8,
|
||||
|
|
@ -244,7 +244,7 @@ const _TrackstatusValueEnumMap = {
|
|||
2: TrackStatus.onHold,
|
||||
3: TrackStatus.dropped,
|
||||
4: TrackStatus.planToRead,
|
||||
5: TrackStatus.rereading,
|
||||
5: TrackStatus.reReading,
|
||||
6: TrackStatus.watching,
|
||||
7: TrackStatus.planToWatch,
|
||||
8: TrackStatus.reWatching,
|
||||
|
|
|
|||
|
|
@ -1013,6 +1013,21 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||
);
|
||||
}
|
||||
|
||||
bool matchesSearchQuery(Manga manga, String query) {
|
||||
final keywords = query
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.where((k) => k.isNotEmpty);
|
||||
|
||||
return keywords.any(
|
||||
(keyword) =>
|
||||
(manga.name?.toLowerCase().contains(keyword) ?? false) ||
|
||||
(manga.source?.toLowerCase().contains(keyword) ?? false) ||
|
||||
(manga.genre?.any((g) => g.toLowerCase().contains(keyword)) ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
List<Manga> _filterAndSortManga({
|
||||
required List<Manga> data,
|
||||
required int downloadFilterType,
|
||||
|
|
@ -1022,133 +1037,126 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||
required int sortType,
|
||||
}) {
|
||||
List<Manga>? mangas;
|
||||
mangas =
|
||||
data
|
||||
.where((element) {
|
||||
List list = [];
|
||||
if (downloadFilterType == 1) {
|
||||
for (var chap in element.chapters) {
|
||||
final modelChapDownload =
|
||||
isar.downloads.filter().idEqualTo(chap.id).findAllSync();
|
||||
|
||||
if (modelChapDownload.isNotEmpty &&
|
||||
modelChapDownload.first.isDownload == true) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.isNotEmpty;
|
||||
} else if (downloadFilterType == 2) {
|
||||
for (var chap in element.chapters) {
|
||||
final modelChapDownload =
|
||||
isar.downloads.filter().idEqualTo(chap.id).findAllSync();
|
||||
if (!(modelChapDownload.isNotEmpty &&
|
||||
modelChapDownload.first.isDownload == true)) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.length == element.chapters.length;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.where((element) {
|
||||
List list = [];
|
||||
if (unreadFilterType == 1 || startedFilterType == 1) {
|
||||
for (var chap in element.chapters) {
|
||||
if (!chap.isRead!) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.isNotEmpty;
|
||||
} else if (unreadFilterType == 2 || startedFilterType == 2) {
|
||||
final searchQuery = _textEditingController.text;
|
||||
// Skip all filters, just do search
|
||||
if (searchQuery.isNotEmpty && _ignoreFiltersOnSearch) {
|
||||
mangas =
|
||||
data
|
||||
.where((element) => matchesSearchQuery(element, searchQuery))
|
||||
.toList();
|
||||
} else {
|
||||
// Apply filters + search
|
||||
mangas =
|
||||
data
|
||||
.where((element) {
|
||||
// Filter by download
|
||||
List list = [];
|
||||
for (var chap in element.chapters) {
|
||||
if (chap.isRead!) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.length == element.chapters.length;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.where((element) {
|
||||
List list = [];
|
||||
if (bookmarkedFilterType == 1) {
|
||||
for (var chap in element.chapters) {
|
||||
if (chap.isBookmarked!) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.isNotEmpty;
|
||||
} else if (bookmarkedFilterType == 2) {
|
||||
List list = [];
|
||||
for (var chap in element.chapters) {
|
||||
if (!chap.isBookmarked!) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.length == element.chapters.length;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.where(
|
||||
(element) =>
|
||||
_textEditingController.text.isNotEmpty
|
||||
? _textEditingController.text
|
||||
.split(",")
|
||||
.any(
|
||||
(keyword) =>
|
||||
element.name!.toLowerCase().contains(
|
||||
_textEditingController.text.toLowerCase(),
|
||||
) ||
|
||||
(element.source != null &&
|
||||
element.source!.toLowerCase().contains(
|
||||
_textEditingController.text.toLowerCase(),
|
||||
)) ||
|
||||
element.genre!.contains(keyword),
|
||||
)
|
||||
: true,
|
||||
)
|
||||
.toList();
|
||||
if (downloadFilterType == 1) {
|
||||
for (var chap in element.chapters) {
|
||||
final modelChapDownload =
|
||||
isar.downloads
|
||||
.filter()
|
||||
.idEqualTo(chap.id)
|
||||
.findAllSync();
|
||||
|
||||
if (sortType == 0) {
|
||||
mangas.sort((a, b) {
|
||||
return a.name!.compareTo(b.name!);
|
||||
});
|
||||
} else if (sortType == 1) {
|
||||
mangas.sort((a, b) {
|
||||
return a.lastRead!.compareTo(b.lastRead!);
|
||||
});
|
||||
} else if (sortType == 2) {
|
||||
mangas.sort((a, b) {
|
||||
return a.lastUpdate?.compareTo(b.lastUpdate ?? 0) ?? 0;
|
||||
});
|
||||
} else if (sortType == 3) {
|
||||
mangas.sort((a, b) {
|
||||
return a.chapters
|
||||
.where((element) => !element.isRead!)
|
||||
.toList()
|
||||
.length
|
||||
.compareTo(
|
||||
b.chapters.where((element) => !element.isRead!).toList().length,
|
||||
);
|
||||
});
|
||||
} else if (sortType == 4) {
|
||||
mangas.sort((a, b) {
|
||||
return a.chapters.length.compareTo(b.chapters.length);
|
||||
});
|
||||
} else if (sortType == 5) {
|
||||
mangas.sort((a, b) {
|
||||
final aChaps = a.chapters;
|
||||
final bChaps = b.chapters;
|
||||
return (aChaps.lastOrNull?.dateUpload ?? "").compareTo(
|
||||
bChaps.lastOrNull?.dateUpload ?? "",
|
||||
);
|
||||
});
|
||||
} else if (sortType == 6) {
|
||||
mangas.sort((a, b) {
|
||||
return a.dateAdded?.compareTo(b.dateAdded ?? 0) ?? 0;
|
||||
});
|
||||
if (modelChapDownload.isNotEmpty &&
|
||||
modelChapDownload.first.isDownload == true) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.isNotEmpty;
|
||||
} else if (downloadFilterType == 2) {
|
||||
for (var chap in element.chapters) {
|
||||
final modelChapDownload =
|
||||
isar.downloads
|
||||
.filter()
|
||||
.idEqualTo(chap.id)
|
||||
.findAllSync();
|
||||
if (!(modelChapDownload.isNotEmpty &&
|
||||
modelChapDownload.first.isDownload == true)) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.length == element.chapters.length;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.where((element) {
|
||||
// Filter by unread or started
|
||||
List list = [];
|
||||
if (unreadFilterType == 1 || startedFilterType == 1) {
|
||||
for (var chap in element.chapters) {
|
||||
if (!chap.isRead!) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.isNotEmpty;
|
||||
} else if (unreadFilterType == 2 || startedFilterType == 2) {
|
||||
List list = [];
|
||||
for (var chap in element.chapters) {
|
||||
if (chap.isRead!) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.length == element.chapters.length;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.where((element) {
|
||||
// Filter by bookmarked
|
||||
List list = [];
|
||||
if (bookmarkedFilterType == 1) {
|
||||
for (var chap in element.chapters) {
|
||||
if (chap.isBookmarked!) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.isNotEmpty;
|
||||
} else if (bookmarkedFilterType == 2) {
|
||||
List list = [];
|
||||
for (var chap in element.chapters) {
|
||||
if (!chap.isBookmarked!) {
|
||||
list.add(true);
|
||||
}
|
||||
}
|
||||
return list.length == element.chapters.length;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.where(
|
||||
(element) =>
|
||||
searchQuery.isNotEmpty
|
||||
? matchesSearchQuery(element, searchQuery)
|
||||
: true,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
// Sorting the data based on selected sort type
|
||||
mangas.sort((a, b) {
|
||||
switch (sortType) {
|
||||
case 0:
|
||||
return a.name!.compareTo(b.name!);
|
||||
case 1:
|
||||
return a.lastRead!.compareTo(b.lastRead!);
|
||||
case 2:
|
||||
return a.lastUpdate?.compareTo(b.lastUpdate ?? 0) ?? 0;
|
||||
case 3:
|
||||
return a.chapters
|
||||
.where((e) => !e.isRead!)
|
||||
.length
|
||||
.compareTo(b.chapters.where((e) => !e.isRead!).length);
|
||||
case 4:
|
||||
return a.chapters.length.compareTo(b.chapters.length);
|
||||
case 5:
|
||||
return (a.chapters.lastOrNull?.dateUpload ?? "").compareTo(
|
||||
b.chapters.lastOrNull?.dateUpload ?? "",
|
||||
);
|
||||
case 6:
|
||||
return a.dateAdded?.compareTo(b.dateAdded ?? 0) ?? 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return mangas;
|
||||
}
|
||||
|
||||
|
|
@ -1188,7 +1196,9 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||
);
|
||||
|
||||
final entries =
|
||||
data.where((e) => !(e.hide ?? false)).toList();
|
||||
data
|
||||
.where((e) => !(e.hide ?? false))
|
||||
.toList();
|
||||
if (entries.isEmpty) {
|
||||
return Text(l10n.library_no_category_exist);
|
||||
}
|
||||
|
|
@ -1462,7 +1472,10 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||
final mangaDir = await storageProvider
|
||||
.getMangaMainDirectory(chapter);
|
||||
final path = await storageProvider
|
||||
.getMangaChapterDirectory(chapter);
|
||||
.getMangaChapterDirectory(
|
||||
chapter,
|
||||
mangaMainDirectory: mangaDir,
|
||||
);
|
||||
|
||||
try {
|
||||
try {
|
||||
|
|
@ -2043,6 +2056,8 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||
return l10n.date_added;
|
||||
}
|
||||
|
||||
bool _ignoreFiltersOnSearch = false;
|
||||
final bool _isMobile = Platform.isIOS || Platform.isAndroid;
|
||||
PreferredSize _appBar(
|
||||
bool isNotFiltering,
|
||||
bool showNumbersOfItems,
|
||||
|
|
@ -2202,6 +2217,31 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|||
},
|
||||
icon: const Icon(Icons.search),
|
||||
),
|
||||
// Checkbox when searching library to ignore filters
|
||||
if (_isSearch)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_isMobile
|
||||
// Adds a line break where spaces exist for better mobile layout.
|
||||
// Works for languages that use spaces between words.
|
||||
? l10n.ignore_filters.replaceFirst(' ', '\n')
|
||||
// Removes manually added line breaks for Thai and Chinese,
|
||||
// where spaces aren’t used, to ensure proper desktop rendering.
|
||||
: l10n.ignore_filters.replaceAll('\n', ''),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Checkbox(
|
||||
value: _ignoreFiltersOnSearch,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_ignoreFiltersOnSearch = val ?? false;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
splashRadius: 20,
|
||||
onPressed: () {
|
||||
|
|
|
|||
340
lib/modules/library/providers/file_scanner.dart
Normal file
340
lib/modules/library/providers/file_scanner.dart
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import 'dart:io'; // For I/O-operations
|
||||
import 'package:isar/isar.dart'; // Isar database package for local storage
|
||||
import 'package:mangayomi/main.dart'; // Exposes the global `isar` instance
|
||||
import 'package:path/path.dart' as p; // For manipulating file system paths
|
||||
import 'package:bot_toast/bot_toast.dart'; // For Exceptions
|
||||
import 'package:mangayomi/models/manga.dart'; // Has Manga model and ItemType enum
|
||||
import 'package:mangayomi/models/chapter.dart'; // Has Chapter model with archivePath
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart'; // Riverpod state management
|
||||
import 'package:mangayomi/providers/storage_provider.dart'; // Provides storage directory selection
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart'; // Annotations for code generation
|
||||
part 'file_scanner.g.dart';
|
||||
|
||||
/// Scans `Mangayomi/local` folder (if exists) for Mangas/Animes and imports in library.
|
||||
///
|
||||
/// **Folder structure:**
|
||||
/// ```
|
||||
/// Mangayomi/local/MangaName/CustomCover.jpg (optional)
|
||||
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
|
||||
/// Mangayomi/local/MangaName/Chapter2.cbz
|
||||
/// Mangayomi/local/AnimeName/Episode1.mp4
|
||||
/// ```
|
||||
/// **Supported filetypes:** (taken from lib/modules/library/providers/local_archive.dart, line 98)
|
||||
/// ```
|
||||
/// Videotypes: mp4, mov, avi, flv, wmv, mpeg, mkv
|
||||
/// Imagetypes: jpg, jpeg, png, webp
|
||||
/// Archivetypes: cbz, zip, cbt, tar
|
||||
/// ```
|
||||
@riverpod
|
||||
Future<void> scanLocalLibrary(Ref ref) async {
|
||||
// Get /local directory
|
||||
final localDir = await _getLocalLibrary();
|
||||
// Don't do anything if /local doesn't exist
|
||||
if (localDir == null || !await localDir.exists()) return;
|
||||
|
||||
final dateNow = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Fetch all existing mangas in library that are in /local (or \local)
|
||||
final List<Manga> existingMangas =
|
||||
await isar.mangas
|
||||
.filter()
|
||||
.linkContains("Mangayomi/local")
|
||||
.or()
|
||||
.linkContains("Mangayomi\\local")
|
||||
.findAll();
|
||||
final mangaMap = {for (var m in existingMangas) _getRelativePath(m.link!): m};
|
||||
|
||||
// Fetch all chapters for existing mangas
|
||||
final existingMangaIds = existingMangas.map((m) => m.id);
|
||||
final existingChapters =
|
||||
await isar.chapters
|
||||
.filter()
|
||||
.anyOf(existingMangaIds, (q, id) => q.mangaIdEqualTo(id))
|
||||
.findAll();
|
||||
|
||||
// Map where the key is manga ID and the value is a set of chapter paths.
|
||||
final chaptersMap = <int, Set<String>>{};
|
||||
|
||||
// Add manga.Ids with all the corresponding relative! paths (Manga/Chapter)
|
||||
for (var chap in existingChapters) {
|
||||
String path = _getRelativePath(chap.archivePath!);
|
||||
// For the given manga ID, add the path to its associated set.
|
||||
// If there's no entry for the manga ID yet, create a new empty set.
|
||||
chaptersMap.putIfAbsent(chap.mangaId!, () => <String>{}).add(path);
|
||||
}
|
||||
|
||||
// Collect all chapter paths chaptersMap into a single set for easy lookup.
|
||||
final existingPaths = chaptersMap.values.expand((s) => s).toSet();
|
||||
List<Manga> processedMangas = <Manga>[];
|
||||
final List<List<dynamic>> newChapters = [];
|
||||
|
||||
// If newMangas > 0, save all collected Mangas in library first to get a Manga ID
|
||||
int newMangas = 0;
|
||||
|
||||
/// helper function to add chapters to newChapters list
|
||||
void addNewChapters(List<FileSystemEntity> items, bool imageFolder) {
|
||||
for (final chapter in items) {
|
||||
final relPath = _getRelativePath(chapter.path).trim();
|
||||
// Skip if the relative path is empty (invalid entry).
|
||||
if (relPath.isEmpty) continue;
|
||||
|
||||
if (!existingPaths.contains(relPath)) {
|
||||
newChapters.add([chapter.path, imageFolder]);
|
||||
existingPaths.add(relPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over each sub-directory (each representing a title, Manga or Anime)
|
||||
await for (final folder in localDir.list()) {
|
||||
if (folder is! Directory) continue;
|
||||
final title = p.basename(folder.path); // Anime/Manga title
|
||||
String relativePath = _getRelativePath(folder.path);
|
||||
|
||||
// List all folders and files inside a Manga/Anime title
|
||||
final children = await folder.list().toList();
|
||||
final subDirs = children.whereType<Directory>().toList();
|
||||
final files = children.whereType<File>().toList();
|
||||
|
||||
// Determine itemtype
|
||||
final hasImagesFolders = subDirs.isNotEmpty;
|
||||
final hasArchives = files.any((f) => _isArchive(f.path));
|
||||
final hasVideos = files.any((f) => _isVideo(f.path));
|
||||
late ItemType itemType;
|
||||
if (hasImagesFolders || hasArchives) {
|
||||
itemType = ItemType.manga;
|
||||
} else if (hasVideos) {
|
||||
itemType = ItemType.anime;
|
||||
} else {
|
||||
continue; // nothing to import from this folder
|
||||
}
|
||||
// Does Manga/Anime already exist in library?
|
||||
bool existingManga = mangaMap.containsKey(relativePath);
|
||||
|
||||
// Create new Manga entry if it doesn't already exist
|
||||
Manga manga;
|
||||
if (existingManga) {
|
||||
manga = mangaMap[relativePath]!;
|
||||
} else {
|
||||
manga = Manga(
|
||||
favorite: true,
|
||||
source: 'local',
|
||||
author: '',
|
||||
artist: '',
|
||||
genre: [],
|
||||
imageUrl: '',
|
||||
lang: '',
|
||||
link: folder.path,
|
||||
name: title,
|
||||
status: Status.unknown,
|
||||
description: '',
|
||||
isLocalArchive: true,
|
||||
itemType: itemType,
|
||||
dateAdded: dateNow,
|
||||
lastUpdate: dateNow,
|
||||
);
|
||||
newMangas++;
|
||||
}
|
||||
|
||||
// Detect a single image in item's root and use it as custom cover
|
||||
final imageFiles = files.where((f) => _isImage(f.path)).toList();
|
||||
if (imageFiles.length == 1) {
|
||||
try {
|
||||
final bytes = await File(imageFiles.first.path).readAsBytes();
|
||||
final byteList = bytes.toList();
|
||||
if (manga.customCoverImage != byteList) {
|
||||
manga.customCoverImage = byteList;
|
||||
manga.lastUpdate = dateNow;
|
||||
}
|
||||
} catch (e) {
|
||||
BotToast.showText(text: "Error reading cover image: $e");
|
||||
}
|
||||
} else if (imageFiles.isEmpty && manga.customCoverImage != null) {
|
||||
manga.customCoverImage = null;
|
||||
}
|
||||
processedMangas.add(manga);
|
||||
|
||||
// Scan chapters/episodes
|
||||
if (hasImagesFolders) {
|
||||
// Each subdirectory is a chapter
|
||||
addNewChapters(subDirs, hasImagesFolders);
|
||||
} // Possible that image folders and archives are mixed in one manga
|
||||
if (hasArchives) {
|
||||
// Each .cbz/.zip file is a chapter
|
||||
final archives = files.where((f) => _isArchive(f.path)).toList();
|
||||
addNewChapters(archives, false);
|
||||
}
|
||||
if (hasVideos) {
|
||||
// Each .mp4 is an episode
|
||||
final videos = files.where((f) => _isVideo(f.path)).toList();
|
||||
addNewChapters(videos, false);
|
||||
}
|
||||
}
|
||||
|
||||
final changedMangas = <Manga>[];
|
||||
for (var manga in processedMangas) {
|
||||
if (manga.lastUpdate == dateNow) {
|
||||
// Filter out items that haven't been changed
|
||||
changedMangas.add(manga);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Save all new and changed items to the library
|
||||
await isar.writeTxn(() async => await isar.mangas.putAll(changedMangas));
|
||||
} catch (e) {
|
||||
BotToast.showText(
|
||||
text: "Database write error. Manga/Anime couldn't be saved: $e",
|
||||
);
|
||||
}
|
||||
|
||||
// If new Mangas have been added (no Id to save Chapters)
|
||||
if (newMangas > 0) {
|
||||
// Copy processedMangas
|
||||
List<Manga> newAddedMangas = processedMangas;
|
||||
// Fetch all existing mangas in library that are in /local (or \local)
|
||||
final savedMangas =
|
||||
await isar.mangas
|
||||
.filter()
|
||||
.linkContains("Mangayomi/local")
|
||||
.or()
|
||||
.linkContains("Mangayomi\\local")
|
||||
.findAll();
|
||||
// Save all retrieved Manga objects (now with id) matching the processedMangas list
|
||||
newAddedMangas =
|
||||
savedMangas
|
||||
.where(
|
||||
(m) => processedMangas.any(
|
||||
(newManga) =>
|
||||
_getRelativePath(newManga.link) == _getRelativePath(m.link),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
processedMangas.clear();
|
||||
processedMangas = newAddedMangas;
|
||||
}
|
||||
|
||||
final chaptersToSave = <Chapter>[];
|
||||
int saveManga = 0; // Just to update the lastUpdate value of not new Mangas
|
||||
final mangaByName = {for (var m in processedMangas) p.basename(m.link!): m};
|
||||
|
||||
// iterate through newChapters elements, which are: ["full_path/to/chapter1", "true"]
|
||||
for (var pathBool in newChapters) {
|
||||
final chapterPath = pathBool[0];
|
||||
// pathBool[0] = first element of list (path)
|
||||
// dirname = remove last part of path (chapter name), = "full_path/to"
|
||||
// basename = remove everything except last (manga name) = "to"
|
||||
final itemName = p.basename(p.dirname(chapterPath));
|
||||
final manga = mangaByName[itemName];
|
||||
if (manga != null) {
|
||||
final chap = Chapter(
|
||||
mangaId: manga.id,
|
||||
name:
|
||||
pathBool[1] // If Chapter is an image folder or archive/video
|
||||
? p.basename(chapterPath)
|
||||
: p.basenameWithoutExtension(chapterPath),
|
||||
dateUpload: dateNow.toString(),
|
||||
archivePath: chapterPath,
|
||||
);
|
||||
if (manga.lastUpdate != dateNow) {
|
||||
manga.lastUpdate = dateNow;
|
||||
saveManga++;
|
||||
}
|
||||
chaptersToSave.add(chap);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (saveManga > 0) {
|
||||
// Just to update the lastUpdate value of not new Mangas
|
||||
await isar.writeTxn(
|
||||
() async => await isar.mangas.putAll(processedMangas),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
BotToast.showText(text: "Error saving chapter/episode to library: $e");
|
||||
}
|
||||
try {
|
||||
if (chaptersToSave.isNotEmpty) {
|
||||
await isar.writeTxn(() async {
|
||||
// insert chapters
|
||||
await isar.chapters.putAll(chaptersToSave);
|
||||
|
||||
// for each one, set its link and save it
|
||||
for (final chap in chaptersToSave) {
|
||||
chap.manga.value = processedMangas.firstWhere(
|
||||
(m) => m.id == chap.mangaId,
|
||||
);
|
||||
await chap.manga.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
BotToast.showText(
|
||||
text: "Database write error. Manga/Anime couldn't be saved: $e",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `/local` directory inside the app's default storage.
|
||||
Future<Directory?> _getLocalLibrary() async {
|
||||
try {
|
||||
final dir = await StorageProvider().getDefaultDirectory();
|
||||
return dir == null ? null : Directory(p.join(dir.path, 'local'));
|
||||
} catch (e) {
|
||||
BotToast.showText(text: "Error getting local library: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the String 'Mangayomi/local' and extract path after
|
||||
/// ```
|
||||
/// "C:\Users\user\Documents\Mangayomi\local\Manga 1\chapter1.zip"
|
||||
/// becomes:
|
||||
/// "Manga 1/chapter1.zip"
|
||||
/// ```
|
||||
String _getRelativePath(dir) {
|
||||
String relativePath;
|
||||
|
||||
if (dir is Directory) {
|
||||
relativePath = dir.path;
|
||||
} else if (dir is String) {
|
||||
relativePath = dir;
|
||||
} else {
|
||||
throw ArgumentError("Input must be a Directory or a String");
|
||||
}
|
||||
|
||||
// Normalize path separators
|
||||
relativePath = relativePath.replaceAll("\\", "/");
|
||||
int index = relativePath.indexOf("Mangayomi/local");
|
||||
if (index != -1) {
|
||||
return relativePath.substring(index + "Mangayomi/local/".length);
|
||||
} else {
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns if file is an image
|
||||
bool _isImage(String path) {
|
||||
final ext = p.extension(path).toLowerCase();
|
||||
return ext == '.jpg' || ext == '.jpeg' || ext == '.png' || ext == '.webp';
|
||||
}
|
||||
|
||||
/// Returns if file is an archive
|
||||
bool _isArchive(String path) {
|
||||
final ext = p.extension(path).toLowerCase();
|
||||
return ext == '.cbz' || ext == '.zip' || ext == '.cbt' || ext == '.tar';
|
||||
}
|
||||
|
||||
/// Returns if file is a video
|
||||
bool _isVideo(String path) {
|
||||
final ext = p.extension(path).toLowerCase();
|
||||
const videoExtensions = {
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.wmv',
|
||||
'.mpeg',
|
||||
'.mkv',
|
||||
};
|
||||
return videoExtensions.contains(ext);
|
||||
}
|
||||
44
lib/modules/library/providers/file_scanner.g.dart
Normal file
44
lib/modules/library/providers/file_scanner.g.dart
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'file_scanner.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$scanLocalLibraryHash() => r'8a80e2582c4abda500034c9e139cdb8be58942e7';
|
||||
|
||||
/// Scans `Mangayomi/local` folder (if exists) for Mangas/Animes and imports in library.
|
||||
///
|
||||
/// **Folder structure:**
|
||||
/// ```
|
||||
/// Mangayomi/local/MangaName/CustomCover.jpg (optional)
|
||||
/// Mangayomi/local/MangaName/Chapter1/Page1.jpg
|
||||
/// Mangayomi/local/MangaName/Chapter2.cbz
|
||||
/// Mangayomi/local/AnimeName/Episode1.mp4
|
||||
/// ```
|
||||
/// **Supported filetypes:** (taken from lib/modules/library/providers/local_archive.dart, line 98)
|
||||
/// ```
|
||||
/// Videotypes: mp4, mov, avi, flv, wmv, mpeg, mkv
|
||||
/// Imagetypes: jpg, jpeg, png, webp
|
||||
/// Archivetypes: cbz, zip, cbt, tar
|
||||
/// ```
|
||||
///
|
||||
/// Copied from [scanLocalLibrary].
|
||||
@ProviderFor(scanLocalLibrary)
|
||||
final scanLocalLibraryProvider = AutoDisposeFutureProvider<void>.internal(
|
||||
scanLocalLibrary,
|
||||
name: r'scanLocalLibraryProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$scanLocalLibraryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef ScanLocalLibraryRef = AutoDisposeFutureProviderRef<void>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
|
|
@ -12,7 +12,7 @@ class LocalArchive {
|
|||
String? path;
|
||||
}
|
||||
|
||||
enum LocalExtensionType { cbz, zip, cbt, tar }
|
||||
enum LocalExtensionType { cbz, zip, cbt, tar, folder }
|
||||
|
||||
class LocalImage {
|
||||
String? name;
|
||||
|
|
|
|||
|
|
@ -102,6 +102,29 @@ bool _isArchiveFile(String path) {
|
|||
}
|
||||
|
||||
LocalArchive _extractArchive(String path) {
|
||||
// Folder of images?
|
||||
if (Directory(path).existsSync()) {
|
||||
final dir = Directory(path);
|
||||
final pages =
|
||||
dir.listSync().whereType<File>().where((f) => _isImageFile(f.path)).map(
|
||||
(f) {
|
||||
return LocalImage()
|
||||
..image = f.readAsBytesSync()
|
||||
..name = p.basename(f.path);
|
||||
},
|
||||
).toList()
|
||||
..sort((a, b) => a.name!.compareTo(b.name!));
|
||||
|
||||
final localArchive =
|
||||
LocalArchive()
|
||||
..path = path
|
||||
..extensionType = LocalExtensionType.folder
|
||||
..name = p.basename(path)
|
||||
..images = pages
|
||||
..coverImage = pages.first.image;
|
||||
|
||||
return localArchive;
|
||||
}
|
||||
final localArchive =
|
||||
LocalArchive()
|
||||
..path = path
|
||||
|
|
@ -144,6 +167,19 @@ LocalArchive _extractArchive(String path) {
|
|||
(String, LocalExtensionType, Uint8List, String) _extractArchiveOnly(
|
||||
String path,
|
||||
) {
|
||||
// If it's a directory, just read its images:
|
||||
if (Directory(path).existsSync()) {
|
||||
final dir = Directory(path);
|
||||
final images =
|
||||
dir
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.where((f) => _isImageFile(f.path))
|
||||
.toList()
|
||||
..sort((a, b) => a.path.compareTo(b.path));
|
||||
final cover = images.first.readAsBytesSync();
|
||||
return (p.basename(path), LocalExtensionType.folder, cover, path);
|
||||
}
|
||||
final extensionType = setTypeExtension(
|
||||
p.extension(path).replaceFirst('.', ''),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,115 +15,69 @@ class TrackState extends _$TrackState {
|
|||
return track!;
|
||||
}
|
||||
|
||||
Future updateManga() async {
|
||||
Track? updateTrack;
|
||||
updateTrack = await switch (track!.syncId) {
|
||||
1 => switch (itemType) {
|
||||
ItemType.manga => ref
|
||||
.read(
|
||||
myAnimeListProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.updateManga(track!),
|
||||
_ => ref
|
||||
.read(
|
||||
myAnimeListProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.updateAnime(track!),
|
||||
},
|
||||
2 => switch (itemType) {
|
||||
ItemType.manga => ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.updateLibManga(track!),
|
||||
_ => ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.updateLibAnime(track!),
|
||||
},
|
||||
_ => ref
|
||||
.read(
|
||||
kitsuProvider(syncId: track!.syncId!, itemType: itemType).notifier,
|
||||
)
|
||||
.updateLib(track!, _isManga),
|
||||
dynamic getNotifier(int syncId) {
|
||||
return switch (syncId) {
|
||||
1 => ref.read(
|
||||
myAnimeListProvider(syncId: syncId, itemType: itemType).notifier,
|
||||
),
|
||||
2 => ref.read(
|
||||
anilistProvider(syncId: syncId, itemType: itemType).notifier,
|
||||
),
|
||||
3 => ref.read(kitsuProvider(syncId: syncId, itemType: itemType).notifier),
|
||||
_ => throw Exception('Unsupported syncId: $syncId'),
|
||||
};
|
||||
}
|
||||
|
||||
void writeBack(Track t) {
|
||||
ref
|
||||
.read(tracksProvider(syncId: track!.syncId!).notifier)
|
||||
.updateTrackManga(updateTrack, itemType!);
|
||||
.read(tracksProvider(syncId: t.syncId!).notifier)
|
||||
.updateTrackManga(t, itemType!);
|
||||
}
|
||||
|
||||
Future updateManga() async {
|
||||
final syncId = track!.syncId!;
|
||||
Track updateTrack = await getNotifier(syncId).update(track!, _isManga);
|
||||
writeBack(updateTrack);
|
||||
}
|
||||
|
||||
int getScoreMaxValue() {
|
||||
int? maxValue;
|
||||
if (track!.syncId == 1 || track!.syncId == 3) {
|
||||
maxValue = 10;
|
||||
} else if (track!.syncId == 2) {
|
||||
maxValue =
|
||||
ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.getScoreValue()
|
||||
.$1;
|
||||
final syncId = track!.syncId!;
|
||||
if (syncId == 2) {
|
||||
final tracker = getNotifier(syncId);
|
||||
return tracker.getScoreValue().$1;
|
||||
} else {
|
||||
return 10;
|
||||
}
|
||||
return maxValue!;
|
||||
}
|
||||
|
||||
String getTextMapper(String numberText) {
|
||||
if (track!.syncId == 1 || track!.syncId == 3) {
|
||||
} else if (track!.syncId == 2) {
|
||||
numberText = ref
|
||||
.read(anilistProvider(syncId: 2, itemType: itemType).notifier)
|
||||
.displayScore(int.parse(numberText));
|
||||
final syncId = track!.syncId!;
|
||||
if (syncId == 2) {
|
||||
final tracker = getNotifier(syncId);
|
||||
return tracker.displayScore(int.parse(numberText));
|
||||
} else {
|
||||
return numberText;
|
||||
}
|
||||
return numberText;
|
||||
}
|
||||
|
||||
int getScoreStep() {
|
||||
int? step;
|
||||
if (track!.syncId == 1 || track!.syncId == 3) {
|
||||
step = 1;
|
||||
} else if (track!.syncId == 2) {
|
||||
step =
|
||||
ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.getScoreValue()
|
||||
.$2;
|
||||
final syncId = track!.syncId!;
|
||||
if (syncId == 2) {
|
||||
final tracker = getNotifier(syncId);
|
||||
return tracker.getScoreValue().$2;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
return step!;
|
||||
}
|
||||
|
||||
String displayScore(int score) {
|
||||
String? result;
|
||||
if (track!.syncId == 1 || track!.syncId == 3) {
|
||||
result = score.toString();
|
||||
} else if (track!.syncId == 2) {
|
||||
result = ref
|
||||
.read(anilistProvider(syncId: 2, itemType: itemType).notifier)
|
||||
.displayScore(score);
|
||||
final syncId = track!.syncId!;
|
||||
if (syncId == 2) {
|
||||
final tracker = getNotifier(syncId);
|
||||
return tracker.displayScore(score);
|
||||
} else {
|
||||
return score.toString();
|
||||
}
|
||||
return result!;
|
||||
}
|
||||
|
||||
bool get _isManga => itemType == ItemType.manga;
|
||||
|
|
@ -134,7 +88,7 @@ class TrackState extends _$TrackState {
|
|||
int syncId,
|
||||
) async {
|
||||
Track? findManga;
|
||||
final track = Track(
|
||||
final newTrack = Track(
|
||||
mangaId: mangaId,
|
||||
score: 0,
|
||||
syncId: syncId,
|
||||
|
|
@ -143,113 +97,28 @@ class TrackState extends _$TrackState {
|
|||
title: trackSearch.title,
|
||||
lastChapterRead: 0,
|
||||
totalChapter: trackSearch.totalChapter,
|
||||
status: TrackStatus.planToRead,
|
||||
status: _isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
|
||||
startedReadingDate: 0,
|
||||
finishedReadingDate: 0,
|
||||
);
|
||||
final tracker = getNotifier(syncId);
|
||||
|
||||
if (syncId == 1) {
|
||||
findManga = await ref
|
||||
.read(
|
||||
myAnimeListProvider(syncId: syncId, itemType: itemType).notifier,
|
||||
)
|
||||
.findManga(track);
|
||||
findManga = await tracker.findLibItem(newTrack, _isManga);
|
||||
} else if (syncId == 2) {
|
||||
findManga =
|
||||
_isManga
|
||||
? await ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: syncId,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.findLibManga(track)
|
||||
: await ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: syncId,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.findLibAnime(track);
|
||||
findManga ??=
|
||||
_isManga
|
||||
? await ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: syncId,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.addLibManga(track)
|
||||
: await ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: syncId,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.addLibAnime(track);
|
||||
findManga = await tracker.findLibItem(newTrack, _isManga);
|
||||
findManga ??= await tracker.update(newTrack, _isManga);
|
||||
} else if (syncId == 3) {
|
||||
findManga = await ref
|
||||
.read(kitsuProvider(syncId: syncId, itemType: itemType).notifier)
|
||||
.addLib(track, _isManga);
|
||||
findManga = await tracker.update(newTrack, _isManga);
|
||||
}
|
||||
|
||||
ref
|
||||
.read(tracksProvider(syncId: syncId).notifier)
|
||||
.updateTrackManga(findManga!, itemType!);
|
||||
writeBack(findManga!);
|
||||
}
|
||||
|
||||
List<TrackStatus> getStatusList() {
|
||||
List<TrackStatus> statusList = [];
|
||||
List<TrackStatus> list = [];
|
||||
if (track!.syncId == 1) {
|
||||
statusList =
|
||||
_isManga
|
||||
? ref
|
||||
.read(
|
||||
myAnimeListProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.myAnimeListStatusListManga
|
||||
: ref
|
||||
.read(
|
||||
myAnimeListProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.myAnimeListStatusListAnime;
|
||||
} else if (track!.syncId == 2) {
|
||||
statusList =
|
||||
_isManga
|
||||
? ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.aniListStatusListManga
|
||||
: ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.aniListStatusListAnime;
|
||||
} else if (track!.syncId == 3) {
|
||||
statusList = ref
|
||||
.read(
|
||||
kitsuProvider(syncId: track!.syncId!, itemType: itemType).notifier,
|
||||
)
|
||||
.kitsuStatusList(_isManga);
|
||||
}
|
||||
final syncId = track!.syncId!;
|
||||
final tracker = getNotifier(syncId);
|
||||
List<TrackStatus> statusList = tracker.statusList(_isManga);
|
||||
for (var element in TrackStatus.values) {
|
||||
if (statusList.contains(element)) {
|
||||
list.add(element);
|
||||
|
|
@ -259,82 +128,14 @@ class TrackState extends _$TrackState {
|
|||
}
|
||||
|
||||
Future<Track?> findManga() async {
|
||||
Track? findManga;
|
||||
if (track!.syncId == 1) {
|
||||
findManga = await ref
|
||||
.read(
|
||||
myAnimeListProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.findManga(track!);
|
||||
} else if (track!.syncId == 2) {
|
||||
findManga =
|
||||
_isManga
|
||||
? await ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.findLibManga(track!)
|
||||
: await ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.findLibAnime(track!);
|
||||
} else if (track!.syncId == 3) {
|
||||
findManga = await ref
|
||||
.read(
|
||||
kitsuProvider(syncId: track!.syncId!, itemType: itemType).notifier,
|
||||
)
|
||||
.findLibItem(track!, _isManga);
|
||||
}
|
||||
return findManga;
|
||||
final syncId = track!.syncId!;
|
||||
final tracker = getNotifier(syncId);
|
||||
return await tracker.findLibItem(track!, _isManga);
|
||||
}
|
||||
|
||||
Future<List<TrackSearch>?> search(String query) async {
|
||||
List<TrackSearch>? tracks;
|
||||
if (track!.syncId == 1) {
|
||||
tracks = await ref
|
||||
.read(
|
||||
myAnimeListProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.search(query);
|
||||
} else if (track!.syncId == 2) {
|
||||
tracks =
|
||||
_isManga
|
||||
? await ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.search(query)
|
||||
: await ref
|
||||
.read(
|
||||
anilistProvider(
|
||||
syncId: track!.syncId!,
|
||||
itemType: itemType,
|
||||
).notifier,
|
||||
)
|
||||
.searchAnime(query);
|
||||
} else if (track!.syncId == 3) {
|
||||
tracks = await ref
|
||||
.read(
|
||||
kitsuProvider(syncId: track!.syncId!, itemType: itemType).notifier,
|
||||
)
|
||||
.search(query, _isManga);
|
||||
}
|
||||
return tracks;
|
||||
final syncId = track!.syncId!;
|
||||
final tracker = getNotifier(syncId);
|
||||
return await tracker.search(query, _isManga);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ class ChapterPageDownload extends ConsumerWidget {
|
|||
void _sendFile() async {
|
||||
final storageProvider = StorageProvider();
|
||||
final mangaDir = await storageProvider.getMangaMainDirectory(chapter);
|
||||
final path = await storageProvider.getMangaChapterDirectory(chapter);
|
||||
final path = await storageProvider.getMangaChapterDirectory(
|
||||
chapter,
|
||||
mangaMainDirectory: mangaDir,
|
||||
);
|
||||
|
||||
List<XFile> files = [];
|
||||
|
||||
|
|
@ -57,7 +60,10 @@ class ChapterPageDownload extends ConsumerWidget {
|
|||
void _deleteFile(int downloadId) async {
|
||||
final storageProvider = StorageProvider();
|
||||
final mangaDir = await storageProvider.getMangaMainDirectory(chapter);
|
||||
final path = await storageProvider.getMangaChapterDirectory(chapter);
|
||||
final path = await storageProvider.getMangaChapterDirectory(
|
||||
chapter,
|
||||
mangaMainDirectory: mangaDir,
|
||||
);
|
||||
|
||||
try {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ Future<void> downloadChapter(
|
|||
final chapterName = chapter.name!.replaceForbiddenCharacters(' ');
|
||||
final itemType = chapter.manga.value!.itemType;
|
||||
final chapterDirectory =
|
||||
(await storageProvider.getMangaChapterDirectory(chapter))!;
|
||||
(await storageProvider.getMangaChapterDirectory(
|
||||
chapter,
|
||||
mangaMainDirectory: mangaMainDirectory,
|
||||
))!;
|
||||
await Directory(chapterDirectory.path).create(recursive: true);
|
||||
Map<String, String> videoHeader = {};
|
||||
Map<String, String> htmlHeader = {
|
||||
|
|
@ -240,8 +243,8 @@ Future<void> downloadChapter(
|
|||
if (!cbzFileExist && itemType == ItemType.manga ||
|
||||
!mp4FileExist && itemType == ItemType.anime ||
|
||||
!htmlFileExist && itemType == ItemType.novel) {
|
||||
final mainDirectory = (await storageProvider.getDirectory())!;
|
||||
for (var index = 0; index < pageUrls.length; index++) {
|
||||
final mainDirectory = (await storageProvider.getDirectory())!;
|
||||
if (Platform.isAndroid) {
|
||||
if (!(await File(p.join(mainDirectory.path, ".nomedia")).exists())) {
|
||||
await File(p.join(mainDirectory.path, ".nomedia")).create();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:mangayomi/main.dart';
|
||||
import 'package:mangayomi/models/chapter.dart';
|
||||
import 'package:mangayomi/services/get_chapter_pages.dart';
|
||||
|
||||
class ChapterWithPages {
|
||||
final Chapter chapter;
|
||||
final GetChapterPagesModel pages;
|
||||
|
||||
ChapterWithPages({required this.chapter, required this.pages});
|
||||
}
|
||||
|
||||
class MangaReaderController extends FamilyAsyncNotifier<ChapterWithPages, int> {
|
||||
@override
|
||||
Future<ChapterWithPages> build(int chapterId) async {
|
||||
final chap = await isar.chapters.get(chapterId);
|
||||
if (chap == null) {
|
||||
throw Exception('Chapter #$chapterId not found');
|
||||
}
|
||||
|
||||
final pages = await ref.read(getChapterPagesProvider(chapter: chap).future);
|
||||
|
||||
return ChapterWithPages(chapter: chap, pages: pages);
|
||||
}
|
||||
}
|
||||
|
||||
final mangaReaderProvider =
|
||||
AsyncNotifierProvider.family<MangaReaderController, ChapterWithPages, int>(
|
||||
MangaReaderController.new,
|
||||
);
|
||||
|
|
@ -39,6 +39,7 @@ import 'package:mangayomi/modules/manga/reader/image_view_vertical.dart';
|
|||
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/widgets/circular_progress_indicator_animate_rotate.dart';
|
||||
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/providers/manga_reader_provider.dart';
|
||||
import 'package:mangayomi/modules/widgets/progress_center.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
|
|
@ -52,64 +53,62 @@ typedef DoubleClickAnimationListener = void Function();
|
|||
|
||||
class MangaReaderView extends ConsumerWidget {
|
||||
final int chapterId;
|
||||
MangaReaderView({super.key, required this.chapterId});
|
||||
late final Chapter chapter = isar.chapters.getSync(chapterId)!;
|
||||
const MangaReaderView({super.key, required this.chapterId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chapterData = ref.watch(getChapterPagesProvider(chapter: chapter));
|
||||
final chapterData = ref.watch(mangaReaderProvider(chapterId));
|
||||
|
||||
return chapterData.when(
|
||||
loading: () => scaffoldWith(context, const ProgressCenter()),
|
||||
error:
|
||||
(error, _) =>
|
||||
scaffoldWith(context, Center(child: Text(error.toString()))),
|
||||
data: (data) {
|
||||
if (data.pageUrls.isEmpty &&
|
||||
(chapter.manga.value!.isLocalArchive ?? false) == false) {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text(''),
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text("Error")),
|
||||
final chapter = data.chapter;
|
||||
final model = data.pages;
|
||||
|
||||
if (model.pageUrls.isEmpty &&
|
||||
!(chapter.manga.value?.isLocalArchive ?? false)) {
|
||||
return scaffoldWith(
|
||||
context,
|
||||
const Center(child: Text('Error: no pages available')),
|
||||
restoreUi: true,
|
||||
);
|
||||
}
|
||||
return MangaChapterPageGallery(chapter: chapter, chapterUrlModel: data);
|
||||
},
|
||||
error:
|
||||
(error, stackTrace) => Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text(''),
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: Center(child: Text(error.toString())),
|
||||
),
|
||||
loading: () {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text(''),
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: const ProgressCenter(),
|
||||
|
||||
return MangaChapterPageGallery(
|
||||
chapter: chapter,
|
||||
chapterUrlModel: model,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget scaffoldWith(
|
||||
BuildContext context,
|
||||
Widget body, {
|
||||
bool restoreUi = false,
|
||||
}) {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
appBar: AppBar(
|
||||
title: const Text(''),
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (restoreUi) {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MangaChapterPageGallery extends ConsumerStatefulWidget {
|
||||
|
|
|
|||
|
|
@ -27,17 +27,173 @@ final navigationItems = {
|
|||
"/more": "More",
|
||||
};
|
||||
|
||||
class SettingsSection extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const SettingsSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 13, color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppearanceScreen extends ConsumerWidget {
|
||||
const AppearanceScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = l10nLocalizations(context);
|
||||
final dateFormatState = ref.watch(dateFormatStateProvider);
|
||||
final relativeTimestamps = ref.watch(relativeTimesTampsStateProvider);
|
||||
final pureBlackDarkMode = ref.watch(pureBlackDarkModeStateProvider);
|
||||
final isDarkTheme = ref.watch(themeModeStateProvider);
|
||||
bool followSystemTheme = ref.watch(followSystemThemeStateProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n!.appearance)),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
SettingsSection(
|
||||
title: l10n.theme,
|
||||
children: [
|
||||
const FollowSystemThemeButton(),
|
||||
if (!followSystemTheme) const DarkModeButton(),
|
||||
const ThemeSelector(),
|
||||
if (isDarkTheme)
|
||||
SwitchListTile(
|
||||
title: Text(l10n.pure_black_dark_mode),
|
||||
value: pureBlackDarkMode,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(pureBlackDarkModeStateProvider.notifier)
|
||||
.set(value);
|
||||
},
|
||||
),
|
||||
if (!pureBlackDarkMode || !isDarkTheme)
|
||||
const BlendLevelSlider(),
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
title: l10n.appearance,
|
||||
children: [
|
||||
_buildLanguageTile(context, ref, l10n),
|
||||
_buildFontTile(context, ref, l10n),
|
||||
ListTile(
|
||||
title: Text(l10n.reorder_navigation),
|
||||
subtitle: Text(
|
||||
l10n.reorder_navigation_description,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
context.push("/customNavigationSettings");
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsSection(
|
||||
title: l10n.timestamp,
|
||||
children: [
|
||||
_buildRelativeTimestampTile(context, ref, l10n),
|
||||
_buildDateFormatTile(context, ref, l10n),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLanguageTile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final l10nLocale = ref.watch(l10nLocaleStateProvider);
|
||||
return ListTile(
|
||||
title: Text(l10n.app_language),
|
||||
subtitle: Text(
|
||||
completeLanguageName(l10nLocale.toLanguageTag()),
|
||||
style: TextStyle(fontSize: 11, color: context.secondaryColor),
|
||||
),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.app_language),
|
||||
content: SizedBox(
|
||||
width: context.width(0.8),
|
||||
child: SuperListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: AppLocalizations.supportedLocales.length,
|
||||
itemBuilder: (context, index) {
|
||||
final locale = AppLocalizations.supportedLocales[index];
|
||||
return RadioListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
value: locale,
|
||||
groupValue: l10nLocale,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(l10nLocaleStateProvider.notifier)
|
||||
.setLocale(locale);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
title: Text(completeLanguageName(locale.toLanguageTag())),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: TextStyle(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFontTile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final appFontFamily = ref.watch(appFontFamilyProvider);
|
||||
final appFontFamilySub =
|
||||
appFontFamily == null
|
||||
|
|
@ -48,486 +204,278 @@ class AppearanceScreen extends ConsumerWidget {
|
|||
(element) => element.value().fontFamily! == appFontFamily,
|
||||
)
|
||||
.key;
|
||||
bool followSystemTheme = ref.watch(followSystemThemeStateProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n!.appearance)),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: Row(
|
||||
return ListTile(
|
||||
title: Text(context.l10n.font),
|
||||
subtitle: Text(
|
||||
appFontFamilySub,
|
||||
style: TextStyle(fontSize: 11, color: context.secondaryColor),
|
||||
),
|
||||
onTap: () {
|
||||
String textValue = "";
|
||||
final controller = ScrollController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.font),
|
||||
content: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return SizedBox(
|
||||
width: context.width(0.8),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
l10n.theme,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: context.primaryColor,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!followSystemTheme) const DarkModeButton(),
|
||||
const FollowSystemThemeButton(),
|
||||
const ThemeSelector(),
|
||||
if (isDarkTheme)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: SwitchListTile(
|
||||
title: Text(l10n.pure_black_dark_mode),
|
||||
value: pureBlackDarkMode,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(pureBlackDarkModeStateProvider.notifier)
|
||||
.set(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!pureBlackDarkMode || !isDarkTheme)
|
||||
const BlendLevelSlider(),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.appearance,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.app_language),
|
||||
content: SizedBox(
|
||||
width: context.width(0.8),
|
||||
child: SuperListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount:
|
||||
AppLocalizations.supportedLocales.length,
|
||||
itemBuilder: (context, index) {
|
||||
final locale =
|
||||
AppLocalizations.supportedLocales[index];
|
||||
return RadioListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
value: locale,
|
||||
groupValue: l10nLocale,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(
|
||||
l10nLocaleStateProvider.notifier,
|
||||
)
|
||||
.setLocale(locale);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
title: Text(
|
||||
completeLanguageName(
|
||||
locale.toLanguageTag(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: TextField(
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
textValue = v;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: false,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
border: const OutlineInputBorder(
|
||||
borderSide: BorderSide(),
|
||||
),
|
||||
hintText: l10n.search,
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
title: Text(l10n.app_language),
|
||||
subtitle: Text(
|
||||
completeLanguageName(l10nLocale.toLanguageTag()),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () {
|
||||
String textValue = "";
|
||||
final controller = ScrollController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(context.l10n.font),
|
||||
content: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return SizedBox(
|
||||
width: context.width(0.8),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 8,
|
||||
),
|
||||
child: TextField(
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
textValue = v;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: false,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
border: const OutlineInputBorder(
|
||||
borderSide: BorderSide(),
|
||||
),
|
||||
hintText: l10n.search,
|
||||
),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
List values =
|
||||
GoogleFonts.asMap().entries
|
||||
.toList();
|
||||
values =
|
||||
values
|
||||
.where(
|
||||
(values) => values.key
|
||||
.toLowerCase()
|
||||
.contains(
|
||||
textValue
|
||||
.toLowerCase(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
List values = GoogleFonts.asMap().entries.toList();
|
||||
values =
|
||||
values
|
||||
.where(
|
||||
(values) => values.key
|
||||
.toLowerCase()
|
||||
.contains(textValue.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
return Flexible(
|
||||
child: Scrollbar(
|
||||
interactive: true,
|
||||
thickness: 12,
|
||||
radius: const Radius.circular(10),
|
||||
controller: controller,
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(0),
|
||||
sliver: SuperSliverList.builder(
|
||||
itemCount: values.length,
|
||||
itemBuilder: (context, index) {
|
||||
final value = values[index];
|
||||
return RadioListTile(
|
||||
dense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.all(0),
|
||||
value: value.value().fontFamily,
|
||||
groupValue: appFontFamily,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(
|
||||
appFontFamilyProvider
|
||||
.notifier,
|
||||
)
|
||||
.toList();
|
||||
return Flexible(
|
||||
child: Scrollbar(
|
||||
interactive: true,
|
||||
thickness: 12,
|
||||
radius: const Radius.circular(10),
|
||||
controller: controller,
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding:
|
||||
const EdgeInsets.all(0),
|
||||
sliver: SuperSliverList.builder(
|
||||
itemCount: values.length,
|
||||
itemBuilder: (
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final value =
|
||||
values[index];
|
||||
return RadioListTile(
|
||||
dense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.all(
|
||||
0,
|
||||
),
|
||||
value:
|
||||
value
|
||||
.value()
|
||||
.fontFamily,
|
||||
groupValue:
|
||||
appFontFamily,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(
|
||||
appFontFamilyProvider
|
||||
.notifier,
|
||||
)
|
||||
.set(value);
|
||||
Navigator.pop(
|
||||
context,
|
||||
);
|
||||
},
|
||||
title: Text(
|
||||
value.key,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
.set(value);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
title: Text(value.key),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
ref
|
||||
.read(appFontFamilyProvider.notifier)
|
||||
.set(null);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.default0,
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
title: Text(context.l10n.font),
|
||||
subtitle: Text(
|
||||
appFontFamilySub,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () {
|
||||
context.push("/customNavigationSettings");
|
||||
},
|
||||
title: Text(l10n.reorder_navigation),
|
||||
subtitle: Text(
|
||||
l10n.reorder_navigation_description,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.timestamp,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.relative_timestamp),
|
||||
content: SizedBox(
|
||||
width: context.width(0.8),
|
||||
child: SuperListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount:
|
||||
relativeTimestampsList(context).length,
|
||||
itemBuilder: (context, index) {
|
||||
return RadioListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
value: index,
|
||||
groupValue: relativeTimestamps,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(
|
||||
relativeTimesTampsStateProvider
|
||||
.notifier,
|
||||
)
|
||||
.set(value!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
relativeTimestampsList(
|
||||
context,
|
||||
)[index],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
title: Text(l10n.relative_timestamp),
|
||||
subtitle: Text(
|
||||
relativeTimestampsList(context)[relativeTimestamps],
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.date_format),
|
||||
content: SizedBox(
|
||||
width: context.width(0.8),
|
||||
child: SuperListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: dateFormatsList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return RadioListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
value: dateFormatsList[index],
|
||||
groupValue: dateFormatState,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(
|
||||
dateFormatStateProvider.notifier,
|
||||
)
|
||||
.set(value!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
"${dateFormatsList[index]} (${dateFormat(context: context, DateTime.now().millisecondsSinceEpoch.toString(), useRelativeTimesTamps: false, dateFormat: dateFormatsList[index], ref: ref)})",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
title: Text(l10n.date_format),
|
||||
subtitle: Text(
|
||||
"$dateFormatState (${dateFormat(context: context, DateTime.now().millisecondsSinceEpoch.toString(), useRelativeTimesTamps: false, dateFormat: dateFormatState, ref: ref)})",
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.secondaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
ref.read(appFontFamilyProvider.notifier).set(null);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.default0,
|
||||
style: TextStyle(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: TextStyle(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRelativeTimestampTile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final relativeTimestamps = ref.watch(relativeTimesTampsStateProvider);
|
||||
return ListTile(
|
||||
title: Text(l10n.relative_timestamp),
|
||||
subtitle: Text(
|
||||
relativeTimestampsList(context)[relativeTimestamps],
|
||||
style: TextStyle(fontSize: 11, color: context.secondaryColor),
|
||||
),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.relative_timestamp),
|
||||
content: SizedBox(
|
||||
width: context.width(0.8),
|
||||
child: SuperListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: relativeTimestampsList(context).length,
|
||||
itemBuilder: (context, index) {
|
||||
return RadioListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
value: index,
|
||||
groupValue: relativeTimestamps,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(relativeTimesTampsStateProvider.notifier)
|
||||
.set(value!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
title: Row(
|
||||
children: [
|
||||
Text(relativeTimestampsList(context)[index]),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: TextStyle(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDateFormatTile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
final dateFormatState = ref.watch(dateFormatStateProvider);
|
||||
return ListTile(
|
||||
title: Text(l10n.date_format),
|
||||
subtitle: Text(
|
||||
"$dateFormatState (${dateFormat(context: context, DateTime.now().millisecondsSinceEpoch.toString(), useRelativeTimesTamps: false, dateFormat: dateFormatState, ref: ref)})",
|
||||
style: TextStyle(fontSize: 11, color: context.secondaryColor),
|
||||
),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(l10n.date_format),
|
||||
content: SizedBox(
|
||||
width: context.width(0.8),
|
||||
child: SuperListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: dateFormatsList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return RadioListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
value: dateFormatsList[index],
|
||||
groupValue: dateFormatState,
|
||||
onChanged: (value) {
|
||||
ref.read(dateFormatStateProvider.notifier).set(value!);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
"${dateFormatsList[index]} (${dateFormat(context: context, DateTime.now().millisecondsSinceEpoch.toString(), useRelativeTimesTamps: false, dateFormat: dateFormatsList[index], ref: ref)})",
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: TextStyle(color: context.primaryColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,70 +21,67 @@ class _CustomNavigationSettingsState
|
|||
final hideItems = ref.watch(hideItemsStateProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.reorder_navigation)),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
shrinkWrap: true,
|
||||
itemCount: navigationOrder.length,
|
||||
itemBuilder: (context, index) {
|
||||
final navigation = navigationOrder[index];
|
||||
return Row(
|
||||
key: Key('navigation_$navigation'),
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(Icons.drag_handle),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: navigationOrder.length,
|
||||
itemBuilder: (context, index) {
|
||||
final navigation = navigationOrder[index];
|
||||
return Row(
|
||||
key: Key('navigation_$navigation'),
|
||||
children: [
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(Icons.drag_handle),
|
||||
),
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
key: Key(navigation),
|
||||
dense: true,
|
||||
value: !hideItems.contains(navigation),
|
||||
onChanged:
|
||||
[
|
||||
"/more",
|
||||
"/browse",
|
||||
"/history",
|
||||
].any((element) => element == navigation)
|
||||
? null
|
||||
: (value) {
|
||||
final temp = hideItems.toList();
|
||||
if (!value && !hideItems.contains(navigation)) {
|
||||
temp.add(navigation);
|
||||
} else if (value) {
|
||||
temp.remove(navigation);
|
||||
}
|
||||
ref
|
||||
.read(hideItemsStateProvider.notifier)
|
||||
.set(temp);
|
||||
},
|
||||
title: Text(navigationItems[navigation]!),
|
||||
),
|
||||
Expanded(
|
||||
child: SwitchListTile(
|
||||
key: Key(navigation),
|
||||
dense: true,
|
||||
value: !hideItems.contains(navigation),
|
||||
onChanged:
|
||||
[
|
||||
"/more",
|
||||
"/browse",
|
||||
"/history",
|
||||
].any((element) => element == navigation)
|
||||
? null
|
||||
: (value) {
|
||||
final temp = hideItems.toList();
|
||||
if (!value && !hideItems.contains(navigation)) {
|
||||
temp.add(navigation);
|
||||
} else if (value) {
|
||||
temp.remove(navigation);
|
||||
}
|
||||
ref
|
||||
.read(hideItemsStateProvider.notifier)
|
||||
.set(temp);
|
||||
},
|
||||
title: Text(navigationItems[navigation]!),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
if (oldIndex < newIndex) {
|
||||
final draggedItem = navigationOrder[oldIndex];
|
||||
for (var i = oldIndex; i < newIndex - 1; i++) {
|
||||
navigationOrder[i] = navigationOrder[i + 1];
|
||||
}
|
||||
navigationOrder[newIndex - 1] = draggedItem;
|
||||
} else {
|
||||
final draggedItem = navigationOrder[oldIndex];
|
||||
for (var i = oldIndex; i > newIndex; i--) {
|
||||
navigationOrder[i] = navigationOrder[i - 1];
|
||||
}
|
||||
navigationOrder[newIndex] = draggedItem;
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
if (oldIndex < newIndex) {
|
||||
final draggedItem = navigationOrder[oldIndex];
|
||||
for (var i = oldIndex; i < newIndex - 1; i++) {
|
||||
navigationOrder[i] = navigationOrder[i + 1];
|
||||
}
|
||||
ref
|
||||
.read(navigationOrderStateProvider.notifier)
|
||||
.set(navigationOrder);
|
||||
},
|
||||
),
|
||||
navigationOrder[newIndex - 1] = draggedItem;
|
||||
} else {
|
||||
final draggedItem = navigationOrder[oldIndex];
|
||||
for (var i = oldIndex; i > newIndex; i--) {
|
||||
navigationOrder[i] = navigationOrder[i - 1];
|
||||
}
|
||||
navigationOrder[newIndex] = draggedItem;
|
||||
}
|
||||
ref
|
||||
.read(navigationOrderStateProvider.notifier)
|
||||
.set(navigationOrder);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ class OAuth {
|
|||
|
||||
OAuth.fromJson(Map<String, dynamic> json) {
|
||||
tokenType = json['token_type'];
|
||||
expiresIn =
|
||||
(json['expires_in'] as int) * 1000 +
|
||||
DateTime.now().millisecondsSinceEpoch;
|
||||
expiresIn = json['expires_in'] as int;
|
||||
accessToken = json['access_token'];
|
||||
refreshToken = json['refresh_token'];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,22 +97,25 @@ class StorageProvider {
|
|||
dir!.path,
|
||||
'downloads',
|
||||
itemTypePath,
|
||||
'${manga.source} (${manga.lang!.toUpperCase()})',
|
||||
manga.name!.replaceForbiddenCharacters('_'),
|
||||
'${manga.source} (${manga.lang!.toUpperCase()})'.trim(),
|
||||
manga.name!.replaceForbiddenCharacters('_').trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Directory?> getMangaChapterDirectory(Chapter chapter) async {
|
||||
final basedir = await getMangaMainDirectory(chapter);
|
||||
Future<Directory?> getMangaChapterDirectory(
|
||||
Chapter chapter, {
|
||||
Directory? mangaMainDirectory,
|
||||
}) async {
|
||||
final basedir = mangaMainDirectory ?? await getMangaMainDirectory(chapter);
|
||||
String scanlator =
|
||||
chapter.scanlator?.isNotEmpty ?? false
|
||||
? "${chapter.scanlator!.replaceForbiddenCharacters('_')}_"
|
||||
? "${chapter.scanlator!.replaceForbiddenCharacters('_')}_".trim()
|
||||
: "";
|
||||
return Directory(
|
||||
path.join(
|
||||
basedir!.path,
|
||||
scanlator + chapter.name!.replaceForbiddenCharacters('_'),
|
||||
scanlator + chapter.name!.replaceForbiddenCharacters('_').trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,530 +93,132 @@ class RouterNotifier extends ChangeNotifier {
|
|||
ShellRoute(
|
||||
builder: (context, state, child) => MainScreen(child: child),
|
||||
routes: [
|
||||
GoRoute(
|
||||
_genericRoute<String?>(
|
||||
name: "MangaLibrary",
|
||||
path: '/MangaLibrary',
|
||||
builder: (context, state) {
|
||||
final presetInput = state.extra as String?;
|
||||
return LibraryScreen(
|
||||
itemType: ItemType.manga,
|
||||
presetInput: presetInput,
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final presetInput = state.extra as String?;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: LibraryScreen(
|
||||
itemType: ItemType.manga,
|
||||
presetInput: presetInput,
|
||||
),
|
||||
);
|
||||
},
|
||||
builder:
|
||||
(id) => LibraryScreen(itemType: ItemType.manga, presetInput: id),
|
||||
),
|
||||
GoRoute(
|
||||
_genericRoute<String?>(
|
||||
name: "AnimeLibrary",
|
||||
path: '/AnimeLibrary',
|
||||
builder: (context, state) {
|
||||
final presetInput = state.extra as String?;
|
||||
return LibraryScreen(
|
||||
itemType: ItemType.anime,
|
||||
presetInput: presetInput,
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final presetInput = state.extra as String?;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: LibraryScreen(
|
||||
itemType: ItemType.anime,
|
||||
presetInput: presetInput,
|
||||
),
|
||||
);
|
||||
},
|
||||
builder:
|
||||
(id) => LibraryScreen(itemType: ItemType.anime, presetInput: id),
|
||||
),
|
||||
GoRoute(
|
||||
_genericRoute<String?>(
|
||||
name: "NovelLibrary",
|
||||
path: '/NovelLibrary',
|
||||
builder: (context, state) {
|
||||
final presetInput = state.extra as String?;
|
||||
return LibraryScreen(
|
||||
itemType: ItemType.novel,
|
||||
presetInput: presetInput,
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final presetInput = state.extra as String?;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: LibraryScreen(
|
||||
itemType: ItemType.novel,
|
||||
presetInput: presetInput,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: "history",
|
||||
path: '/history',
|
||||
builder: (context, state) => const HistoryScreen(),
|
||||
pageBuilder:
|
||||
(context, state) => transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const HistoryScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: "updates",
|
||||
path: '/updates',
|
||||
builder: (context, state) => const UpdatesScreen(),
|
||||
pageBuilder:
|
||||
(context, state) => transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const UpdatesScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: "browse",
|
||||
path: '/browse',
|
||||
builder: (context, state) => const BrowseScreen(),
|
||||
pageBuilder:
|
||||
(context, state) => transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const BrowseScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: "more",
|
||||
path: '/more',
|
||||
builder: (context, state) => const MoreScreen(),
|
||||
pageBuilder:
|
||||
(context, state) =>
|
||||
transitionPage(key: state.pageKey, child: const MoreScreen()),
|
||||
builder:
|
||||
(id) => LibraryScreen(itemType: ItemType.novel, presetInput: id),
|
||||
),
|
||||
_genericRoute(name: "history", child: const HistoryScreen()),
|
||||
_genericRoute(name: "updates", child: const UpdatesScreen()),
|
||||
_genericRoute(name: "browse", child: const BrowseScreen()),
|
||||
_genericRoute(name: "more", child: const MoreScreen()),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: "/mangaHome",
|
||||
_genericRoute<(Source?, bool)>(
|
||||
name: "mangaHome",
|
||||
builder: (context, state) {
|
||||
final source = state.extra as (Source?, bool);
|
||||
return MangaHomeScreen(source: source.$1!, isLatest: source.$2);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final source = state.extra as (Source?, bool);
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: MangaHomeScreen(source: source.$1!, isLatest: source.$2),
|
||||
);
|
||||
},
|
||||
builder: (id) => MangaHomeScreen(source: id.$1!, isLatest: id.$2),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/manga-reader/detail',
|
||||
builder: (context, state) {
|
||||
int mangaId = state.extra as int;
|
||||
|
||||
return MangaReaderDetail(mangaId: mangaId);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
int mangaId = state.extra as int;
|
||||
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: MangaReaderDetail(mangaId: mangaId),
|
||||
);
|
||||
},
|
||||
_genericRoute<int>(
|
||||
path: "/manga-reader/detail",
|
||||
builder: (id) => MangaReaderDetail(mangaId: id),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/mangaReaderView",
|
||||
_genericRoute<int>(
|
||||
name: "mangaReaderView",
|
||||
builder: (context, state) {
|
||||
final chapterId = state.extra as int;
|
||||
return MangaReaderView(chapterId: chapterId);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final chapterId = state.extra as int;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: MangaReaderView(chapterId: chapterId),
|
||||
);
|
||||
},
|
||||
builder: (id) => MangaReaderView(chapterId: id),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/animePlayerView",
|
||||
_genericRoute<int>(
|
||||
name: "animePlayerView",
|
||||
builder: (context, state) {
|
||||
final episodeId = state.extra as int;
|
||||
return AnimePlayerView(episodeId: episodeId);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final episodeId = state.extra as int;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: AnimePlayerView(episodeId: episodeId),
|
||||
);
|
||||
},
|
||||
builder: (id) => AnimePlayerView(episodeId: id),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/novelReaderView",
|
||||
_genericRoute<int>(
|
||||
name: "novelReaderView",
|
||||
builder: (context, state) {
|
||||
final chapterId = state.extra as int;
|
||||
return NovelReaderView(chapterId: chapterId);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final chapterId = state.extra as int;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: NovelReaderView(chapterId: chapterId),
|
||||
);
|
||||
},
|
||||
builder: (id) => NovelReaderView(chapterId: id),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/ExtensionLang",
|
||||
_genericRoute<ItemType>(
|
||||
name: "ExtensionLang",
|
||||
builder: (context, state) {
|
||||
final itemType = state.extra as ItemType;
|
||||
return ExtensionsLang(itemType: itemType);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final itemType = state.extra as ItemType;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: ExtensionsLang(itemType: itemType),
|
||||
);
|
||||
},
|
||||
builder: (itemType) => ExtensionsLang(itemType: itemType),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
builder: (context, state) {
|
||||
return const SettingsScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const SettingsScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/appearance",
|
||||
name: "appearance",
|
||||
builder: (context, state) {
|
||||
return const AppearanceScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const AppearanceScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/extension_detail",
|
||||
_genericRoute(name: "settings", child: const SettingsScreen()),
|
||||
_genericRoute(name: "appearance", child: const AppearanceScreen()),
|
||||
_genericRoute<Source>(
|
||||
name: "extension_detail",
|
||||
builder: (context, state) {
|
||||
final source = state.extra as Source;
|
||||
return ExtensionDetail(source: source);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final source = state.extra as Source;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: ExtensionDetail(source: source),
|
||||
);
|
||||
},
|
||||
builder: (source) => ExtensionDetail(source: source),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/globalSearch",
|
||||
_genericRoute<ItemType>(
|
||||
name: "globalSearch",
|
||||
builder: (context, state) {
|
||||
final itemType = state.extra as ItemType;
|
||||
return GlobalSearchScreen(itemType: itemType);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final itemType = state.extra as ItemType;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: GlobalSearchScreen(itemType: itemType),
|
||||
);
|
||||
},
|
||||
builder: (itemType) => GlobalSearchScreen(itemType: itemType),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/about",
|
||||
name: "about",
|
||||
builder: (context, state) {
|
||||
return const AboutScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: const AboutScreen());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/track",
|
||||
name: "track",
|
||||
builder: (context, state) {
|
||||
return const TrackScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: const TrackScreen());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/sync",
|
||||
name: "sync",
|
||||
builder: (context, state) {
|
||||
return const SyncScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: const SyncScreen());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/sourceFilter",
|
||||
_genericRoute(name: "about", child: const AboutScreen()),
|
||||
_genericRoute(name: "track", child: const TrackScreen()),
|
||||
_genericRoute(name: "sync", child: const SyncScreen()),
|
||||
_genericRoute<ItemType>(
|
||||
name: "sourceFilter",
|
||||
builder: (context, state) {
|
||||
final itemType = state.extra as ItemType;
|
||||
return SourcesFilterScreen(itemType: itemType);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final itemType = state.extra as ItemType;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: SourcesFilterScreen(itemType: itemType),
|
||||
);
|
||||
},
|
||||
builder: (itemType) => SourcesFilterScreen(itemType: itemType),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/downloadQueue",
|
||||
name: "downloadQueue",
|
||||
builder: (context, state) {
|
||||
return const DownloadQueueScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const DownloadQueueScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/mangawebview",
|
||||
_genericRoute(name: "downloadQueue", child: const DownloadQueueScreen()),
|
||||
_genericRoute<Map<String, dynamic>>(
|
||||
name: "mangawebview",
|
||||
builder: (context, state) {
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
return MangaWebView(url: data["url"]!, title: data['title']!);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: MangaWebView(url: data["url"]!, title: data['title']!),
|
||||
);
|
||||
},
|
||||
builder: (data) => MangaWebView(url: data["url"]!, title: data['title']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/categories",
|
||||
_genericRoute<(bool, int)>(
|
||||
name: "categories",
|
||||
builder: (context, state) {
|
||||
final data = state.extra as (bool, int);
|
||||
return CategoriesScreen(data: data);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final data = state.extra as (bool, int);
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: CategoriesScreen(data: data),
|
||||
);
|
||||
},
|
||||
builder: (data) => CategoriesScreen(data: data),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/statistics",
|
||||
name: "statistics",
|
||||
builder: (context, state) {
|
||||
return StatisticsScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: StatisticsScreen());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/general",
|
||||
name: "general",
|
||||
builder: (context, state) {
|
||||
return const GeneralScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: const GeneralScreen());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/readerMode",
|
||||
name: "readerMode",
|
||||
builder: (context, state) {
|
||||
return const ReaderScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: const ReaderScreen());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/browseS",
|
||||
name: "browseS",
|
||||
builder: (context, state) {
|
||||
return const BrowseSScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: const BrowseSScreen());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/SourceRepositories",
|
||||
_genericRoute(name: "statistics", child: const StatisticsScreen()),
|
||||
_genericRoute(name: "general", child: const GeneralScreen()),
|
||||
_genericRoute(name: "readerMode", child: const ReaderScreen()),
|
||||
_genericRoute(name: "browseS", child: const BrowseSScreen()),
|
||||
_genericRoute<ItemType>(
|
||||
name: "SourceRepositories",
|
||||
builder: (context, state) {
|
||||
final itemType = state.extra as ItemType;
|
||||
return SourceRepositories(itemType: itemType);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final itemType = state.extra as ItemType;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: SourceRepositories(itemType: itemType),
|
||||
);
|
||||
},
|
||||
builder: (itemType) => SourceRepositories(itemType: itemType),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/downloads",
|
||||
name: "downloads",
|
||||
builder: (context, state) {
|
||||
return const DownloadsScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const DownloadsScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/dataAndStorage",
|
||||
name: "dataAndStorage",
|
||||
builder: (context, state) {
|
||||
return const DataAndStorage();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const DataAndStorage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/manageTrackers",
|
||||
name: "manageTrackers",
|
||||
builder: (context, state) {
|
||||
return const ManageTrackersScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const ManageTrackersScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/trackingDetail",
|
||||
_genericRoute(name: "downloads", child: const DownloadsScreen()),
|
||||
_genericRoute(name: "dataAndStorage", child: const DataAndStorage()),
|
||||
_genericRoute(name: "manageTrackers", child: const ManageTrackersScreen()),
|
||||
_genericRoute<TrackPreference>(
|
||||
name: "trackingDetail",
|
||||
builder: (context, state) {
|
||||
final trackerPref = state.extra as TrackPreference;
|
||||
return TrackingDetail(trackerPref: trackerPref);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final trackerPref = state.extra as TrackPreference;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: TrackingDetail(trackerPref: trackerPref),
|
||||
);
|
||||
},
|
||||
builder: (trackerPref) => TrackingDetail(trackerPref: trackerPref),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/playerMode",
|
||||
name: "playerMode",
|
||||
builder: (context, state) {
|
||||
return const PlayerScreen();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: const PlayerScreen());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/codeEditor",
|
||||
_genericRoute(name: "playerMode", child: const PlayerScreen()),
|
||||
_genericRoute<int>(
|
||||
name: "codeEditor",
|
||||
builder: (context, state) {
|
||||
final sourceId = state.extra as int?;
|
||||
return CodeEditorPage(sourceId: sourceId);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final sourceId = state.extra as int?;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: CodeEditorPage(sourceId: sourceId),
|
||||
);
|
||||
},
|
||||
builder: (sourceId) => CodeEditorPage(sourceId: sourceId),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/createExtension",
|
||||
name: "createExtension",
|
||||
builder: (context, state) {
|
||||
return const CreateExtension();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const CreateExtension(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/createBackup",
|
||||
name: "createBackup",
|
||||
builder: (context, state) {
|
||||
return const CreateBackup();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(key: state.pageKey, child: const CreateBackup());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/customNavigationSettings",
|
||||
_genericRoute(name: "createExtension", child: const CreateExtension()),
|
||||
_genericRoute(name: "createBackup", child: const CreateBackup()),
|
||||
_genericRoute(
|
||||
name: "customNavigationSettings",
|
||||
builder: (context, state) {
|
||||
return const CustomNavigationSettings();
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: const CustomNavigationSettings(),
|
||||
);
|
||||
},
|
||||
child: const CustomNavigationSettings(),
|
||||
),
|
||||
GoRoute(
|
||||
path: "/migrate",
|
||||
_genericRoute<Manga>(
|
||||
name: "migrate",
|
||||
builder: (context, state) {
|
||||
final manga = state.extra as Manga;
|
||||
return MigrationScreen(manga: manga);
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final manga = state.extra as Manga;
|
||||
return transitionPage(
|
||||
key: state.pageKey,
|
||||
child: MigrationScreen(manga: manga),
|
||||
);
|
||||
},
|
||||
builder: (manga) => MigrationScreen(manga: manga),
|
||||
),
|
||||
];
|
||||
|
||||
GoRoute _genericRoute<T>({
|
||||
String? name,
|
||||
String? path,
|
||||
Widget Function(T extra)? builder,
|
||||
Widget? child,
|
||||
}) {
|
||||
return GoRoute(
|
||||
path: path ?? (name != null ? "/$name" : "/"),
|
||||
name: name,
|
||||
builder: (context, state) {
|
||||
if (builder != null) {
|
||||
final id = state.extra as T;
|
||||
return builder(id);
|
||||
} else {
|
||||
return child!;
|
||||
}
|
||||
},
|
||||
pageBuilder: (context, state) {
|
||||
final pageChild = builder != null ? builder(state.extra as T) : child!;
|
||||
return transitionPage(key: state.pageKey, child: pageChild);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Page transitionPage({required LocalKey key, required child}) {
|
||||
|
|
|
|||
|
|
@ -50,8 +50,11 @@ Future<GetChapterPagesModel> getChapterPages(
|
|||
.firstOrNull;
|
||||
final incognitoMode = ref.watch(incognitoModeStateProvider);
|
||||
final storageProvider = StorageProvider();
|
||||
path = await storageProvider.getMangaChapterDirectory(chapter);
|
||||
final mangaDirectory = await storageProvider.getMangaMainDirectory(chapter);
|
||||
path = await storageProvider.getMangaChapterDirectory(
|
||||
chapter,
|
||||
mangaMainDirectory: mangaDirectory,
|
||||
);
|
||||
|
||||
List<Uint8List?> archiveImages = [];
|
||||
final isLocalArchive = (chapter.archivePath ?? '').isNotEmpty;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import 'package:mangayomi/services/http/rhttp/rhttp.dart' as rhttp;
|
|||
|
||||
class MClient {
|
||||
MClient();
|
||||
static final defaultClient = IOClient(HttpClient());
|
||||
static final Map<rhttp.ClientSettings, Client> rhttpPool = {};
|
||||
static Client httpClient({
|
||||
Map<String, dynamic>? reqcopyWith,
|
||||
rhttp.ClientSettings? settings,
|
||||
|
|
@ -41,10 +43,12 @@ class MClient {
|
|||
verifyCertificates: reqcopyWith?["verifyCertificates"] ?? false,
|
||||
),
|
||||
);
|
||||
return rhttp.RhttpCompatibleClient.createSync(settings: settings);
|
||||
return rhttpPool.putIfAbsent(settings, () {
|
||||
return rhttp.RhttpCompatibleClient.createSync(settings: settings);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
return IOClient(HttpClient());
|
||||
return defaultClient;
|
||||
}
|
||||
|
||||
static InterceptedClient init({
|
||||
|
|
@ -66,12 +70,11 @@ class MClient {
|
|||
static Map<String, String> getCookiesPref(String url) {
|
||||
final cookiesList = isar.settings.getSync(227)!.cookiesList ?? [];
|
||||
if (cookiesList.isEmpty) return {};
|
||||
final host = Uri.parse(url).host;
|
||||
final cookies =
|
||||
cookiesList
|
||||
.firstWhere(
|
||||
(element) =>
|
||||
element.host == Uri.parse(url).host ||
|
||||
Uri.parse(url).host.contains(element.host!),
|
||||
(element) => element.host == host || host.contains(element.host!),
|
||||
orElse: () => MCookie(cookie: ""),
|
||||
)
|
||||
.cookie!;
|
||||
|
|
@ -93,56 +96,51 @@ class MClient {
|
|||
.split(RegExp('(?<=)(,)(?=[^;]+?=)'))
|
||||
.where((cookie) => cookie.isNotEmpty)
|
||||
.toList();
|
||||
} else {
|
||||
if (!Platform.isLinux) {
|
||||
cookies =
|
||||
(await flutter_inappwebview.CookieManager.instance(
|
||||
webViewEnvironment: webViewEnvironment,
|
||||
).getCookies(
|
||||
url: flutter_inappwebview.WebUri(url),
|
||||
webViewController: webViewController,
|
||||
)).map((e) => "${e.name}=${e.value}").toList();
|
||||
}
|
||||
} else if (!Platform.isLinux) {
|
||||
cookies =
|
||||
(await flutter_inappwebview.CookieManager.instance(
|
||||
webViewEnvironment: webViewEnvironment,
|
||||
).getCookies(
|
||||
url: flutter_inappwebview.WebUri(url),
|
||||
webViewController: webViewController,
|
||||
)).map((e) => "${e.name}=${e.value}").toList();
|
||||
}
|
||||
if (cookies.isNotEmpty) {
|
||||
final host = Uri.parse(url).host;
|
||||
final newCookie = cookies.join("; ");
|
||||
final settings = isar.settings.getSync(227);
|
||||
List<MCookie>? cookieList = [];
|
||||
for (var cookie in settings!.cookiesList ?? []) {
|
||||
if (cookie.host != host || (!host.contains(cookie.host))) {
|
||||
cookieList.add(cookie);
|
||||
}
|
||||
}
|
||||
cookieList.add(
|
||||
final settings = await isar.settings.get(227);
|
||||
final existingCookies = settings!.cookiesList ?? [];
|
||||
final filteredCookies = removeCookiesForHost(existingCookies, host);
|
||||
filteredCookies.add(
|
||||
MCookie()
|
||||
..host = host
|
||||
..cookie = newCookie,
|
||||
);
|
||||
isar.writeTxnSync(
|
||||
() => isar.settings.putSync(settings..cookiesList = cookieList),
|
||||
await isar.writeTxn(
|
||||
() => isar.settings.put(settings..cookiesList = filteredCookies),
|
||||
);
|
||||
}
|
||||
if (ua.isNotEmpty) {
|
||||
final settings = isar.settings.getSync(227);
|
||||
isar.writeTxnSync(() => isar.settings.putSync(settings!..userAgent = ua));
|
||||
final settings = await isar.settings.get(227);
|
||||
await isar.writeTxn(() => isar.settings.put(settings!..userAgent = ua));
|
||||
}
|
||||
}
|
||||
|
||||
static void deleteAllCookies(String url) {
|
||||
final cookiesList = isar.settings.getSync(227)!.cookiesList ?? [];
|
||||
List<MCookie>? cookieList = [];
|
||||
for (var cookie in cookiesList) {
|
||||
if (!(cookie.host == Uri.parse(url).host ||
|
||||
Uri.parse(url).host.contains(cookie.host!))) {
|
||||
cookieList.add(cookie);
|
||||
}
|
||||
}
|
||||
isar.writeTxnSync(
|
||||
() => isar.settings.putSync(
|
||||
isar.settings.getSync(227)!..cookiesList = cookieList,
|
||||
),
|
||||
);
|
||||
static List<MCookie> removeCookiesForHost(
|
||||
List<MCookie> allCookies,
|
||||
String host,
|
||||
) {
|
||||
return allCookies
|
||||
.where((cookie) => cookie.host != host && !host.contains(cookie.host!))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<void> deleteAllCookies(String url) async {
|
||||
final settings = await isar.settings.get(227);
|
||||
final oldCookies = settings!.cookiesList ?? [];
|
||||
final host = Uri.parse(url).host;
|
||||
settings.cookiesList = removeCookiesForHost(oldCookies, host);
|
||||
await isar.writeTxn(() => isar.settings.put(settings));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +152,8 @@ class MCookieManager extends InterceptorContract {
|
|||
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
|
||||
final cookie = MClient.getCookiesPref(request.url.toString());
|
||||
if (cookie.isNotEmpty) {
|
||||
final userAgent = isar.settings.getSync(227)!.userAgent!;
|
||||
final settings = await isar.settings.get(227);
|
||||
final userAgent = settings!.userAgent!;
|
||||
if (request.headers[HttpHeaders.cookieHeader] == null) {
|
||||
request.headers.addAll(cookie);
|
||||
}
|
||||
|
|
@ -211,12 +210,7 @@ class LoggerInterceptor extends InterceptorContract {
|
|||
required BaseResponse response,
|
||||
}) async {
|
||||
if (showCloudFlareError) {
|
||||
final cloudflare =
|
||||
[403, 503].contains(response.statusCode) &&
|
||||
[
|
||||
"cloudflare-nginx",
|
||||
"cloudflare",
|
||||
].contains(response.headers["server"]);
|
||||
final cloudflare = isCloudflare(response);
|
||||
final content =
|
||||
"----- Response -----\n${response.request?.method}: ${response.request?.url}, statusCode: ${response.statusCode} ${cloudflare ? "Failed to bypass Cloudflare" : ""}";
|
||||
if (kDebugMode) {
|
||||
|
|
@ -238,6 +232,11 @@ class LoggerInterceptor extends InterceptorContract {
|
|||
}
|
||||
}
|
||||
|
||||
bool isCloudflare(BaseResponse response) {
|
||||
return [403, 503].contains(response.statusCode) &&
|
||||
["cloudflare-nginx", "cloudflare"].contains(response.headers["server"]);
|
||||
}
|
||||
|
||||
class ResolveCloudFlareChallenge extends RetryPolicy {
|
||||
bool showCloudFlareError;
|
||||
ResolveCloudFlareChallenge(this.showCloudFlareError);
|
||||
|
|
@ -249,11 +248,8 @@ class ResolveCloudFlareChallenge extends RetryPolicy {
|
|||
flutter_inappwebview.HeadlessInAppWebView? headlessWebView;
|
||||
int time = 0;
|
||||
bool timeOut = false;
|
||||
final cloudflare =
|
||||
[403, 503].contains(response.statusCode) &&
|
||||
["cloudflare-nginx", "cloudflare"].contains(response.headers["server"]);
|
||||
if (cloudflare) {
|
||||
bool isCloudFlare = true;
|
||||
bool isCloudFlare = isCloudflare(response);
|
||||
if (isCloudFlare) {
|
||||
headlessWebView = flutter_inappwebview.HeadlessInAppWebView(
|
||||
webViewEnvironment: webViewEnvironment,
|
||||
initialUrlRequest: flutter_inappwebview.URLRequest(
|
||||
|
|
@ -270,10 +266,7 @@ class ResolveCloudFlareChallenge extends RetryPolicy {
|
|||
}
|
||||
|
||||
await Future.doWhile(() async {
|
||||
if (timeOut == true) {
|
||||
return false;
|
||||
}
|
||||
if (isCloudFlare) {
|
||||
if (!timeOut && isCloudFlare) {
|
||||
try {
|
||||
isCloudFlare = await controller.platform.evaluateJavascript(
|
||||
source:
|
||||
|
|
@ -282,9 +275,10 @@ class ResolveCloudFlareChallenge extends RetryPolicy {
|
|||
} catch (_) {
|
||||
isCloudFlare = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (isCloudFlare) await Future.delayed(Duration(milliseconds: 300));
|
||||
|
||||
return isCloudFlare;
|
||||
});
|
||||
if (!timeOut) {
|
||||
final ua =
|
||||
|
|
|
|||
|
|
@ -16,15 +16,15 @@ part 'anilist.g.dart';
|
|||
@riverpod
|
||||
class Anilist extends _$Anilist {
|
||||
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
|
||||
final String _clientId =
|
||||
(Platform.isWindows || Platform.isLinux) ? '13587' : '13588';
|
||||
static final _isDesktop = Platform.isWindows || Platform.isLinux;
|
||||
final String _clientId = _isDesktop ? '13587' : '13588';
|
||||
static const String _baseApiUrl = "https://graphql.anilist.co/";
|
||||
final String _redirectUri =
|
||||
(Platform.isWindows || Platform.isLinux)
|
||||
_isDesktop
|
||||
? 'http://localhost:43824/success?code=1337'
|
||||
: 'mangayomi://success?code=1337';
|
||||
final String _clientSecret =
|
||||
(Platform.isWindows || Platform.isLinux)
|
||||
_isDesktop
|
||||
? 'tJA13cAR2tCCXrJCwwvmwEDbWRoIaahFiJTXToHd'
|
||||
: 'G2fFUiGtgFd60D0lCkhgGKvMmrCfDmZXADQIzWXr';
|
||||
|
||||
|
|
@ -33,11 +33,10 @@ class Anilist extends _$Anilist {
|
|||
|
||||
Future<bool?> login() async {
|
||||
final callbackUrlScheme =
|
||||
(Platform.isWindows || Platform.isLinux)
|
||||
? 'http://localhost:43824'
|
||||
: 'mangayomi';
|
||||
_isDesktop ? 'http://localhost:43824' : 'mangayomi';
|
||||
final loginUrl =
|
||||
'https://anilist.co/api/v2/oauth/authorize?client_id=$_clientId&redirect_uri=$_redirectUri&response_type=code';
|
||||
'https://anilist.co/api/v2/oauth/authorize?client_id=$_clientId'
|
||||
'&redirect_uri=$_redirectUri&response_type=code';
|
||||
|
||||
try {
|
||||
final uri = await FlutterWebAuth2.authenticate(
|
||||
|
|
@ -57,7 +56,11 @@ class Anilist extends _$Anilist {
|
|||
},
|
||||
);
|
||||
final res = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final aLOAuth = OAuth.fromJson(res);
|
||||
final aLOAuth = OAuth.fromJson(res)
|
||||
..expiresIn =
|
||||
DateTime.now()
|
||||
.add(Duration(seconds: res['expires_in']))
|
||||
.millisecondsSinceEpoch;
|
||||
final currenUser = await _getCurrentUser(aLOAuth.accessToken!);
|
||||
ref
|
||||
.read(tracksProvider(syncId: syncId).notifier)
|
||||
|
|
@ -76,84 +79,19 @@ class Anilist extends _$Anilist {
|
|||
}
|
||||
}
|
||||
|
||||
Future<Track> addLibManga(Track track) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
Future<Track> update(Track track, bool isManga) async {
|
||||
final isNew = track.libraryId == null;
|
||||
final opName = isNew ? 'AddEntry' : 'UpdateEntry';
|
||||
final idVarName = isNew ? 'mediaId' : 'id';
|
||||
final idVarValue = isNew ? track.mediaId : track.libraryId!;
|
||||
|
||||
const query = '''
|
||||
mutation AddManga(\$mangaId: Int, \$progress: Int, \$status: MediaListStatus) {
|
||||
SaveMediaListEntry(mediaId: \$mangaId, progress: \$progress, status: \$status) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
final body = {
|
||||
"query": query,
|
||||
"variables": {
|
||||
"mangaId": track.mediaId,
|
||||
"progress": track.lastChapterRead,
|
||||
"status": toAniListStatusManga(track.status),
|
||||
},
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(_baseApiUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: json.encode(body),
|
||||
);
|
||||
final data = json.decode(response.body);
|
||||
track.libraryId = data['data']['SaveMediaListEntry']['id'];
|
||||
return track;
|
||||
}
|
||||
|
||||
Future<Track> addLibAnime(Track track) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
|
||||
const query = '''
|
||||
mutation AddAnime(\$animeId: Int, \$progress: Int, \$status: MediaListStatus) {
|
||||
SaveMediaListEntry(mediaId: \$animeId, progress: \$progress, status: \$status) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
''';
|
||||
final body = {
|
||||
"query": query,
|
||||
"variables": {
|
||||
"animeId": track.mediaId,
|
||||
"progress": track.lastChapterRead,
|
||||
"status": toAniListStatusAnime(track.status),
|
||||
},
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(_baseApiUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: json.encode(body),
|
||||
);
|
||||
final data = json.decode(response.body);
|
||||
track.libraryId = data['data']['SaveMediaListEntry']['id'];
|
||||
return track;
|
||||
}
|
||||
|
||||
Future<Track> updateLibManga(Track track) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
const query = '''
|
||||
mutation UpdateManga(\$listId: Int, \$progress: Int, \$status: MediaListStatus, \$score: Int, \$startedAt: FuzzyDateInput, \$completedAt: FuzzyDateInput) {
|
||||
final document = '''
|
||||
mutation $opName(\$$idVarName: Int!, \$progress: Int!, \$status: MediaListStatus${isNew ? '' : ', \$score: Int, \$startedAt: FuzzyDateInput, \$completedAt: FuzzyDateInput'} ) {
|
||||
SaveMediaListEntry(
|
||||
id: \$listId,
|
||||
${isNew ? 'mediaId' : 'id'}: \$$idVarName,
|
||||
progress: \$progress,
|
||||
status: \$status,
|
||||
scoreRaw: \$score,
|
||||
startedAt: \$startedAt,
|
||||
completedAt: \$completedAt,
|
||||
${!isNew ? 'scoreRaw: \$score, startedAt: \$startedAt, completedAt: \$completedAt,' : ''}
|
||||
) {
|
||||
id
|
||||
status
|
||||
|
|
@ -162,115 +100,47 @@ class Anilist extends _$Anilist {
|
|||
}
|
||||
''';
|
||||
|
||||
final body = {
|
||||
"query": query,
|
||||
"variables": {
|
||||
"listId": track.libraryId,
|
||||
"progress": track.lastChapterRead,
|
||||
"status": toAniListStatusManga(track.status),
|
||||
"score": track.score!,
|
||||
"startedAt": createDate(track.startedReadingDate!),
|
||||
"completedAt": createDate(track.finishedReadingDate!),
|
||||
},
|
||||
final vars = {
|
||||
idVarName: idVarValue,
|
||||
'progress': track.lastChapterRead,
|
||||
'status': toAniListStatus(track.status, isManga),
|
||||
if (!isNew) 'score': track.score!,
|
||||
if (!isNew) 'startedAt': createDate(track.startedReadingDate!),
|
||||
if (!isNew) 'completedAt': createDate(track.finishedReadingDate!),
|
||||
};
|
||||
|
||||
await http.post(
|
||||
Uri.parse(_baseApiUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: json.encode(body),
|
||||
);
|
||||
final data = await _executeGraphQL(document, vars);
|
||||
final entry = data['SaveMediaListEntry'] as Map<String, dynamic>;
|
||||
track.libraryId = entry['id'] as int;
|
||||
return track;
|
||||
}
|
||||
|
||||
Future<Track> updateLibAnime(Track track) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
const query = '''
|
||||
mutation UpdateAnime(\$listId: Int, \$progress: Int, \$status: MediaListStatus, \$score: Int, \$startedAt: FuzzyDateInput, \$completedAt: FuzzyDateInput) {
|
||||
SaveMediaListEntry(
|
||||
id: \$listId,
|
||||
progress: \$progress,
|
||||
status: \$status,
|
||||
scoreRaw: \$score,
|
||||
startedAt: \$startedAt,
|
||||
completedAt: \$completedAt,
|
||||
) {
|
||||
id
|
||||
status
|
||||
progress
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
final body = {
|
||||
"query": query,
|
||||
"variables": {
|
||||
"listId": track.libraryId,
|
||||
"progress": track.lastChapterRead,
|
||||
"status": toAniListStatusAnime(track.status),
|
||||
"score": track.score!,
|
||||
"startedAt": createDate(track.startedReadingDate!),
|
||||
"completedAt": createDate(track.finishedReadingDate!),
|
||||
},
|
||||
};
|
||||
await http.post(
|
||||
Uri.parse(_baseApiUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: json.encode(body),
|
||||
);
|
||||
return track;
|
||||
}
|
||||
|
||||
Future<List<TrackSearch>> search(String search) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
const query = '''
|
||||
Future<List<TrackSearch>> search(String search, bool isManga) async {
|
||||
final type = isManga ? "MANGA" : "ANIME";
|
||||
final contentUnit = isManga ? "chapters" : "episodes";
|
||||
final query = '''
|
||||
query Search(\$query: String) {
|
||||
Page(perPage: 50) {
|
||||
media(search: \$query, type: MANGA, format_not_in: [NOVEL]) {
|
||||
media(search: \$query, type: $type, format_not_in: [NOVEL]) {
|
||||
id
|
||||
title {
|
||||
userPreferred
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
title { userPreferred }
|
||||
coverImage { large }
|
||||
format
|
||||
status
|
||||
chapters
|
||||
$contentUnit
|
||||
description
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
startDate { year month day }
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
final body = {
|
||||
"query": query,
|
||||
"variables": {"query": search},
|
||||
};
|
||||
final vars = {"query": search};
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(_baseApiUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: json.encode(body),
|
||||
);
|
||||
|
||||
final data = json.decode(response.body);
|
||||
final data = await _executeGraphQL(query, vars);
|
||||
|
||||
final entries = List<Map<String, dynamic>>.from(
|
||||
data['data']['Page']['media'],
|
||||
data['Page']['media'] as List,
|
||||
);
|
||||
return entries
|
||||
.map(
|
||||
|
|
@ -280,7 +150,7 @@ class Anilist extends _$Anilist {
|
|||
trackingUrl: "",
|
||||
mediaId: jsonRes['id'],
|
||||
summary: jsonRes['description'] ?? "",
|
||||
totalChapter: jsonRes['chapters'] ?? 0,
|
||||
totalChapter: jsonRes[contentUnit] ?? 0,
|
||||
coverUrl: jsonRes['coverImage']['large'] ?? "",
|
||||
title: jsonRes['title']['userPreferred'],
|
||||
startDate:
|
||||
|
|
@ -295,229 +165,77 @@ class Anilist extends _$Anilist {
|
|||
.toList();
|
||||
}
|
||||
|
||||
Future<List<TrackSearch>> searchAnime(String search) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
const query = '''
|
||||
query Search(\$query: String) {
|
||||
Page(perPage: 50) {
|
||||
media(search: \$query, type: ANIME) {
|
||||
id
|
||||
title {
|
||||
userPreferred
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
format
|
||||
status
|
||||
episodes
|
||||
description
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
final body = {
|
||||
"query": query,
|
||||
"variables": {"query": search},
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(_baseApiUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: json.encode(body),
|
||||
Future<Track?> findLibItem(Track track, bool isManga) async {
|
||||
final userId = int.parse(
|
||||
ref.watch(tracksProvider(syncId: syncId))!.username!,
|
||||
);
|
||||
final type = isManga ? "MANGA" : "ANIME";
|
||||
final typeVar = isManga ? "manga_id" : "anime_id";
|
||||
final contentUnit = isManga ? "chapters" : "episodes";
|
||||
|
||||
final data = json.decode(response.body);
|
||||
|
||||
final entries = List<Map<String, dynamic>>.from(
|
||||
data['data']['Page']['media'],
|
||||
);
|
||||
return entries
|
||||
.map(
|
||||
(jsonRes) => TrackSearch(
|
||||
libraryId: jsonRes['id'],
|
||||
syncId: syncId,
|
||||
trackingUrl: "",
|
||||
mediaId: jsonRes['id'],
|
||||
summary: jsonRes['description'] ?? "",
|
||||
totalChapter: jsonRes['episodes'] ?? 0,
|
||||
coverUrl: jsonRes['coverImage']['large'] ?? "",
|
||||
title: jsonRes['title']['userPreferred'],
|
||||
startDate:
|
||||
jsonRes["start_date"] ??
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
parseDate(jsonRes, 'startDate'),
|
||||
).toString(),
|
||||
publishingType: "",
|
||||
publishingStatus: jsonRes['status'],
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Track?> findLibManga(Track track) async {
|
||||
final userId = ref.watch(tracksProvider(syncId: syncId))!.username;
|
||||
|
||||
final accessToken = await _getAccesToken();
|
||||
const query = '''
|
||||
query(\$id: Int!, \$manga_id: Int!) {
|
||||
final query = '''
|
||||
query(\$id: Int!, \$$typeVar: Int!) {
|
||||
Page {
|
||||
mediaList(userId: \$id, type: MANGA, mediaId: \$manga_id) {
|
||||
mediaList(userId: \$id, type: $type, mediaId: \$$typeVar) {
|
||||
id
|
||||
status
|
||||
scoreRaw: score(format: POINT_100)
|
||||
progress
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
startedAt { year month day }
|
||||
completedAt { year month day }
|
||||
media {
|
||||
id
|
||||
title {
|
||||
userPreferred
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
title { userPreferred }
|
||||
coverImage { large }
|
||||
format
|
||||
status
|
||||
chapters
|
||||
$contentUnit
|
||||
description
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
startDate { year month day }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
final body = {
|
||||
"query": query,
|
||||
"variables": {"id": int.parse(userId!), "manga_id": track.mediaId},
|
||||
};
|
||||
final vars = {"id": userId, typeVar: track.mediaId};
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse(_baseApiUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: json.encode(body),
|
||||
);
|
||||
final data = json.decode(response.body);
|
||||
final data = await _executeGraphQL(query, vars);
|
||||
final entries = List<Map<String, dynamic>>.from(
|
||||
data['data']['Page']['mediaList'],
|
||||
data['Page']['mediaList'] as List,
|
||||
);
|
||||
if (entries.isNotEmpty) {
|
||||
final jsonRes = entries.first;
|
||||
track.libraryId = jsonRes['id'];
|
||||
track.syncId = syncId;
|
||||
track.mediaId = jsonRes['media']['id'];
|
||||
track.status = _getALTrackStatusManga(jsonRes['status']);
|
||||
track.title = jsonRes['media']['title']['userPreferred'] ?? '';
|
||||
track.score = jsonRes['scoreRaw'] ?? 0;
|
||||
track.lastChapterRead = jsonRes['progress'] ?? 0;
|
||||
track.startedReadingDate = parseDate(jsonRes, 'startedAt');
|
||||
track.finishedReadingDate = parseDate(jsonRes, 'completedAt');
|
||||
track.totalChapter = jsonRes['media']["chapters"] ?? 0;
|
||||
}
|
||||
return entries.isNotEmpty ? track : null;
|
||||
if (entries.isEmpty) return null;
|
||||
|
||||
final jsonRes = entries.first;
|
||||
return track
|
||||
..libraryId = jsonRes['id'] as int
|
||||
..syncId = syncId
|
||||
..mediaId = jsonRes['media']['id'] as int
|
||||
..status = _getALTrackStatus(jsonRes['status'], isManga)
|
||||
..title = jsonRes['media']['title']['userPreferred'] ?? ''
|
||||
..score = jsonRes['scoreRaw'] as int?
|
||||
..lastChapterRead = jsonRes['progress'] as int? ?? 0
|
||||
..startedReadingDate = parseDate(jsonRes, 'startedAt')
|
||||
..finishedReadingDate = parseDate(jsonRes, 'completedAt')
|
||||
..totalChapter = jsonRes['media'][contentUnit] as int? ?? 0;
|
||||
}
|
||||
|
||||
Future<Track?> findLibAnime(Track track) async {
|
||||
final userId = ref.watch(tracksProvider(syncId: syncId))!.username;
|
||||
|
||||
final accessToken = await _getAccesToken();
|
||||
const query = '''
|
||||
query(\$id: Int!, \$anime_id: Int!) {
|
||||
Page {
|
||||
mediaList(userId: \$id, type: ANIME, mediaId: \$anime_id) {
|
||||
id
|
||||
status
|
||||
scoreRaw: score(format: POINT_100)
|
||||
progress
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
media {
|
||||
id
|
||||
title {
|
||||
userPreferred
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
format
|
||||
status
|
||||
episodes
|
||||
description
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
||||
|
||||
final body = {
|
||||
"query": query,
|
||||
"variables": {"id": int.parse(userId!), "anime_id": track.mediaId},
|
||||
};
|
||||
|
||||
Future<Map<String, dynamic>> _executeGraphQL(
|
||||
String document,
|
||||
Map<String, dynamic> variables,
|
||||
) async {
|
||||
final response = await http.post(
|
||||
Uri.parse(_baseApiUrl),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ${await _getAccessToken()}',
|
||||
},
|
||||
body: json.encode(body),
|
||||
body: jsonEncode({'query': document, 'variables': variables}),
|
||||
);
|
||||
final data = json.decode(response.body);
|
||||
final entries = List<Map<String, dynamic>>.from(
|
||||
data['data']['Page']['mediaList'],
|
||||
);
|
||||
if (entries.isNotEmpty) {
|
||||
final jsonRes = entries.first;
|
||||
track.libraryId = jsonRes['id'];
|
||||
track.syncId = syncId;
|
||||
track.mediaId = jsonRes['media']['id'];
|
||||
track.status = _getALTrackStatusAnime(jsonRes['status']);
|
||||
track.title = jsonRes['media']['title']['userPreferred'] ?? '';
|
||||
track.score = jsonRes['scoreRaw'] ?? 0;
|
||||
track.lastChapterRead = jsonRes['progress'] ?? 0;
|
||||
track.startedReadingDate = parseDate(jsonRes, 'startedAt');
|
||||
track.finishedReadingDate = parseDate(jsonRes, 'completedAt');
|
||||
track.totalChapter = jsonRes['media']["episodes"] ?? 0;
|
||||
}
|
||||
return entries.isNotEmpty ? track : null;
|
||||
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
|
||||
return decoded['data'] as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<(String, String)> _getCurrentUser(String accessToken) async {
|
||||
|
|
@ -551,7 +269,7 @@ class Anilist extends _$Anilist {
|
|||
);
|
||||
}
|
||||
|
||||
Future<String> _getAccesToken() async {
|
||||
Future<String> _getAccessToken() async {
|
||||
final track = ref.watch(tracksProvider(syncId: syncId));
|
||||
final mALOAuth = OAuth.fromJson(
|
||||
jsonDecode(track!.oAuth!) as Map<String, dynamic>,
|
||||
|
|
@ -590,63 +308,35 @@ class Anilist extends _$Anilist {
|
|||
};
|
||||
}
|
||||
|
||||
TrackStatus _getALTrackStatusManga(String status) {
|
||||
TrackStatus _getALTrackStatus(String status, bool isManga) {
|
||||
return switch (status) {
|
||||
"CURRENT" => TrackStatus.reading,
|
||||
"CURRENT" => isManga ? TrackStatus.reading : TrackStatus.watching,
|
||||
"COMPLETED" => TrackStatus.completed,
|
||||
"PAUSED" => TrackStatus.onHold,
|
||||
"DROPPED" => TrackStatus.dropped,
|
||||
"PLANNING" => TrackStatus.planToRead,
|
||||
_ => TrackStatus.rereading,
|
||||
"PLANNING" => isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
|
||||
_ => isManga ? TrackStatus.reReading : TrackStatus.reWatching,
|
||||
};
|
||||
}
|
||||
|
||||
TrackStatus _getALTrackStatusAnime(String status) {
|
||||
return switch (status) {
|
||||
"CURRENT" => TrackStatus.watching,
|
||||
"COMPLETED" => TrackStatus.completed,
|
||||
"PAUSED" => TrackStatus.onHold,
|
||||
"DROPPED" => TrackStatus.dropped,
|
||||
"PLANNING" => TrackStatus.planToWatch,
|
||||
_ => TrackStatus.reWatching,
|
||||
};
|
||||
}
|
||||
|
||||
List<TrackStatus> aniListStatusListManga = [
|
||||
TrackStatus.reading,
|
||||
List<TrackStatus> statusList(bool isManga) => [
|
||||
isManga ? TrackStatus.reading : TrackStatus.watching,
|
||||
TrackStatus.completed,
|
||||
TrackStatus.onHold,
|
||||
TrackStatus.dropped,
|
||||
TrackStatus.planToRead,
|
||||
TrackStatus.rereading,
|
||||
];
|
||||
List<TrackStatus> aniListStatusListAnime = [
|
||||
TrackStatus.watching,
|
||||
TrackStatus.completed,
|
||||
TrackStatus.onHold,
|
||||
TrackStatus.dropped,
|
||||
TrackStatus.planToWatch,
|
||||
TrackStatus.reWatching,
|
||||
isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
|
||||
isManga ? TrackStatus.reReading : TrackStatus.reWatching,
|
||||
];
|
||||
|
||||
String? toAniListStatusManga(TrackStatus status) {
|
||||
String? toAniListStatus(TrackStatus status, bool isManga) {
|
||||
return switch (status) {
|
||||
TrackStatus.reading => "CURRENT",
|
||||
TrackStatus.reading when isManga => "CURRENT",
|
||||
TrackStatus.watching when !isManga => "CURRENT",
|
||||
TrackStatus.completed => "COMPLETED",
|
||||
TrackStatus.onHold => "PAUSED",
|
||||
TrackStatus.dropped => "DROPPED",
|
||||
TrackStatus.planToRead => "PLANNING",
|
||||
_ => "REPEATING",
|
||||
};
|
||||
}
|
||||
|
||||
String? toAniListStatusAnime(TrackStatus status) {
|
||||
return switch (status) {
|
||||
TrackStatus.watching => "CURRENT",
|
||||
TrackStatus.completed => "COMPLETED",
|
||||
TrackStatus.onHold => "PAUSED",
|
||||
TrackStatus.dropped => "DROPPED",
|
||||
TrackStatus.planToWatch => "PLANNING",
|
||||
TrackStatus.planToRead when isManga => "PLANNING",
|
||||
TrackStatus.planToWatch when !isManga => "PLANNING",
|
||||
_ => "REPEATING",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ class Kitsu extends _$Kitsu {
|
|||
'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd';
|
||||
final String _clientSecret =
|
||||
'54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151';
|
||||
final String _baseUrl = 'https://kitsu.io/api/edge/';
|
||||
final String _loginUrl = 'https://kitsu.io/api/oauth/token';
|
||||
final String _algoliaKeyUrl = 'https://kitsu.io/api/edge/algolia-keys/media/';
|
||||
final String _baseUrl = 'https://kitsu.app/api/edge/';
|
||||
final String _loginUrl = 'https://kitsu.app/api/oauth/token';
|
||||
final String _algoliaKeyUrl =
|
||||
'https://kitsu.app/api/edge/algolia-keys/media/';
|
||||
final String _algoliaUrl =
|
||||
'https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/query/';
|
||||
final String _algoliaAppId = 'AWQO5J657S';
|
||||
|
|
@ -31,7 +32,7 @@ class Kitsu extends _$Kitsu {
|
|||
'${isManga ? 'chapter' : 'episode'}Count%22%2C%22posterImage%22%2C%22'
|
||||
'startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D';
|
||||
|
||||
String _mediaUrl(String type, int id) => 'https://kitsu.io/$type/$id';
|
||||
String _mediaUrl(String type, int id) => 'https://kitsu.app/$type/$id';
|
||||
|
||||
@override
|
||||
void build({required int syncId, ItemType? itemType}) {}
|
||||
|
|
@ -53,7 +54,11 @@ class Kitsu extends _$Kitsu {
|
|||
final res =
|
||||
jsonDecode(await response.stream.bytesToString())
|
||||
as Map<String, dynamic>;
|
||||
final aKOAuth = OAuth.fromJson(res);
|
||||
final aKOAuth = OAuth.fromJson(res)
|
||||
..expiresIn =
|
||||
DateTime.now()
|
||||
.add(Duration(seconds: res['expires_in']))
|
||||
.millisecondsSinceEpoch;
|
||||
final currentUser = await _getCurrentUser(aKOAuth.accessToken!);
|
||||
ref
|
||||
.read(tracksProvider(syncId: syncId).notifier)
|
||||
|
|
@ -72,68 +77,54 @@ class Kitsu extends _$Kitsu {
|
|||
}
|
||||
}
|
||||
|
||||
Future<Track?> addLib(Track track, bool isManga) async {
|
||||
final userId = _getUserId();
|
||||
final accessToken = _getAccessToken();
|
||||
var data = jsonEncode({
|
||||
'data': {
|
||||
'type': 'libraryEntries',
|
||||
'attributes': {
|
||||
'status': toKitsuStatus(track.status, isManga),
|
||||
'progress': track.lastChapterRead,
|
||||
},
|
||||
'relationships': {
|
||||
'user': {
|
||||
'data': {'id': userId, 'type': 'users'},
|
||||
},
|
||||
'media': {
|
||||
'data': {'id': track.mediaId, 'type': isManga ? 'manga' : 'anime'},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var response = await http.post(
|
||||
Uri.parse('${_baseUrl}library-entries'),
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.api+json',
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: data,
|
||||
Future<Track> update(Track track, bool isManga) async {
|
||||
final isNew = track.libraryId == null;
|
||||
final String? userId = isNew ? _getUserId() : null;
|
||||
final type = isManga ? 'manga' : 'anime';
|
||||
final url = Uri.parse(
|
||||
'${_baseUrl}library-entries${isNew ? "" : "/${track.libraryId}"}',
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
return await findLibItem(track, true);
|
||||
}
|
||||
|
||||
var jsonData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
track.libraryId = int.parse(jsonData['data']['id']);
|
||||
return track;
|
||||
}
|
||||
|
||||
Future<Track> updateLib(Track track, bool isManga) async {
|
||||
final accessToken = _getAccessToken();
|
||||
final data = jsonEncode({
|
||||
final headers = {
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
'Authorization': 'Bearer ${_getAccessToken()}',
|
||||
};
|
||||
final payload = jsonEncode({
|
||||
"data": {
|
||||
"type": "libraryEntries",
|
||||
"id": track.libraryId,
|
||||
if (!isNew) "id": track.libraryId,
|
||||
"attributes": {
|
||||
"status": toKitsuStatus(track.status, isManga),
|
||||
"progress": track.lastChapterRead,
|
||||
"ratingTwenty": _toKitsuScore(track.score!),
|
||||
"startedAt": _convertDate(track.startedReadingDate!),
|
||||
"finishedAt": _convertDate(track.finishedReadingDate!),
|
||||
if (!isNew) "ratingTwenty": _toKitsuScore(track.score!),
|
||||
if (!isNew) "startedAt": _convertDate(track.startedReadingDate!),
|
||||
if (!isNew) "finishedAt": _convertDate(track.finishedReadingDate!),
|
||||
},
|
||||
if (isNew)
|
||||
"relationships": {
|
||||
'user': {
|
||||
'data': {'id': userId, 'type': 'users'},
|
||||
},
|
||||
'media': {
|
||||
'data': {'id': track.mediaId, 'type': type},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await http.patch(
|
||||
Uri.parse('${_baseUrl}library-entries/${track.libraryId}'),
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
body: data,
|
||||
);
|
||||
if (isNew) {
|
||||
final response = await http.post(url, headers: headers, body: payload);
|
||||
if (response.statusCode != 200) {
|
||||
final found = await findLibItem(track, isManga);
|
||||
if (found == null) {
|
||||
throw Exception('Could not add $type entry for ${track.mediaId}');
|
||||
}
|
||||
track.libraryId = found.libraryId;
|
||||
return track;
|
||||
}
|
||||
final jsonData = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
track.libraryId = int.parse(jsonData['data']['id']);
|
||||
} else {
|
||||
await http.patch(url, headers: headers, body: payload);
|
||||
}
|
||||
return track;
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +132,7 @@ class Kitsu extends _$Kitsu {
|
|||
final accessToken = _getAccessToken();
|
||||
|
||||
final url = Uri.parse(_algoliaKeyUrl);
|
||||
final algoliaKeyResponse = await makeGetRequest(url, accessToken);
|
||||
final algoliaKeyResponse = await _makeGetRequest(url, accessToken);
|
||||
final key = json.decode(algoliaKeyResponse.body)["media"]["key"];
|
||||
final response = await http.post(
|
||||
Uri.parse(_algoliaUrl),
|
||||
|
|
@ -190,7 +181,7 @@ class Kitsu extends _$Kitsu {
|
|||
final url = Uri.parse(
|
||||
'${_baseUrl}library-entries?filter[${type}_id]=${track.libraryId}&filter[user_id]=$userId&include=$type',
|
||||
);
|
||||
Response response = await makeGetRequest(url, accessToken);
|
||||
Response response = await _makeGetRequest(url, accessToken);
|
||||
if (response.statusCode == 200) {
|
||||
final parsed = parseTrackResponse(response, track, type);
|
||||
if (parsed != null) return parsed;
|
||||
|
|
@ -198,15 +189,14 @@ class Kitsu extends _$Kitsu {
|
|||
return await getItem(track, type);
|
||||
}
|
||||
|
||||
Future<Response> makeGetRequest(Uri url, String accessToken) async {
|
||||
final response = await http.get(
|
||||
Future<Response> _makeGetRequest(Uri url, String accessToken) async {
|
||||
return await http.get(
|
||||
url,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
'Authorization': 'Bearer $accessToken',
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<Track?> getItem(Track track, String type) async {
|
||||
|
|
@ -214,7 +204,7 @@ class Kitsu extends _$Kitsu {
|
|||
final url = Uri.parse(
|
||||
'${_baseUrl}library-entries?filter[id]=${track.mediaId}&include=$type',
|
||||
);
|
||||
Response response = await makeGetRequest(url, accessToken);
|
||||
Response response = await _makeGetRequest(url, accessToken);
|
||||
if (response.statusCode == 200) {
|
||||
return parseTrackResponse(response, track, type);
|
||||
}
|
||||
|
|
@ -233,23 +223,23 @@ class Kitsu extends _$Kitsu {
|
|||
final included = jsonResponse['included'][0]["attributes"];
|
||||
final id = int.parse(obj["id"]);
|
||||
final totalChapter = type == 'manga' ? "chapterCount" : "episodeCount";
|
||||
track.mediaId = id;
|
||||
track.libraryId = id;
|
||||
track.syncId = syncId;
|
||||
track.trackingUrl = _mediaUrl(type, id);
|
||||
track.totalChapter = included[totalChapter] ?? 0;
|
||||
track.status = getKitsuTrackStatus(attributes["status"], type);
|
||||
track.score = ((attributes["ratingTwenty"] ?? 0) / 2).toInt();
|
||||
track.title = included["canonicalTitle"];
|
||||
track.lastChapterRead = attributes["progress"];
|
||||
track.startedReadingDate = _parseDate(attributes["startedAt"]);
|
||||
track.finishedReadingDate = _parseDate(attributes["finishedAt"]);
|
||||
return track;
|
||||
return track
|
||||
..mediaId = id
|
||||
..libraryId = id
|
||||
..syncId = syncId
|
||||
..trackingUrl = _mediaUrl(type, id)
|
||||
..totalChapter = included[totalChapter] ?? 0
|
||||
..status = getKitsuTrackStatus(attributes["status"], type)
|
||||
..score = ((attributes["ratingTwenty"] ?? 0) / 2).toInt()
|
||||
..title = included["canonicalTitle"]
|
||||
..lastChapterRead = attributes["progress"]
|
||||
..startedReadingDate = _parseDate(attributes["startedAt"])
|
||||
..finishedReadingDate = _parseDate(attributes["finishedAt"]);
|
||||
}
|
||||
|
||||
Future<(String, String)> _getCurrentUser(String accessToken) async {
|
||||
final url = Uri.parse('${_baseUrl}users?filter[self]=true');
|
||||
Response response = await makeGetRequest(url, accessToken);
|
||||
Response response = await _makeGetRequest(url, accessToken);
|
||||
final data = json.decode(response.body)['data'][0];
|
||||
return (
|
||||
data['id'].toString(),
|
||||
|
|
@ -286,7 +276,7 @@ class Kitsu extends _$Kitsu {
|
|||
};
|
||||
}
|
||||
|
||||
List<TrackStatus> kitsuStatusList(bool isManga) => [
|
||||
List<TrackStatus> statusList(bool isManga) => [
|
||||
isManga ? TrackStatus.reading : TrackStatus.watching,
|
||||
TrackStatus.completed,
|
||||
TrackStatus.onHold,
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@ class MyAnimeList extends _$MyAnimeList {
|
|||
String baseOAuthUrl = 'https://myanimelist.net/v1/oauth2';
|
||||
String baseApiUrl = 'https://api.myanimelist.net/v2';
|
||||
String codeVerifier = "";
|
||||
static final isDesktop = (Platform.isWindows || Platform.isLinux);
|
||||
String clientId =
|
||||
(Platform.isWindows || Platform.isLinux)
|
||||
isDesktop
|
||||
? '39e9be346b4e7dbcc59a98357e2f8472'
|
||||
: '0c9100ccd443ddb441a319a881180f7f';
|
||||
|
||||
|
|
@ -30,9 +31,7 @@ class MyAnimeList extends _$MyAnimeList {
|
|||
|
||||
Future<bool?> login() async {
|
||||
final callbackUrlScheme =
|
||||
(Platform.isWindows || Platform.isLinux)
|
||||
? 'http://localhost:43824'
|
||||
: 'mangayomi';
|
||||
isDesktop ? 'http://localhost:43824' : 'mangayomi';
|
||||
final loginUrl = _authUrl();
|
||||
|
||||
try {
|
||||
|
|
@ -66,7 +65,7 @@ class MyAnimeList extends _$MyAnimeList {
|
|||
}
|
||||
}
|
||||
|
||||
Future<String> _getAccesToken() async {
|
||||
Future<String> _getAccessToken() async {
|
||||
final track = ref.watch(tracksProvider(syncId: syncId));
|
||||
final mALOAuth = OAuth.fromJson(
|
||||
jsonDecode(track!.oAuth!) as Map<String, dynamic>,
|
||||
|
|
@ -101,86 +100,54 @@ class MyAnimeList extends _$MyAnimeList {
|
|||
return mALOAuth.accessToken!;
|
||||
}
|
||||
|
||||
Future<List<TrackSearch>> search(String query) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
Future<List<TrackSearch>> search(String query, isManga) async {
|
||||
final accessToken = await _getAccessToken();
|
||||
final url = Uri.parse(
|
||||
itemType == ItemType.manga ? '$baseApiUrl/manga' : '$baseApiUrl/anime',
|
||||
'$baseApiUrl/${isManga ? "manga" : "anime"}',
|
||||
).replace(queryParameters: {'q': query.trim(), 'nsfw': 'true'});
|
||||
final result = await http.get(
|
||||
url,
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
final result = await _makeGetRequest(url, accessToken);
|
||||
final res = jsonDecode(result.body) as Map<String, dynamic>;
|
||||
|
||||
List<int> mangaIds =
|
||||
res['data'] == null
|
||||
? []
|
||||
: (res['data'] as List).map((e) => e['node']["id"] as int).toList();
|
||||
List<TrackSearch> trackSearchResult = [];
|
||||
for (var mangaId in mangaIds) {
|
||||
final trackSearch =
|
||||
itemType == ItemType.manga
|
||||
? await getMangaDetails(mangaId, accessToken)
|
||||
: await getAnimeDetails(mangaId, accessToken);
|
||||
trackSearchResult.add(trackSearch);
|
||||
}
|
||||
final trackSearchResult = await Future.wait(
|
||||
mangaIds.map((id) => getDetails(id, accessToken, isManga)),
|
||||
);
|
||||
|
||||
return trackSearchResult
|
||||
.where((element) => !element.publishingType!.contains("novel"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<TrackSearch> getMangaDetails(int id, String accessToken) async {
|
||||
final url = Uri.parse('$baseApiUrl/manga/$id').replace(
|
||||
Future<TrackSearch> getDetails(
|
||||
int id,
|
||||
String accessToken,
|
||||
bool isManga,
|
||||
) async {
|
||||
final item = isManga ? "manga" : "anime";
|
||||
final contentUnit = isManga ? "num_chapters" : "num_episodes";
|
||||
final url = Uri.parse('$baseApiUrl/$item/$id').replace(
|
||||
queryParameters: {
|
||||
'fields':
|
||||
'id,title,synopsis,num_chapters,main_picture,status,media_type,start_date',
|
||||
'id,title,synopsis,$contentUnit,main_picture,status,media_type,start_date',
|
||||
},
|
||||
);
|
||||
|
||||
final result = await http.get(
|
||||
url,
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
final result = await _makeGetRequest(url, accessToken);
|
||||
final res = jsonDecode(result.body) as Map<String, dynamic>;
|
||||
|
||||
return TrackSearch(
|
||||
mediaId: res["id"],
|
||||
summary: res["synopsis"] ?? "",
|
||||
totalChapter: res["num_chapters"],
|
||||
totalChapter: res[contentUnit],
|
||||
coverUrl: res["main_picture"]["large"] ?? "",
|
||||
title: res["title"],
|
||||
startDate: res["start_date"] ?? "",
|
||||
publishingType: res["media_type"].toString().replaceAll("_", " "),
|
||||
publishingStatus: res["status"].toString().replaceAll("_", " "),
|
||||
trackingUrl: "https://myanimelist.net/manga/${res["id"]}",
|
||||
);
|
||||
}
|
||||
|
||||
Future<TrackSearch> getAnimeDetails(int id, String accessToken) async {
|
||||
final url = Uri.parse('$baseApiUrl/anime/$id').replace(
|
||||
queryParameters: {
|
||||
'fields':
|
||||
'id,title,synopsis,num_episodes,main_picture,status,media_type,start_date',
|
||||
},
|
||||
);
|
||||
|
||||
final result = await http.get(
|
||||
url,
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
final res = jsonDecode(result.body) as Map<String, dynamic>;
|
||||
|
||||
return TrackSearch(
|
||||
mediaId: res["id"],
|
||||
summary: res["synopsis"] ?? "",
|
||||
totalChapter: res["num_episodes"],
|
||||
coverUrl: res["main_picture"]["large"] ?? "",
|
||||
title: res["title"],
|
||||
startDate: res["start_date"] ?? "",
|
||||
publishingType: res["media_type"].toString().replaceAll("_", " "),
|
||||
publishingStatus: res["status"].toString().replaceAll("_", " "),
|
||||
trackingUrl: "https://myanimelist.net/anime/${res["id"]}",
|
||||
trackingUrl: "https://myanimelist.net/$item/${res["id"]}",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -207,61 +174,38 @@ class MyAnimeList extends _$MyAnimeList {
|
|||
return '$baseOAuthUrl/authorize?client_id=$clientId&code_challenge=$codeVerifier&response_type=code';
|
||||
}
|
||||
|
||||
TrackStatus _getMALTrackStatusManga(String status) {
|
||||
TrackStatus _getMALTrackStatus(String status, bool isManga) {
|
||||
return switch (status) {
|
||||
"reading" => TrackStatus.reading,
|
||||
"reading" when isManga => TrackStatus.reading,
|
||||
"watching" when !isManga => TrackStatus.watching,
|
||||
"completed" => TrackStatus.completed,
|
||||
"on_hold" => TrackStatus.onHold,
|
||||
"dropped" => TrackStatus.dropped,
|
||||
"plan_to_read" => TrackStatus.planToRead,
|
||||
_ => TrackStatus.rereading,
|
||||
"plan_to_read" when isManga => TrackStatus.planToRead,
|
||||
"plan_to_watch" when !isManga => TrackStatus.planToWatch,
|
||||
_ => isManga ? TrackStatus.reReading : TrackStatus.planToWatch,
|
||||
};
|
||||
}
|
||||
|
||||
TrackStatus _getMALTrackStatusAnime(String status) {
|
||||
return switch (status) {
|
||||
"watching" => TrackStatus.watching,
|
||||
"completed" => TrackStatus.completed,
|
||||
"on_hold" => TrackStatus.onHold,
|
||||
"dropped" => TrackStatus.dropped,
|
||||
_ => TrackStatus.planToWatch,
|
||||
};
|
||||
}
|
||||
|
||||
List<TrackStatus> myAnimeListStatusListManga = [
|
||||
TrackStatus.reading,
|
||||
List<TrackStatus> statusList(bool isManga) => [
|
||||
isManga ? TrackStatus.reading : TrackStatus.watching,
|
||||
TrackStatus.completed,
|
||||
TrackStatus.onHold,
|
||||
TrackStatus.dropped,
|
||||
TrackStatus.planToRead,
|
||||
TrackStatus.rereading,
|
||||
];
|
||||
List<TrackStatus> myAnimeListStatusListAnime = [
|
||||
TrackStatus.watching,
|
||||
TrackStatus.completed,
|
||||
TrackStatus.onHold,
|
||||
TrackStatus.dropped,
|
||||
TrackStatus.planToWatch,
|
||||
isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
|
||||
if (isManga) TrackStatus.reReading,
|
||||
];
|
||||
|
||||
String? toMyAnimeListStatusManga(TrackStatus status) {
|
||||
String? toMyAnimeListStatus(TrackStatus status, bool isManga) {
|
||||
return switch (status) {
|
||||
TrackStatus.reading => "reading",
|
||||
TrackStatus.reading when isManga => "reading",
|
||||
TrackStatus.watching when !isManga => "watching",
|
||||
TrackStatus.completed => "completed",
|
||||
TrackStatus.onHold => "on_hold",
|
||||
TrackStatus.dropped => "dropped",
|
||||
TrackStatus.planToRead => "plan_to_read",
|
||||
_ => "reading",
|
||||
};
|
||||
}
|
||||
|
||||
String? toMyAnimeListStatusAnime(TrackStatus status) {
|
||||
return switch (status) {
|
||||
TrackStatus.watching => "watching",
|
||||
TrackStatus.completed => "completed",
|
||||
TrackStatus.onHold => "on_hold",
|
||||
TrackStatus.dropped => "dropped",
|
||||
_ => "plan_to_watch",
|
||||
TrackStatus.planToRead when isManga => "plan_to_read",
|
||||
TrackStatus.planToWatch when !isManga => "plan_to_watch",
|
||||
_ => isManga ? "reading" : "plan_to_watch",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -280,70 +224,43 @@ class MyAnimeList extends _$MyAnimeList {
|
|||
}
|
||||
|
||||
Future<String> _getUserName(String accessToken) async {
|
||||
final response = await http.get(
|
||||
final response = await _makeGetRequest(
|
||||
Uri.parse('$baseApiUrl/users/@me'),
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
accessToken,
|
||||
);
|
||||
return jsonDecode(response.body)['name'];
|
||||
}
|
||||
|
||||
Future<Track> findManga(Track track) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
final uri = Uri.parse(
|
||||
itemType == ItemType.manga
|
||||
? '$baseApiUrl/manga/${track.mediaId}'
|
||||
: '$baseApiUrl/anime/${track.mediaId}',
|
||||
).replace(
|
||||
Future<Track> findLibItem(Track track, bool isManga) async {
|
||||
final type = isManga ? "manga" : "anime";
|
||||
final contentUnit = isManga ? 'num_chapters' : 'num_episodes';
|
||||
final accessToken = await _getAccessToken();
|
||||
final uri = Uri.parse('$baseApiUrl/$type/${track.mediaId}').replace(
|
||||
queryParameters: {
|
||||
'fields':
|
||||
itemType == ItemType.manga
|
||||
? 'num_chapters,my_list_status{start_date,finish_date}'
|
||||
: 'num_episodes,my_list_status{start_date,finish_date}',
|
||||
'fields': '$contentUnit,my_list_status{start_date,finish_date}',
|
||||
},
|
||||
);
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
final response = await _makeGetRequest(uri, accessToken);
|
||||
final mJson = jsonDecode(response.body);
|
||||
track.totalChapter =
|
||||
itemType == ItemType.manga
|
||||
? mJson['num_chapters'] ?? 0
|
||||
: mJson['num_episodes'] ?? 0;
|
||||
track.totalChapter = mJson[contentUnit] ?? 0;
|
||||
if (mJson['my_list_status'] != null) {
|
||||
track =
|
||||
itemType == ItemType.manga
|
||||
? _parseMangaItem(mJson["my_list_status"], track)
|
||||
: _parseAnimeItem(mJson["my_list_status"], track);
|
||||
track = _parseItem(mJson["my_list_status"], track, isManga);
|
||||
} else {
|
||||
track =
|
||||
itemType == ItemType.manga
|
||||
? await updateManga(track)
|
||||
: await updateAnime(track);
|
||||
track = await update(track, isManga);
|
||||
}
|
||||
return track;
|
||||
}
|
||||
|
||||
Track _parseMangaItem(Map<String, dynamic> mJson, Track track) {
|
||||
bool isRereading = mJson["is_rereading"] ?? false;
|
||||
Track _parseItem(Map<String, dynamic> mJson, Track track, bool isManga) {
|
||||
bool isRepeating =
|
||||
mJson[isManga ? "is_rereading" : "is_rewatching"] ?? false;
|
||||
track.status =
|
||||
isRereading
|
||||
? TrackStatus.rereading
|
||||
: _getMALTrackStatusManga(mJson["status"]);
|
||||
track.lastChapterRead = int.parse(mJson["num_chapters_read"].toString());
|
||||
track.score = int.parse(mJson["score"].toString());
|
||||
track.startedReadingDate = _parseDate(mJson["start_date"]);
|
||||
track.finishedReadingDate = _parseDate(mJson["finish_date"]);
|
||||
return track;
|
||||
}
|
||||
|
||||
Track _parseAnimeItem(Map<String, dynamic> mJson, Track track) {
|
||||
bool isReWatching = mJson["is_rewatching"] ?? false;
|
||||
track.status =
|
||||
isReWatching
|
||||
? TrackStatus.reWatching
|
||||
: _getMALTrackStatusAnime(mJson["status"]);
|
||||
track.lastChapterRead = int.parse(mJson["num_episodes_watched"].toString());
|
||||
isRepeating
|
||||
? (isManga ? TrackStatus.reReading : TrackStatus.reWatching)
|
||||
: _getMALTrackStatus(mJson["status"], isManga);
|
||||
track.lastChapterRead = int.parse(
|
||||
mJson[isManga ? "num_chapters_read" : "num_episodes_watched"].toString(),
|
||||
);
|
||||
track.score = int.parse(mJson["score"].toString());
|
||||
track.startedReadingDate = _parseDate(mJson["start_date"]);
|
||||
track.finishedReadingDate = _parseDate(mJson["finish_date"]);
|
||||
|
|
@ -357,14 +274,20 @@ class MyAnimeList extends _$MyAnimeList {
|
|||
return date.millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
Future<Track> updateAnime(Track track) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
Future<Track> update(Track track, bool isManga) async {
|
||||
final accessToken = await _getAccessToken();
|
||||
final formBody = {
|
||||
'status':
|
||||
(toMyAnimeListStatusAnime(track.status) ?? 'watching').toString(),
|
||||
'is_rewatching': (track.status == TrackStatus.reWatching).toString(),
|
||||
(toMyAnimeListStatus(track.status, isManga) ??
|
||||
(isManga ? 'reading' : 'watching'))
|
||||
.toString(),
|
||||
isManga ? 'is_rereading' : 'is_rewatching':
|
||||
(track.status ==
|
||||
(isManga ? TrackStatus.reReading : TrackStatus.reWatching))
|
||||
.toString(),
|
||||
'score': track.score.toString(),
|
||||
'num_watched_episodes': track.lastChapterRead.toString(),
|
||||
isManga ? 'num_chapters_read' : 'num_watched_episodes':
|
||||
track.lastChapterRead.toString(),
|
||||
if (track.startedReadingDate != null)
|
||||
'start_date': _convertToIsoDate(track.startedReadingDate),
|
||||
if (track.finishedReadingDate != null)
|
||||
|
|
@ -372,36 +295,22 @@ class MyAnimeList extends _$MyAnimeList {
|
|||
};
|
||||
final request = Request(
|
||||
'PUT',
|
||||
Uri.parse('$baseApiUrl/anime/${track.mediaId}/my_list_status'),
|
||||
Uri.parse(
|
||||
'$baseApiUrl/${isManga ? "manga" : "anime"}'
|
||||
'/${track.mediaId}/my_list_status',
|
||||
),
|
||||
);
|
||||
request.bodyFields = formBody;
|
||||
request.headers.addAll({'Authorization': 'Bearer $accessToken'});
|
||||
final response = await Client().send(request);
|
||||
final mJson = jsonDecode(await response.stream.bytesToString());
|
||||
return _parseAnimeItem(mJson, track);
|
||||
return _parseItem(mJson, track, isManga);
|
||||
}
|
||||
|
||||
Future<Track> updateManga(Track track) async {
|
||||
final accessToken = await _getAccesToken();
|
||||
final formBody = {
|
||||
'status':
|
||||
(toMyAnimeListStatusManga(track.status) ?? 'reading').toString(),
|
||||
'is_rereading': (track.status == TrackStatus.rereading).toString(),
|
||||
'score': track.score.toString(),
|
||||
'num_chapters_read': track.lastChapterRead.toString(),
|
||||
if (track.startedReadingDate != null)
|
||||
'start_date': _convertToIsoDate(track.startedReadingDate),
|
||||
if (track.finishedReadingDate != null)
|
||||
'finish_date': _convertToIsoDate(track.finishedReadingDate),
|
||||
};
|
||||
final request = Request(
|
||||
'PUT',
|
||||
Uri.parse('$baseApiUrl/manga/${track.mediaId}/my_list_status'),
|
||||
Future<Response> _makeGetRequest(Uri url, String accessToken) async {
|
||||
return await http.get(
|
||||
url,
|
||||
headers: {'Authorization': 'Bearer $accessToken'},
|
||||
);
|
||||
request.bodyFields = formBody;
|
||||
request.headers.addAll({'Authorization': 'Bearer $accessToken'});
|
||||
final response = await Client().send(request);
|
||||
final mJson = jsonDecode(await response.stream.bytesToString());
|
||||
return _parseMangaItem(mJson, track);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ String getTrackStatus(TrackStatus status, BuildContext context) {
|
|||
TrackStatus.onHold => l10n.on_hold,
|
||||
TrackStatus.dropped => l10n.dropped,
|
||||
TrackStatus.planToRead => l10n.plan_to_read,
|
||||
TrackStatus.rereading => l10n.re_reading,
|
||||
TrackStatus.reReading => l10n.re_reading,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ TrackStatus toTrackStatus(TrackStatus status, ItemType itemType, int syncId) {
|
|||
? switch (status) {
|
||||
TrackStatus.reading => TrackStatus.watching,
|
||||
TrackStatus.planToRead => TrackStatus.planToWatch,
|
||||
TrackStatus.rereading => TrackStatus.reWatching,
|
||||
TrackStatus.reReading => TrackStatus.reWatching,
|
||||
_ => status,
|
||||
}
|
||||
: status;
|
||||
|
|
|
|||
Loading…
Reference in a new issue