added two-way tracking

This commit is contained in:
Schnitzel5 2025-06-09 02:26:31 +02:00
commit 5dff4a1aa3
27 changed files with 1332 additions and 145 deletions

View file

@ -474,5 +474,7 @@
"no_next_chapter": "No next chapter",
"you_have_finished_reading": "You have finished reading",
"return_to_the_list_of_chapters": "Return to the list of chapters",
"hwdec": "Hardware Decoder"
"hwdec": "Hardware Decoder",
"track_library_add": "Add to local library",
"track_library_add_confirm": "Add tracked item to local library"
}

View file

@ -2969,6 +2969,18 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Hardware Decoder'**
String get hwdec;
/// No description provided for @track_library_add.
///
/// In en, this message translates to:
/// **'Add to local library'**
String get track_library_add;
/// No description provided for @track_library_add_confirm.
///
/// In en, this message translates to:
/// **'Add tracked item to local library'**
String get track_library_add_confirm;
}
class _AppLocalizationsDelegate

View file

@ -1517,4 +1517,10 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1528,4 +1528,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1517,4 +1517,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1534,6 +1534,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}
/// The translations for Spanish Castilian, as used in Latin America and the Caribbean (`es_419`).

View file

@ -1539,4 +1539,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1523,4 +1523,10 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1533,4 +1533,10 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1531,6 +1531,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}
/// The translations for Portuguese, as used in Brazil (`pt_BR`).

View file

@ -1533,4 +1533,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1518,4 +1518,10 @@ class AppLocalizationsTh extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1524,4 +1524,10 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -1486,4 +1486,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get hwdec => 'Hardware Decoder';
@override
String get track_library_add => 'Add to local library';
@override
String get track_library_add_confirm => 'Add tracked item to local library';
}

View file

@ -5,6 +5,7 @@ import 'package:app_links/app_links.dart';
import 'package:bot_toast/bot_toast.dart';
import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -117,6 +118,7 @@ class _MyAppState extends ConsumerState<MyApp> {
routerDelegate: router.routerDelegate,
routeInformationProvider: router.routeInformationProvider,
title: 'MangaYomi',
scrollBehavior: AllowDesktopScrollBehavior(),
);
}
@ -230,3 +232,11 @@ class _MyAppState extends ConsumerState<MyApp> {
return true;
}
}
class AllowDesktopScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
};
}

View file

@ -329,6 +329,42 @@ class _MainScreenState extends ConsumerState<MainScreen> {
),
);
}
if (dest.contains("/trackerLibrary/anilist")) {
destinations[dest.indexOf(
"/trackerLibrary/anilist",
)] = NavigationRailDestination(
selectedIcon: const Icon(Icons.account_tree),
icon: const Icon(Icons.account_tree_outlined),
label: Padding(
padding: const EdgeInsets.only(top: 5),
child: Text("AL"),
),
);
}
if (dest.contains("/trackerLibrary/kitsu")) {
destinations[dest.indexOf(
"/trackerLibrary/kitsu",
)] = NavigationRailDestination(
selectedIcon: const Icon(Icons.account_tree),
icon: const Icon(Icons.account_tree_outlined),
label: Padding(
padding: const EdgeInsets.only(top: 5),
child: Text("Kitsu"),
),
);
}
if (dest.contains("/trackerLibrary/mal")) {
destinations[dest.indexOf(
"/trackerLibrary/mal",
)] = NavigationRailDestination(
selectedIcon: const Icon(Icons.account_tree),
icon: const Icon(Icons.account_tree_outlined),
label: Padding(
padding: const EdgeInsets.only(top: 5),
child: Text("MAL"),
),
);
}
final result = destinations.nonNulls.toList();
_desktopDestinationsCache[cacheKey] = result;
@ -412,6 +448,31 @@ class _MainScreenState extends ConsumerState<MainScreen> {
label: l10n.more,
);
}
if (dest.contains("/trackerLibrary/anilist")) {
destinations[dest.indexOf(
"/trackerLibrary/anilist",
)] = NavigationDestination(
selectedIcon: const Icon(Icons.account_tree),
icon: const Icon(Icons.account_tree_outlined),
label: "AL",
);
}
if (dest.contains("/trackerLibrary/kitsu")) {
destinations[dest.indexOf(
"/trackerLibrary/kitsu",
)] = NavigationDestination(
selectedIcon: const Icon(Icons.account_tree),
icon: const Icon(Icons.account_tree_outlined),
label: "Kitsu",
);
}
if (dest.contains("/trackerLibrary/mal")) {
destinations[dest.indexOf("/trackerLibrary/mal")] = NavigationDestination(
selectedIcon: const Icon(Icons.account_tree),
icon: const Icon(Icons.account_tree_outlined),
label: "MAL",
);
}
_mobileDestinationsCache[cacheKey] = destinations;
return destinations;
@ -531,6 +592,9 @@ class _TabletLayout extends StatelessWidget {
'/updates',
'/browse',
'/more',
'/trackerLibrary/anilist',
'/trackerLibrary/kitsu',
'/trackerLibrary/mal',
};
return (location == null || validLocations.contains(location)) ? 100 : 0;
@ -598,6 +662,9 @@ class _MobileBottomNavigation extends StatelessWidget {
'/updates',
'/browse',
'/more',
'/trackerLibrary/anilist',
'/trackerLibrary/kitsu',
'/trackerLibrary/mal',
};
return (location == null || validLocations.contains(location)) ? null : 0;

View file

