diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ab1d4457..7b91dc20 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } \ No newline at end of file diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index efb19f25..b23933d4 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -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 diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index b6a703d2..b1dd9ce9 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 94186c1e..c3339885 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 729a9cf3..c5451cb2 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 0d2239c4..710c1a42 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -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`). diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 308b36f0..685d04e3 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_id.dart b/lib/l10n/generated/app_localizations_id.dart index 90f19e6f..9cc3c166 100644 --- a/lib/l10n/generated/app_localizations_id.dart +++ b/lib/l10n/generated/app_localizations_id.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 2ab0a6c6..c5ca5550 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 45e14e2e..a6c821d0 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -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`). diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 6eaed9f5..eb0fd83b 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_th.dart b/lib/l10n/generated/app_localizations_th.dart index b8afd7f8..c211a536 100644 --- a/lib/l10n/generated/app_localizations_th.dart +++ b/lib/l10n/generated/app_localizations_th.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 0130c582..8d8fe75b 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -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'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 691ba362..1d662e5a 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -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'; } diff --git a/lib/main.dart b/lib/main.dart index 5fb98a31..fb084e3c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { routerDelegate: router.routerDelegate, routeInformationProvider: router.routeInformationProvider, title: 'MangaYomi', + scrollBehavior: AllowDesktopScrollBehavior(), ); } @@ -230,3 +232,11 @@ class _MyAppState extends ConsumerState { return true; } } + +class AllowDesktopScrollBehavior extends MaterialScrollBehavior { + @override + Set get dragDevices => { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }; +} diff --git a/lib/modules/main_view/main_screen.dart b/lib/modules/main_view/main_screen.dart index 54de0244..a975f5de 100644 --- a/lib/modules/main_view/main_screen.dart +++ b/lib/modules/main_view/main_screen.dart @@ -329,6 +329,42 @@ class _MainScreenState extends ConsumerState { ), ); } + 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 { 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; diff --git a/lib/modules/manga/detail/providers/track_state_providers.dart b/lib/modules/manga/detail/providers/track_state_providers.dart index eb25567e..d7f80ab5 100644 --- a/lib/modules/manga/detail/providers/track_state_providers.dart +++ b/lib/modules/manga/detail/providers/track_state_providers.dart @@ -138,4 +138,21 @@ class TrackState extends _$TrackState { final tracker = getNotifier(syncId); return await tracker.search(query, _isManga); } + + Future?> fetchGeneralData({ + String rankingType = "airing", + }) async { + final syncId = track!.syncId!; + final tracker = getNotifier(syncId); + return await tracker.fetchGeneralData( + isManga: _isManga, + rankingType: rankingType, + ); + } + + Future?> fetchUserData() async { + final syncId = track!.syncId!; + final tracker = getNotifier(syncId); + return await tracker.fetchUserData(isManga: _isManga); + } } diff --git a/lib/modules/manga/detail/providers/track_state_providers.g.dart b/lib/modules/manga/detail/providers/track_state_providers.g.dart index 4246bdc5..718848a6 100644 --- a/lib/modules/manga/detail/providers/track_state_providers.g.dart +++ b/lib/modules/manga/detail/providers/track_state_providers.g.dart @@ -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 { diff --git a/lib/modules/manga/detail/widgets/migrate_screen.dart b/lib/modules/manga/detail/widgets/migrate_screen.dart index 8fe0ae40..13524e02 100644 --- a/lib/modules/manga/detail/widgets/migrate_screen.dart +++ b/lib/modules/manga/detail/widgets/migrate_screen.dart @@ -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 createState() => _MigrationScreenScreenState(); @@ -48,7 +54,11 @@ class _MigrationScreenScreenState extends ConsumerState { 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 { query: widget.manga.name ?? widget.manga.author ?? "", manga: widget.manga, source: source, + trackSearch: widget.trackSearch, ), ); }, @@ -73,6 +84,7 @@ class _MigrationScreenScreenState extends ConsumerState { 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 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 _addTrackManga(BuildContext context) async { + List 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 _migrateManga(MManga preview) async { + String? historyChapter; + String? historyDate; + List 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*', diff --git a/lib/modules/more/settings/appearance/appearance_screen.dart b/lib/modules/more/settings/appearance/appearance_screen.dart index 9c83cc6d..2474f357 100644 --- a/lib/modules/more/settings/appearance/appearance_screen.dart +++ b/lib/modules/more/settings/appearance/appearance_screen.dart @@ -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 { diff --git a/lib/modules/more/settings/reader/providers/reader_state_provider.dart b/lib/modules/more/settings/reader/providers/reader_state_provider.dart index 753fa984..595afd64 100644 --- a/lib/modules/more/settings/reader/providers/reader_state_provider.dart +++ b/lib/modules/more/settings/reader/providers/reader_state_provider.dart @@ -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 build() { - return isar.settings.getSync(227)!.navigationOrder ?? - [ - '/MangaLibrary', - '/AnimeLibrary', - '/NovelLibrary', - '/updates', - '/history', - '/browse', - '/more', - ]; + return _checkMissingItems( + isar.settings.getSync(227)!.navigationOrder?.toList() ?? [], + ); + } + + List _checkMissingItems(List navigationOrder) { + navigationOrder.addAll( + items.where((e) => !navigationOrder.contains(e)).toList(), + ); + return navigationOrder; } void set(List values) { @@ -176,7 +189,12 @@ class NavigationOrderState extends _$NavigationOrderState { class HideItemsState extends _$HideItemsState { @override List build() { - return isar.settings.getSync(227)!.hideItems ?? []; + return isar.settings.getSync(227)!.hideItems ?? + [ + '/trackerLibrary/anilist', + '/trackerLibrary/kitsu', + '/trackerLibrary/mal', + ]; } void set(List values) { diff --git a/lib/modules/more/settings/reader/providers/reader_state_provider.g.dart b/lib/modules/more/settings/reader/providers/reader_state_provider.g.dart index 3b672a06..b86397cb 100644 --- a/lib/modules/more/settings/reader/providers/reader_state_provider.g.dart +++ b/lib/modules/more/settings/reader/providers/reader_state_provider.g.dart @@ -158,7 +158,7 @@ final fullScreenReaderStateProvider = typedef _$FullScreenReaderState = AutoDisposeNotifier; String _$navigationOrderStateHash() => - r'f1da55a7687995d136a6580d3f63f9b1b32a6ae8'; + r'f300869743afaccfd47210115f341d25fec522bb'; /// See also [NavigationOrderState]. @ProviderFor(NavigationOrderState) @@ -174,7 +174,7 @@ final navigationOrderStateProvider = ); typedef _$NavigationOrderState = AutoDisposeNotifier>; -String _$hideItemsStateHash() => r'b4a467e66f6a1f9b36e4b201a10b771e0dae6a80'; +String _$hideItemsStateHash() => r'6844a05786f6c547a7cba261f742e82d871b6cb1'; /// See also [HideItemsState]. @ProviderFor(HideItemsState) diff --git a/lib/modules/tracker_library/tracker_library_screen.dart b/lib/modules/tracker_library/tracker_library_screen.dart new file mode 100644 index 00000000..976c733f --- /dev/null +++ b/lib/modules/tracker_library/tracker_library_screen.dart @@ -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?> 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 createState() => + _TrackerLibraryScreenState(); +} + +class _TrackerLibraryScreenState extends ConsumerState { + @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?> 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?> 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 createState() => _TrackerSectionScreenState(); +} + +class _TrackerSectionScreenState extends State { + @override + void initState() { + super.initState(); + _init(); + } + + String _errorMessage = ""; + bool _isLoading = true; + List 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 createState() => + _TrackerLibraryImageCardState(); +} + +class _TrackerLibraryImageCardState + extends ConsumerState + with AutomaticKeepAliveClientMixin { + @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; + } +} diff --git a/lib/router/router.dart b/lib/router/router.dart index 09bf69e3..bd89dd69 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -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( + name: "trackerLibrary/anilist", + builder: (id) => TrackerLibraryScreen( + trackerProvider: TrackerProviders.anilist, + presetInput: id, + ), + ), + _genericRoute( + name: "trackerLibrary/kitsu", + builder: (id) => TrackerLibraryScreen( + trackerProvider: TrackerProviders.kitsu, + presetInput: id, + ), + ), + _genericRoute( + 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({ diff --git a/lib/services/trackers/myanimelist.dart b/lib/services/trackers/myanimelist.dart index d73334cb..38d2d7e9 100644 --- a/lib/services/trackers/myanimelist.dart +++ b/lib/services/trackers/myanimelist.dart @@ -149,6 +149,112 @@ class MyAnimeList extends _$MyAnimeList { ); } + Future> 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; + + 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> 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; + + 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 _getOAuth(String code) async { final params = { 'client_id': clientId, diff --git a/lib/services/trackers/myanimelist.g.dart b/lib/services/trackers/myanimelist.g.dart index 8d0071b1..cd13b058 100644 --- a/lib/services/trackers/myanimelist.g.dart +++ b/lib/services/trackers/myanimelist.g.dart @@ -6,7 +6,7 @@ part of 'myanimelist.dart'; // RiverpodGenerator // ************************************************************************** -String _$myAnimeListHash() => r'a7d644ee61119350613a9cff2fbe87dbd2f98912'; +String _$myAnimeListHash() => r'eb483b6451d34e595bb770eefc0f673df13275b3'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/services/trackers/trakt.dart b/lib/services/trackers/trakt.dart new file mode 100644 index 00000000..4412ac99 --- /dev/null +++ b/lib/services/trackers/trakt.dart @@ -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 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) + ..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 _getAccessToken() async { + final track = ref.watch(tracksProvider(syncId: syncId)); + final mALOAuth = OAuth.fromJson( + jsonDecode(track!.oAuth!) as Map, + ); + 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, + ); + 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> 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; + + List 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 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; + + 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.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 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 _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 _getUserName(String accessToken) async { + final response = await _makeGetRequest( + Uri.parse('$baseApiUrl/users/@me'), + accessToken, + ); + return jsonDecode(response.body)['name']; + } + + Future 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 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 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 _makeGetRequest(Uri url, String accessToken) async { + return await http.get( + url, + headers: {'Authorization': 'Bearer $accessToken'}, + ); + } +} +*/ \ No newline at end of file