@ -138,4 +138,21 @@ class TrackState extends _$TrackState {
final tracker = getNotifier(syncId);
return await tracker.search(query, _isManga);
}
Future<List<TrackSearch>?> fetchGeneralData({
String rankingType = "airing",
}) async {
final syncId = track!.syncId!;
final tracker = getNotifier(syncId);
return await tracker.fetchGeneralData(
isManga: _isManga,
rankingType: rankingType,
);
}
Future<List<TrackSearch>?> fetchUserData() async {
final syncId = track!.syncId!;
final tracker = getNotifier(syncId);
return await tracker.fetchUserData(isManga: _isManga);
}
}

View file

@ -6,7 +6,7 @@ part of 'track_state_providers.dart';
// RiverpodGenerator
// **************************************************************************
String _$trackStateHash() => r'4d31a8a939412cabd800f9747bff7a1ac0ef1996';
String _$trackStateHash() => r'b10c02c2e50eb1f044a76560093a8dcf232487c5';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -1,17 +1,22 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:isar/isar.dart';
import 'package:mangayomi/eval/model/m_manga.dart';
import 'package:mangayomi/eval/model/m_pages.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/category.dart';
import 'package:mangayomi/models/changed.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/history.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/track_search.dart';
import 'package:mangayomi/models/update.dart';
import 'package:mangayomi/modules/manga/detail/providers/isar_providers.dart';
import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart';
import 'package:mangayomi/modules/manga/detail/providers/update_manga_detail_providers.dart';
import 'package:mangayomi/modules/manga/detail/widgets/chapter_filter_list_tile_widget.dart';
import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/models/source.dart';
@ -28,7 +33,8 @@ import 'package:super_sliver_list/super_sliver_list.dart';
class MigrationScreen extends ConsumerStatefulWidget {
final Manga manga;
const MigrationScreen({required this.manga, super.key});
final TrackSearch? trackSearch;
const MigrationScreen({required this.manga, this.trackSearch, super.key});
@override
ConsumerState<MigrationScreen> createState() => _MigrationScreenScreenState();
@ -48,7 +54,11 @@ class _MigrationScreenScreenState extends ConsumerState<MigrationScreen> {
final l10n = l10nLocalizations(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.migrate)),
appBar: AppBar(
title: Text(
widget.trackSearch == null ? l10n.migrate : l10n.track_library_add,
),
),
body: widget.manga.name != null && widget.manga.author != null
? SuperListView.builder(
itemCount: sourceList.length,
@ -61,6 +71,7 @@ class _MigrationScreenScreenState extends ConsumerState<MigrationScreen> {
query: widget.manga.name ?? widget.manga.author ?? "",
manga: widget.manga,
source: source,
trackSearch: widget.trackSearch,
),
);
},
@ -73,6 +84,7 @@ class _MigrationScreenScreenState extends ConsumerState<MigrationScreen> {
class MigrationSourceSearchScreen extends StatefulWidget {
final String query;
final Manga manga;
final TrackSearch? trackSearch;
final Source source;
const MigrationSourceSearchScreen({
@ -80,6 +92,7 @@ class MigrationSourceSearchScreen extends StatefulWidget {
required this.query,
required this.manga,
required this.source,
this.trackSearch,
});
@override
@ -158,6 +171,7 @@ class _MigrationSourceSearchScreenState
oldManga: widget.manga,
manga: pages!.list[index],
source: widget.source,
trackSearch: widget.trackSearch,
);
},
);
@ -177,12 +191,14 @@ class MigrationMangaGlobalImageCard extends ConsumerStatefulWidget {
final Manga oldManga;
final MManga manga;
final Source source;
final TrackSearch? trackSearch;
const MigrationMangaGlobalImageCard({
super.key,
required this.oldManga,
required this.manga,
required this.source,
this.trackSearch,
});
@override
@ -306,7 +322,11 @@ class _MigrationMangaGlobalImageCardState
context: context,
builder: (ctx) {
return AlertDialog(
title: Text(l10n.migrate_confirm),
title: Text(
widget.trackSearch == null
? l10n.migrate_confirm
: l10n.track_library_add_confirm,
),
content: preview.chapters != null
? SizedBox(
height: ctx.height(0.5),
@ -385,133 +405,14 @@ class _MigrationMangaGlobalImageCardState
Consumer(
builder: (context, ref, child) => TextButton(
onPressed: () async {
String? historyChapter;
String? historyDate;
List<Chapter> chaptersProgress = [];
isar.writeTxnSync(() {
final histories = isar.historys
.filter()
.mangaIdEqualTo(widget.oldManga.id)
.sortByDate()
.findAllSync();
historyChapter = _extractChapterNumber(
histories.lastOrNull?.chapter.value?.name ??
"",
);
historyDate = histories.lastOrNull?.date;
for (var history in histories) {
isar.historys.deleteSync(history.id!);
ref
.read(
synchingProvider(syncId: 1).notifier,
)
.addChangedPart(
ActionType.removeHistory,
history.id,
"{}",
false,
);
if (widget.trackSearch == null) {
await _migrateManga(preview);
if (ctx.mounted) {
Navigator.pop(ctx);
Navigator.pop(ctx);
}
for (var chapter in widget.oldManga.chapters) {
chaptersProgress.add(chapter);
isar.updates
.filter()
.mangaIdEqualTo(chapter.mangaId)
.chapterNameEqualTo(chapter.name)
.deleteAllSync();
isar.chapters.deleteSync(chapter.id!);
ref
.read(
synchingProvider(syncId: 1).notifier,
)
.addChangedPart(
ActionType.removeChapter,
chapter.id,
"{}",
false,
);
}
widget.oldManga.name = widget.manga.name;
widget.oldManga.link = widget.manga.link;
widget.oldManga.imageUrl =
widget.manga.imageUrl;
widget.oldManga.lang = widget.source.lang;
widget.oldManga.source = widget.source.name;
widget.oldManga.artist = preview.artist;
widget.oldManga.author = preview.author;
widget.oldManga.status =
preview.status ?? widget.oldManga.status;
widget.oldManga.description =
preview.description;
widget.oldManga.genre = preview.genre;
isar.mangas.putSync(widget.oldManga);
ref
.read(synchingProvider(syncId: 1).notifier)
.addChangedPart(
ActionType.updateItem,
widget.oldManga.id,
widget.oldManga.toJson(),
false,
);
});
await ref.read(
updateMangaDetailProvider(
mangaId: widget.oldManga.id,
isInit: false,
).future,
);
isar.writeTxnSync(() {
for (var oldChapter in chaptersProgress) {
final chapter = isar.chapters
.filter()
.mangaIdEqualTo(widget.oldManga.id)
.nameContains(
_extractChapterNumber(
oldChapter.name ?? "",
) ??
".....",
caseSensitive: false,
)
.findFirstSync();
if (chapter != null) {
chapter.isBookmarked =
oldChapter.isBookmarked;
chapter.lastPageRead =
oldChapter.lastPageRead;
chapter.isRead = oldChapter.isRead;
isar.chapters.putSync(chapter);
}
}
final chapter = isar.chapters
.filter()
.mangaIdEqualTo(widget.oldManga.id)
.nameContains(
historyChapter ?? ".....",
caseSensitive: false,
)
.findFirstSync();
if (chapter != null) {
isar.historys.putSync(
History(
mangaId: widget.oldManga.id,
date:
historyDate ??
DateTime.now().millisecondsSinceEpoch
.toString(),
itemType: widget.oldManga.itemType,
chapterId: chapter.id,
)..chapter.value = chapter,
);
}
});
ref.invalidate(
getMangaDetailStreamProvider(
mangaId: widget.oldManga.id!,
),
);
if (ctx.mounted) {
Navigator.pop(ctx);
Navigator.pop(ctx);
} else {
await _addTrackManga(context);
}
},
child: Text(l10n.ok),
@ -527,6 +428,243 @@ class _MigrationMangaGlobalImageCardState
});
}
Future<void> _addTrackManga(BuildContext context) async {
List<int> categoryIds = [];
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
final l10n = l10nLocalizations(context)!;
return AlertDialog(
title: Text(l10n.set_categories),
content: SizedBox(
width: context.width(0.8),
child: StreamBuilder(
stream: isar.categorys
.filter()
.idIsNotNull()
.and()
.forItemTypeEqualTo(widget.oldManga.itemType)
.watch(fireImmediately: true),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
final entries = snapshot.data!;
return SuperListView.builder(
shrinkWrap: true,
itemCount: entries.length,
itemBuilder: (context, index) {
return ListTileChapterFilter(
label: entries[index].name!,
onTap: () {
setState(() {
if (categoryIds.contains(entries[index].id)) {
categoryIds.remove(entries[index].id);
} else {
categoryIds.add(entries[index].id!);
}
});
},
type: categoryIds.contains(entries[index].id)
? 1
: 0,
);
},
);
}
return Container();
},
),
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () {
context.push(
"/categories",
extra: (
true,
widget.oldManga.itemType == ItemType.manga
? 0
: widget.oldManga.itemType == ItemType.anime
? 1
: 2,
),
);
Navigator.pop(context);
},
child: Text(l10n.edit),
),
Row(
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(l10n.cancel),
),
const SizedBox(width: 15),
TextButton(
onPressed: () async {
final model = widget.manga;
final manga = Manga(
name: model.name,
artist: model.artist,
author: model.author,
description: model.description,
imageUrl: model.imageUrl,
link: model.link,
genre: model.genre,
status: model.status ?? Status.unknown,
source: widget.source.name,
lang: widget.source.lang,
itemType: widget.oldManga.itemType,
favorite: true,
categories: categoryIds,
dateAdded: DateTime.now().millisecondsSinceEpoch,
);
int mangaId = -1;
isar.writeTxnSync(() {
mangaId = isar.mangas.putSync(manga);
ref
.read(synchingProvider(syncId: 1).notifier)
.addChangedPart(
ActionType.addItem,
manga.id,
manga.toJson(),
false,
);
});
if (mangaId != -1) {
await ref
.read(
trackStateProvider(
track: null,
itemType: widget.oldManga.itemType,
).notifier,
)
.setTrackSearch(
widget.trackSearch!,
mangaId,
widget.trackSearch!.syncId!,
);
}
if (context.mounted) {
Navigator.pop(context);
Navigator.pop(context);
}
},
child: Text(l10n.ok),
),
],
),
],
),
],
);
},
);
},
);
}
Future<void> _migrateManga(MManga preview) async {
String? historyChapter;
String? historyDate;
List<Chapter> chaptersProgress = [];
isar.writeTxnSync(() {
final histories = isar.historys
.filter()
.mangaIdEqualTo(widget.oldManga.id)
.sortByDate()
.findAllSync();
historyChapter = _extractChapterNumber(
histories.lastOrNull?.chapter.value?.name ?? "",
);
historyDate = histories.lastOrNull?.date;
for (var history in histories) {
isar.historys.deleteSync(history.id!);
ref
.read(synchingProvider(syncId: 1).notifier)
.addChangedPart(ActionType.removeHistory, history.id, "{}", false);
}
for (var chapter in widget.oldManga.chapters) {
chaptersProgress.add(chapter);
isar.updates
.filter()
.mangaIdEqualTo(chapter.mangaId)
.chapterNameEqualTo(chapter.name)
.deleteAllSync();
isar.chapters.deleteSync(chapter.id!);
ref
.read(synchingProvider(syncId: 1).notifier)
.addChangedPart(ActionType.removeChapter, chapter.id, "{}", false);
}
widget.oldManga.name = widget.manga.name;
widget.oldManga.link = widget.manga.link;
widget.oldManga.imageUrl = widget.manga.imageUrl;
widget.oldManga.lang = widget.source.lang;
widget.oldManga.source = widget.source.name;
widget.oldManga.artist = preview.artist;
widget.oldManga.author = preview.author;
widget.oldManga.status = preview.status ?? widget.oldManga.status;
widget.oldManga.description = preview.description;
widget.oldManga.genre = preview.genre;
isar.mangas.putSync(widget.oldManga);
ref
.read(synchingProvider(syncId: 1).notifier)
.addChangedPart(
ActionType.updateItem,
widget.oldManga.id,
widget.oldManga.toJson(),
false,
);
});
await ref.read(
updateMangaDetailProvider(
mangaId: widget.oldManga.id,
isInit: false,
).future,
);
isar.writeTxnSync(() {
for (var oldChapter in chaptersProgress) {
final chapter = isar.chapters
.filter()
.mangaIdEqualTo(widget.oldManga.id)
.nameContains(
_extractChapterNumber(oldChapter.name ?? "") ?? ".....",
caseSensitive: false,
)
.findFirstSync();
if (chapter != null) {
chapter.isBookmarked = oldChapter.isBookmarked;
chapter.lastPageRead = oldChapter.lastPageRead;
chapter.isRead = oldChapter.isRead;
isar.chapters.putSync(chapter);
}
}
final chapter = isar.chapters
.filter()
.mangaIdEqualTo(widget.oldManga.id)
.nameContains(historyChapter ?? ".....", caseSensitive: false)
.findFirstSync();
if (chapter != null) {
isar.historys.putSync(
History(
mangaId: widget.oldManga.id,
date:
historyDate ?? DateTime.now().millisecondsSinceEpoch.toString(),
itemType: widget.oldManga.itemType,
chapterId: chapter.id,
)..chapter.value = chapter,
);
}
});
ref.invalidate(getMangaDetailStreamProvider(mangaId: widget.oldManga.id!));
}
String? _extractChapterNumber(String chapterName) {
return RegExp(
r'\s*(\d+\.\d+)\s*',

View file

@ -25,6 +25,9 @@ final navigationItems = {
"/history": "History",
"/browse": "Browse",
"/more": "More",
"/trackerLibrary/anilist": "AL",
"/trackerLibrary/kitsu": "Kitsu",
"/trackerLibrary/mal": "MAL",
};
class SettingsSection extends StatelessWidget {

View file

@ -149,18 +149,31 @@ class FullScreenReaderState extends _$FullScreenReaderState {
@riverpod
class NavigationOrderState extends _$NavigationOrderState {
final items = [
'/MangaLibrary',
'/AnimeLibrary',
'/NovelLibrary',
'/updates',
'/history',
'/browse',
'/more',
'/trackerLibrary/anilist',
'/trackerLibrary/kitsu',
'/trackerLibrary/mal',
];
@override
List<String> build() {
return isar.settings.getSync(227)!.navigationOrder ??
[
'/MangaLibrary',
'/AnimeLibrary',
'/NovelLibrary',
'/updates',
'/history',
'/browse',
'/more',
];
return _checkMissingItems(
isar.settings.getSync(227)!.navigationOrder?.toList() ?? [],
);
}
List<String> _checkMissingItems(List<String> navigationOrder) {
navigationOrder.addAll(
items.where((e) => !navigationOrder.contains(e)).toList(),
);
return navigationOrder;
}
void set(List<String> values) {
@ -176,7 +189,12 @@ class NavigationOrderState extends _$NavigationOrderState {
class HideItemsState extends _$HideItemsState {
@override
List<String> build() {
return isar.settings.getSync(227)!.hideItems ?? [];
return isar.settings.getSync(227)!.hideItems ??
[
'/trackerLibrary/anilist',
'/trackerLibrary/kitsu',
'/trackerLibrary/mal',
];
}
void set(List<String> values) {

View file

@ -158,7 +158,7 @@ final fullScreenReaderStateProvider =
typedef _$FullScreenReaderState = AutoDisposeNotifier<bool>;
String _$navigationOrderStateHash() =>
r'f1da55a7687995d136a6580d3f63f9b1b32a6ae8';
r'f300869743afaccfd47210115f341d25fec522bb';
/// See also [NavigationOrderState].
@ProviderFor(NavigationOrderState)
@ -174,7 +174,7 @@ final navigationOrderStateProvider =
);
typedef _$NavigationOrderState = AutoDisposeNotifier<List<String>>;
String _$hideItemsStateHash() => r'b4a467e66f6a1f9b36e4b201a10b771e0dae6a80';
String _$hideItemsStateHash() => r'6844a05786f6c547a7cba261f742e82d871b6cb1';
/// See also [HideItemsState].
@ProviderFor(HideItemsState)

View file

@ -0,0 +1,387 @@
// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:isar/isar.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/track.dart';
import 'package:mangayomi/models/track_search.dart';
import 'package:mangayomi/modules/manga/detail/providers/track_state_providers.dart';
import 'package:mangayomi/modules/widgets/bottom_text_widget.dart';
import 'package:mangayomi/providers/l10n_providers.dart';
import 'package:mangayomi/utils/cached_network.dart';
import 'package:mangayomi/utils/constant.dart';
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
enum TrackerProviders {
myAnimeList(syncId: 1, name: "MAL"),
anilist(syncId: 2, name: "AL"),
kitsu(syncId: 3, name: "Kitsu"),
trakt(syncId: 4, name: "Trakt");
const TrackerProviders({required this.syncId, required this.name});
final int syncId;
final String name;
}
class TrackLibrarySection {
String name;
Future<List<TrackSearch>?> Function() func;
ItemType itemType;
TrackLibrarySection({
required this.name,
required this.func,
this.itemType = ItemType.manga,
});
}
class TrackerLibraryScreen extends ConsumerStatefulWidget {
final TrackerProviders trackerProvider;
final String? presetInput;
const TrackerLibraryScreen({
required this.trackerProvider,
required this.presetInput,
super.key,
});
@override
ConsumerState<TrackerLibraryScreen> createState() =>
_TrackerLibraryScreenState();
}
class _TrackerLibraryScreenState extends ConsumerState<TrackerLibraryScreen> {
@override
Widget build(BuildContext context) {
final sections = [
TrackLibrarySection(
name: "Airing Anime",
func: fetchGeneralData(ItemType.anime),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Popular Anime",
func: fetchGeneralData(ItemType.anime, rankingType: "bypopularity"),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Upcoming Anime",
func: fetchGeneralData(ItemType.anime, rankingType: "upcoming"),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Continue watching",
func: fetchUserData(ItemType.anime),
itemType: ItemType.anime,
),
TrackLibrarySection(
name: "Popular Manga",
func: fetchGeneralData(ItemType.manga, rankingType: "bypopularity"),
),
TrackLibrarySection(
name: "Top Manga",
func: fetchGeneralData(ItemType.manga, rankingType: "manga"),
),
TrackLibrarySection(
name: "Top Manhwa",
func: fetchGeneralData(ItemType.manga, rankingType: "manhwa"),
),
TrackLibrarySection(
name: "Top Manhua ",
func: fetchGeneralData(ItemType.manga, rankingType: "manhua"),
),
TrackLibrarySection(
name: "Continue reading",
func: fetchUserData(ItemType.manga),
),
];
return Scaffold(
appBar: AppBar(title: Text(widget.trackerProvider.name)),
body: Padding(
padding: const EdgeInsets.all(15),
child: SuperListView.builder(
itemCount: sections.length,
extentPrecalculationPolicy: SuperPrecalculationPolicy(),
itemBuilder: (context, index) {
final section = sections[index];
return SizedBox(
height: 260,
child: TrackerSectionScreen(section: section),
);
},
),
),
);
}
Future<List<TrackSearch>?> Function() fetchGeneralData(
ItemType itemType, {
String rankingType = "airing",
}) {
return () async => await ref
.read(
trackStateProvider(
track: Track(
syncId: widget.trackerProvider.syncId,
status: TrackStatus.completed,
),
itemType: itemType,
).notifier,
)
.fetchGeneralData(rankingType: rankingType);
}
Future<List<TrackSearch>?> Function() fetchUserData(ItemType itemType) {
return () async => await ref
.read(
trackStateProvider(
track: Track(
syncId: widget.trackerProvider.syncId,
status: TrackStatus.completed,
),
itemType: itemType,
).notifier,
)
.fetchUserData();
}
}
class TrackerSectionScreen extends StatefulWidget {
final TrackLibrarySection section;
const TrackerSectionScreen({super.key, required this.section});
@override
State<TrackerSectionScreen> createState() => _TrackerSectionScreenState();
}
class _TrackerSectionScreenState extends State<TrackerSectionScreen> {
@override
void initState() {
super.initState();
_init();
}
String _errorMessage = "";
bool _isLoading = true;
List<TrackSearch> tracks = [];
_init() async {
try {
_errorMessage = "";
tracks = await widget.section.func() ?? [];
if (mounted) {
setState(() {
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final l10n = l10nLocalizations(context)!;
return Scaffold(
body: SizedBox(
height: 260,
child: Column(
children: [
ListTile(dense: true, title: Text(widget.section.name)),
Flexible(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: Builder(
builder: (context) {
if (_errorMessage.isNotEmpty) {
return Center(child: Text(_errorMessage));
}
if (tracks.isNotEmpty) {
return SuperListView.builder(
extentPrecalculationPolicy:
SuperPrecalculationPolicy(),
scrollDirection: Axis.horizontal,
itemCount: tracks.length,
itemBuilder: (context, index) {
return TrackerLibraryImageCard(
track: tracks[index],
itemType: widget.section.itemType,
);
},
);
}
return Center(child: Text(l10n.no_result));
},
),
),
],
),
),
);
}
}
class TrackerLibraryImageCard extends ConsumerStatefulWidget {
final TrackSearch track;
final ItemType itemType;
const TrackerLibraryImageCard({
super.key,
required this.track,
required this.itemType,
});
@override
ConsumerState<TrackerLibraryImageCard> createState() =>
_TrackerLibraryImageCardState();
}
class _TrackerLibraryImageCardState
extends ConsumerState<TrackerLibraryImageCard>
with AutomaticKeepAliveClientMixin<TrackerLibraryImageCard> {
@override
Widget build(BuildContext context) {
super.build(context);
final trackData = widget.track;
return GestureDetector(
onTap: () => _pushMigrationScreen(context),
child: StreamBuilder(
stream: isar.mangas
.filter()
.itemTypeEqualTo(widget.itemType)
.nameEqualTo(trackData.title)
.watch(fireImmediately: true),
builder: (context, snapshot) {
final hasData = snapshot.hasData && snapshot.data!.isNotEmpty;
return Padding(
padding: const EdgeInsets.only(left: 10),
child: Stack(
children: [
SizedBox(
width: 110,
child: Column(
children: [
Builder(
builder: (context) {
if (hasData &&
snapshot.data!.first.customCoverImage != null) {
return Image.memory(
snapshot.data!.first.customCoverImage
as Uint8List,
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(5),
child: cachedNetworkImage(
imageUrl: toImgUrl(
hasData
? snapshot
.data!
.first
.customCoverFromTracker ??
snapshot.data!.first.imageUrl ??
""
: trackData.coverUrl ?? "",
),
width: 110,
height: 150,
fit: BoxFit.cover,
),
);
},
),
BottomTextWidget(
fontSize: 12.0,
text: trackData.title!,
isLoading: true,
textColor: Theme.of(context).textTheme.bodyLarge!.color,
isComfortableGrid: true,
),
],
),
),
Container(
width: 110,
height: 150,
color: hasData && snapshot.data!.first.favorite!
? Colors.black.withValues(alpha: 0.7)
: null,
),
if (hasData && snapshot.data!.first.favorite!)
Positioned(
top: 0,
left: 0,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.collections_bookmark,
color: context.primaryColor,
),
),
),
Positioned(
bottom: 0,
left: 0,
child: Text.rich(
TextSpan(
children: [
WidgetSpan(
child: Icon(Icons.star, color: context.primaryColor),
),
TextSpan(text: " ${trackData.score ?? "?"}"),
],
),
),
),
],
),
);
},
),
);
}
void _pushMigrationScreen(BuildContext context) {
context.push(
"/migrate/tracker",
extra: (
Manga(
name: widget.track.title,
itemType: widget.itemType,
source: null,
author: "",
artist: null,
genre: [],
imageUrl: null,
lang: null,
link: null,
status: Status.unknown,
description: null,
),
widget.track,
),
);
}
@override
bool get wantKeepAlive => true;
}
class SuperPrecalculationPolicy extends ExtentPrecalculationPolicy {
@override
bool shouldPrecalculateExtents(ExtentPrecalculationContext context) {
return context.numberOfItems < 100;
}
}

View file

@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/source.dart';
import 'package:mangayomi/models/track_preference.dart';
import 'package:mangayomi/models/track_search.dart';
import 'package:mangayomi/modules/anime/anime_player_view.dart';
import 'package:mangayomi/modules/browse/extension/edit_code.dart';
import 'package:mangayomi/modules/browse/extension/extension_detail.dart';
@ -18,6 +19,7 @@ import 'package:mangayomi/modules/more/settings/browse/source_repositories.dart'
import 'package:mangayomi/modules/more/settings/reader/providers/reader_state_provider.dart';
import 'package:mangayomi/modules/more/statistics/statistics_screen.dart';
import 'package:mangayomi/modules/novel/novel_reader_view.dart';
import 'package:mangayomi/modules/tracker_library/tracker_library_screen.dart';
import 'package:mangayomi/modules/updates/updates_screen.dart';
import 'package:mangayomi/modules/more/categories/categories_screen.dart';
import 'package:mangayomi/modules/more/settings/downloads/downloads_screen.dart';
@ -122,6 +124,27 @@ class RouterNotifier extends ChangeNotifier {
builder: (id) =>
LibraryScreen(itemType: ItemType.novel, presetInput: id),
),
_genericRoute<String?>(
name: "trackerLibrary/anilist",
builder: (id) => TrackerLibraryScreen(
trackerProvider: TrackerProviders.anilist,
presetInput: id,
),
),
_genericRoute<String?>(
name: "trackerLibrary/kitsu",
builder: (id) => TrackerLibraryScreen(
trackerProvider: TrackerProviders.kitsu,
presetInput: id,
),
),
_genericRoute<String?>(
name: "trackerLibrary/mal",
builder: (id) => TrackerLibraryScreen(
trackerProvider: TrackerProviders.myAnimeList,
presetInput: id,
),
),
_genericRoute(name: "history", child: const HistoryScreen()),
_genericRoute(name: "updates", child: const UpdatesScreen()),
_genericRoute(name: "browse", child: const BrowseScreen()),
@ -208,6 +231,10 @@ class RouterNotifier extends ChangeNotifier {
name: "migrate",
builder: (manga) => MigrationScreen(manga: manga),
),
_genericRoute<(Manga, TrackSearch)>(
name: "migrate/tracker",
builder: (data) => MigrationScreen(manga: data.$1, trackSearch: data.$2),
),
];
GoRoute _genericRoute<T>({

View file

@ -149,6 +149,112 @@ class MyAnimeList extends _$MyAnimeList {
);
}
Future<List<TrackSearch>> fetchGeneralData({
bool isManga = true,
String rankingType =
"airing", // bypopularity, tv, upcoming - all, manga, manhwa, manhua
}) async {
final accessToken = await _getAccessToken();
final item = isManga ? "manga" : "anime";
final contentUnit = isManga ? "num_chapters" : "num_episodes";
final url = Uri.parse('$baseApiUrl/$item/ranking').replace(
queryParameters: {
'ranking_type': rankingType,
'limit': '15',
'fields':
'id,title,synopsis,$contentUnit,main_picture,status,media_type,start_date,mean',
},
);
final result = await _makeGetRequest(url, accessToken);
final res = jsonDecode(result.body) as Map<String, dynamic>;
return res['data'] == null
? []
: (res['data'] as List)
.map(
(e) => TrackSearch(
mediaId: e["node"]["id"],
summary: e["node"]["synopsis"] ?? "",
totalChapter: e["node"][contentUnit],
coverUrl: e["node"]["main_picture"]["large"] ?? "",
title: e["node"]["title"],
score: e["node"]["mean"],
startDate: e["node"]["start_date"] ?? "",
publishingType: e["node"]["media_type"].toString().replaceAll(
"_",
" ",
),
publishingStatus: e["node"]["status"].toString().replaceAll(
"_",
" ",
),
trackingUrl:
"https://myanimelist.net/$item/${e["node"]["id"]}",
syncId: syncId,
),
)
.toList();
}
Future<List<TrackSearch>> fetchUserData({bool isManga = true}) async {
final accessToken = await _getAccessToken();
final item = isManga ? "mangalist" : "animelist";
final contentUnit = isManga ? "num_chapters" : "num_episodes";
final url = Uri.parse('$baseApiUrl/users/@me/$item').replace(
queryParameters: {
'sort': 'list_updated_at',
'limit': '1000',
'fields':
'id,title,synopsis,$contentUnit,main_picture,status,media_type,start_date,mean,list_status',
},
);
final result = await _makeGetRequest(url, accessToken);
final res = jsonDecode(result.body) as Map<String, dynamic>;
return res['data'] == null
? []
: (res['data'] as List)
.map(
(e) => TrackSearch(
mediaId: e["node"]["id"],
summary: e["node"]["synopsis"] ?? "",
totalChapter: e["node"][contentUnit],
coverUrl: e["node"]["main_picture"]["large"] ?? "",
title: e["node"]["title"],
score: e["node"]["mean"] is double
? e["node"]["mean"]
: (e["node"]["mean"] as int).toDouble(),
startDate: e["node"]["start_date"] ?? "",
publishingType: e["node"]["media_type"].toString().replaceAll(
"_",
" ",
),
publishingStatus: e["node"]["status"].toString().replaceAll(
"_",
" ",
),
trackingUrl:
"https://myanimelist.net/$item/${e["node"]["id"]}",
startedReadingDate: _parseDate(
e["list_status"]["start_date"],
),
finishedReadingDate: _parseDate(
e["list_status"]["finish_date"],
),
lastChapterRead:
e["list_status"][isManga
? "num_chapters_read"
: "num_episodes_watched"],
status: fromMyAnimeListStatus(
e["list_status"]["status"],
isManga,
).name,
syncId: syncId,
),
)
.toList();
}
String _convertToIsoDate(int? epochTime) {
String date = "";
try {
@ -207,6 +313,19 @@ class MyAnimeList extends _$MyAnimeList {
};
}
TrackStatus fromMyAnimeListStatus(String status, bool isManga) {
return switch (status) {
"reading" when isManga => TrackStatus.reading,
"watching" when !isManga => TrackStatus.watching,
"completed" => TrackStatus.completed,
"on_hold" => TrackStatus.onHold,
"dropped" => TrackStatus.dropped,
"plan_to_read" when isManga => TrackStatus.planToRead,
"plan_to_watch" when !isManga => TrackStatus.planToWatch,
_ => isManga ? TrackStatus.reading : TrackStatus.planToWatch,
};
}
Future<dynamic> _getOAuth(String code) async {
final params = {
'client_id': clientId,

View file

@ -6,7 +6,7 @@ part of 'myanimelist.dart';
// RiverpodGenerator
// **************************************************************************
String _$myAnimeListHash() => r'a7d644ee61119350613a9cff2fbe87dbd2f98912';
String _$myAnimeListHash() => r'eb483b6451d34e595bb770eefc0f673df13275b3';
/// Copied from Dart SDK
class _SystemHash {

View file

@ -0,0 +1,315 @@
/*import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:intl/intl.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/track.dart';
import 'package:mangayomi/models/track_preference.dart';
import 'package:mangayomi/models/track_search.dart';
import 'package:mangayomi/modules/more/settings/track/myanimelist/model.dart';
import 'package:mangayomi/modules/more/settings/track/providers/track_providers.dart';
import 'package:mangayomi/services/http/m_client.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'trakt.g.dart';
@riverpod
class Trakt extends _$Trakt {
final http = MClient.init(reqcopyWith: {'useDartHttpClient': true});
String baseOAuthUrl = 'https://api.trakt.tv/oauth/authorize?response_type=code&client_id=%20&redirect_uri=%20&state=%20';
String baseApiUrl = 'https://api.trakt.tv';
String codeVerifier = "";
static final isDesktop = (Platform.isWindows || Platform.isLinux);
String clientId = isDesktop
? '5520c7e24da0d8d73ec80315b61b9849483583b013cb7f296c6db723eb9886a1'
: '5520c7e24da0d8d73ec80315b61b9849483583b013cb7f296c6db723eb9886a1';
@override
void build({required int syncId, required ItemType? itemType}) {}
Future<bool?> login() async {
final callbackUrlScheme = isDesktop
? 'http://localhost:43824'
: 'mangayomi';
final loginUrl = _authUrl();
try {
final uri = await FlutterWebAuth2.authenticate(
url: loginUrl,
callbackUrlScheme: callbackUrlScheme,
);
final queryParams = Uri.parse(uri).queryParameters;
if (queryParams['code'] == null) return null;
final oAuth = await _getOAuth(queryParams['code']!);
final mALOAuth = OAuth.fromJson(oAuth as Map<String, dynamic>)
..expiresIn = DateTime.now()
.add(Duration(seconds: oAuth['expires_in']))
.millisecondsSinceEpoch;
final username = await _getUserName(mALOAuth.accessToken!);
ref
.read(tracksProvider(syncId: syncId).notifier)
.login(
TrackPreference(
syncId: syncId,
username: username,
oAuth: jsonEncode(mALOAuth.toJson()),
),
);
return true;
} catch (_) {
return false;
}
}
Future<String> _getAccessToken() async {
final track = ref.watch(tracksProvider(syncId: syncId));
final mALOAuth = OAuth.fromJson(
jsonDecode(track!.oAuth!) as Map<String, dynamic>,
);
final expiresIn = DateTime.fromMillisecondsSinceEpoch(mALOAuth.expiresIn!);
if (DateTime.now().isAfter(expiresIn)) {
final params = {
'client_id': clientId,
'grant_type': 'refresh_token',
'refresh_token': mALOAuth.refreshToken,
};
final response = await http.post(
Uri.parse('$baseOAuthUrl/token'),
body: params,
);
final oAuth = OAuth.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
final username = await _getUserName(oAuth.accessToken!);
ref
.read(tracksProvider(syncId: syncId).notifier)
.login(
TrackPreference(
syncId: syncId,
username: username,
prefs: "",
oAuth: jsonEncode(oAuth.toJson()),
),
);
return oAuth.accessToken!;
}
return mALOAuth.accessToken!;
}
Future<List<TrackSearch>> search(String query, isManga) async {
final accessToken = await _getAccessToken();
final url = Uri.parse(
'$baseApiUrl/${isManga ? "manga" : "anime"}',
).replace(queryParameters: {'q': query.trim(), 'nsfw': 'true'});
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();
final trackSearchResult = await Future.wait(
mangaIds.map((id) => getDetails(id, accessToken, isManga)),
);
return trackSearchResult
.where((element) => !element.publishingType!.contains("novel"))
.toList();
}
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,$contentUnit,main_picture,status,media_type,start_date',
},
);
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[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/$item/${res["id"]}",
);
}
String _convertToIsoDate(int? epochTime) {
String date = "";
try {
date = DateFormat(
"yyyy-MM-dd",
"en_US",
).format(DateTime.fromMillisecondsSinceEpoch(epochTime!));
} catch (_) {}
return date;
}
String _codeVerifier() {
final random = Random.secure();
final values = List<int>.generate(200, (i) => random.nextInt(256));
codeVerifier = base64UrlEncode(values).substring(0, 128);
return codeVerifier;
}
String _authUrl() {
_codeVerifier();
return '$baseOAuthUrl/authorize?client_id=$clientId&code_challenge=$codeVerifier&response_type=code';
}
TrackStatus _getMALTrackStatus(String status, bool isManga) {
return switch (status) {
"reading" when isManga => TrackStatus.reading,
"watching" when !isManga => TrackStatus.watching,
"completed" => TrackStatus.completed,
"on_hold" => TrackStatus.onHold,
"dropped" => TrackStatus.dropped,
"plan_to_read" when isManga => TrackStatus.planToRead,
"plan_to_watch" when !isManga => TrackStatus.planToWatch,
_ => isManga ? TrackStatus.reReading : TrackStatus.planToWatch,
};
}
List<TrackStatus> statusList(bool isManga) => [
isManga ? TrackStatus.reading : TrackStatus.watching,
TrackStatus.completed,
TrackStatus.onHold,
TrackStatus.dropped,
isManga ? TrackStatus.planToRead : TrackStatus.planToWatch,
if (isManga) TrackStatus.reReading,
];
String? toMyAnimeListStatus(TrackStatus status, bool isManga) {
return switch (status) {
TrackStatus.reading when isManga => "reading",
TrackStatus.watching when !isManga => "watching",
TrackStatus.completed => "completed",
TrackStatus.onHold => "on_hold",
TrackStatus.dropped => "dropped",
TrackStatus.planToRead when isManga => "plan_to_read",
TrackStatus.planToWatch when !isManga => "plan_to_watch",
_ => isManga ? "reading" : "plan_to_watch",
};
}
Future<dynamic> _getOAuth(String code) async {
final params = {
'client_id': clientId,
'code': code,
'code_verifier': codeVerifier,
'grant_type': 'authorization_code',
};
final response = await http.post(
Uri.parse('$baseOAuthUrl/token'),
body: params,
);
return jsonDecode(response.body);
}
Future<String> _getUserName(String accessToken) async {
final response = await _makeGetRequest(
Uri.parse('$baseApiUrl/users/@me'),
accessToken,
);
return jsonDecode(response.body)['name'];
}
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': '$contentUnit,my_list_status{start_date,finish_date}',
},
);
final response = await _makeGetRequest(uri, accessToken);
final mJson = jsonDecode(response.body);
track.totalChapter = mJson[contentUnit] ?? 0;
if (mJson['my_list_status'] != null) {
track = _parseItem(mJson["my_list_status"], track, isManga);
} else {
track = await update(track, isManga);
}
return track;
}
Track _parseItem(Map<String, dynamic> mJson, Track track, bool isManga) {
bool isRepeating =
mJson[isManga ? "is_rereading" : "is_rewatching"] ?? false;
track.status = 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"]);
return track;
}
int? _parseDate(String? isoDate) {
if (isoDate == null) return null;
final date = DateFormat('yyyy-MM-dd', 'en_US').parse(isoDate);
return date.millisecondsSinceEpoch;
}
Future<Track> update(Track track, bool isManga) async {
final accessToken = await _getAccessToken();
final formBody = {
'status':
(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(),
isManga ? 'num_chapters_read' : 'num_watched_episodes': 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/${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 _parseItem(mJson, track, isManga);
}
Future<Response> _makeGetRequest(Uri url, String accessToken) async {
return await http.get(
url,
headers: {'Authorization': 'Bearer $accessToken'},
);
}
}
*/