// ignore_for_file: use_build_context_synchronously import 'dart:io'; import 'dart:math'; import 'package:bot_toast/bot_toast.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:isar/isar.dart'; import 'package:mangayomi/eval/model/m_bridge.dart'; import 'package:mangayomi/main.dart'; import 'package:mangayomi/models/changed.dart'; import 'package:mangayomi/models/chapter.dart'; import 'package:mangayomi/models/download.dart'; import 'package:mangayomi/models/history.dart'; import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/models/update.dart'; import 'package:mangayomi/modules/library/providers/add_torrent.dart'; import 'package:mangayomi/modules/library/providers/local_archive.dart'; import 'package:mangayomi/modules/manga/detail/providers/update_manga_detail_providers.dart'; import 'package:mangayomi/modules/more/categories/providers/isar_providers.dart'; import 'package:mangayomi/modules/more/settings/appearance/providers/theme_mode_state_provider.dart'; import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; import 'package:mangayomi/modules/widgets/category_selection_dialog.dart'; import 'package:mangayomi/modules/widgets/custom_draggable_tabbar.dart'; import 'package:mangayomi/modules/widgets/manga_image_card_widget.dart'; import 'package:mangayomi/providers/l10n_providers.dart'; import 'package:mangayomi/providers/storage_provider.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/modules/library/providers/isar_providers.dart'; import 'package:mangayomi/modules/library/providers/library_state_provider.dart'; import 'package:mangayomi/modules/library/widgets/search_text_form_field.dart'; import 'package:mangayomi/modules/library/widgets/library_gridview_widget.dart'; import 'package:mangayomi/modules/library/widgets/library_listview_widget.dart'; import 'package:mangayomi/modules/manga/detail/widgets/chapter_filter_list_tile_widget.dart'; import 'package:mangayomi/modules/manga/detail/widgets/chapter_sort_list_tile_widget.dart'; import 'package:mangayomi/modules/widgets/error_text.dart'; import 'package:mangayomi/modules/widgets/progress_center.dart'; import 'package:mangayomi/utils/extensions/string_extensions.dart'; import 'package:mangayomi/utils/global_style.dart'; import 'package:path/path.dart' as p; class LibraryScreen extends ConsumerStatefulWidget { final ItemType itemType; final String? presetInput; const LibraryScreen({ required this.itemType, required this.presetInput, super.key, }); @override ConsumerState createState() => _LibraryScreenState(); } class _LibraryScreenState extends ConsumerState with TickerProviderStateMixin { bool _isSearch = false; final List _entries = []; final _textEditingController = TextEditingController(); TabController? tabBarController; int _tabIndex = 0; @override void initState() { super.initState(); if (widget.presetInput != null) { _isSearch = true; _textEditingController.text = widget.presetInput!; } } @override void dispose() { _textEditingController.dispose(); tabBarController?.dispose(); super.dispose(); } Future _updateLibrary(List mangaList) async { bool isDark = ref.read(themeModeStateProvider); botToast( context.l10n.updating_library("0", "0", "0"), fontSize: 13, second: 30, alignY: !context.isTablet ? 0.85 : 1, themeDark: isDark, ); int numbers = 0; int failed = 0; for (var manga in mangaList) { try { await ref.read( updateMangaDetailProvider(mangaId: manga.id, isInit: false).future, ); } catch (_) { failed++; } numbers++; if (context.mounted) { botToast( context.l10n.updating_library(numbers, failed, mangaList.length), fontSize: 13, second: 10, alignY: !context.isTablet ? 0.85 : 1, animationDuration: 0, dismissDirections: [DismissDirection.none], onlyOne: false, themeDark: isDark, ); } } await Future.doWhile(() async { await Future.delayed(const Duration(seconds: 1)); if (mangaList.length == numbers) { return false; } return true; }); BotToast.cleanAll(); } @override Widget build(BuildContext context) { final settingsStream = ref.watch(getSettingsStreamProvider); return settingsStream.when( data: (settingsList) { final settings = settingsList.first; final categories = ref.watch( getMangaCategorieStreamProvider(itemType: widget.itemType), ); final withoutCategories = ref.watch( getAllMangaWithoutCategoriesStreamProvider(itemType: widget.itemType), ); final mangaAll = ref.watch( getAllMangaStreamProvider( categoryId: null, itemType: widget.itemType, ), ); T watchWithSettings( ProviderListenable Function({ required ItemType itemType, required Settings settings, }) providerFn, ) { return ref.watch( providerFn(itemType: widget.itemType, settings: settings), ); } T watchWithSettingsAndManga( ProviderListenable Function({ required ItemType itemType, required List mangaList, required Settings settings, }) providerFn, ) { return ref.watch( providerFn( itemType: widget.itemType, mangaList: _entries, settings: settings, ), ); } final showCategoryTabs = watchWithSettings( libraryShowCategoryTabsStateProvider.call, ); final l10n = l10nLocalizations(context)!; return Scaffold( body: mangaAll.when( data: (man) { return withoutCategories.when( data: (withoutCategory) { return categories.when( data: (data) { bool reverse = watchWithSettings( sortLibraryMangaStateProvider.call, ).reverse!; final continueReaderBtn = watchWithSettings( libraryShowContinueReadingButtonStateProvider.call, ); final showNumbersOfItems = watchWithSettings( libraryShowNumbersOfItemsStateProvider.call, ); final localSource = watchWithSettings( libraryLocalSourceStateProvider.call, ); final downloadedChapter = watchWithSettings( libraryDownloadedChaptersStateProvider.call, ); final language = watchWithSettings( libraryLanguageStateProvider.call, ); final displayType = watchWithSettings( libraryDisplayTypeStateProvider.call, ); final isNotFiltering = watchWithSettingsAndManga( mangasFilterResultStateProvider.call, ); final downloadFilterType = watchWithSettingsAndManga( mangaFilterDownloadedStateProvider.call, ); final unreadFilterType = watchWithSettingsAndManga( mangaFilterUnreadStateProvider.call, ); final startedFilterType = watchWithSettingsAndManga( mangaFilterStartedStateProvider.call, ); final bookmarkedFilterType = watchWithSettingsAndManga( mangaFilterBookmarkedStateProvider.call, ); final sortType = watchWithSettings( sortLibraryMangaStateProvider.call, ).index as int; if (data.isNotEmpty && showCategoryTabs) { data.sort((a, b) => (a.pos ?? 0).compareTo(b.pos ?? 0)); final entr = data .where((e) => !(e.hide ?? false)) .toList(); int tabCount = withoutCategory.isNotEmpty ? entr.length + 1 : entr.length; if (tabCount <= 0) { return _bodyWithoutCategories( withoutCategories: true, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, reverse: reverse, downloadedChapter: downloadedChapter, continueReaderBtn: continueReaderBtn, language: language, displayType: displayType, ref: ref, localSource: localSource, settings: settings, ); } if (tabCount > 0 && (tabBarController == null || tabBarController!.length != tabCount)) { int newTabIndex = _tabIndex; if (newTabIndex >= tabCount) { newTabIndex = tabCount - 1; } tabBarController?.dispose(); tabBarController = TabController( length: tabCount, vsync: this, initialIndex: newTabIndex, ); _tabIndex = newTabIndex; tabBarController!.addListener(() { setState(() { _tabIndex = tabBarController!.index; }); }); } return Consumer( builder: (context, ref, child) { final numberOfItemsList = _filterAndSortManga( data: man, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, sortType: sortType, ); final withoutCategoryNumberOfItemsList = _filterAndSortManga( data: withoutCategory, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, sortType: sortType, ); return DefaultTabController( length: entr.length, child: Scaffold( appBar: _appBar( isNotFiltering, showNumbersOfItems, numberOfItemsList.length, ref, [], true, withoutCategory.isNotEmpty && _tabIndex == 0 ? null : entr[withoutCategory.isNotEmpty ? _tabIndex - 1 : _tabIndex] .id!, settings, ), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TabBar( isScrollable: true, controller: tabBarController, tabs: [ if (withoutCategory.isNotEmpty) for ( var i = 0; i < entr.length + 1; i++ ) Row( children: [ Tab( text: i == 0 ? l10n.default0 : entr[i - 1].name, ), const SizedBox(width: 4), if (showNumbersOfItems) i == 0 ? CircleAvatar( backgroundColor: Theme.of( context, ).focusColor, radius: 8, child: Text( withoutCategoryNumberOfItemsList .length .toString(), style: TextStyle( fontSize: 10, color: Theme.of( context, ) .textTheme .bodySmall! .color, ), ), ) : _categoriesNumberOfItems( downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, reverse: reverse, downloadedChapter: downloadedChapter, continueReaderBtn: continueReaderBtn, categoryId: entr[i - 1].id!, settings: settings, ), ], ), if (withoutCategory.isEmpty) for (var i = 0; i < entr.length; i++) Row( children: [ Tab(text: entr[i].name), const SizedBox(width: 4), if (showNumbersOfItems) _categoriesNumberOfItems( downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, reverse: reverse, downloadedChapter: downloadedChapter, continueReaderBtn: continueReaderBtn, categoryId: entr[i].id!, settings: settings, ), ], ), ], ), Flexible( child: TabBarView( controller: tabBarController, children: [ if (withoutCategory.isNotEmpty) for ( var i = 0; i < entr.length + 1; i++ ) i == 0 ? _bodyWithoutCategories( withoutCategories: true, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, reverse: reverse, downloadedChapter: downloadedChapter, continueReaderBtn: continueReaderBtn, language: language, displayType: displayType, ref: ref, localSource: localSource, settings: settings, ) : _bodyWithCatories( categoryId: entr[i - 1].id!, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, reverse: reverse, downloadedChapter: downloadedChapter, continueReaderBtn: continueReaderBtn, language: language, displayType: displayType, ref: ref, localSource: localSource, settings: settings, ), if (withoutCategory.isEmpty) for ( var i = 0; i < entr.length; i++ ) _bodyWithCatories( categoryId: entr[i].id!, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, reverse: reverse, downloadedChapter: downloadedChapter, continueReaderBtn: continueReaderBtn, language: language, displayType: displayType, ref: ref, localSource: localSource, settings: settings, ), ], ), ), ], ), ), ); }, ); } return Consumer( builder: (context, ref, child) { final numberOfItemsList = _filterAndSortManga( data: man, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, sortType: sortType, ); return Scaffold( appBar: _appBar( isNotFiltering, showNumbersOfItems, numberOfItemsList.length, ref, numberOfItemsList, false, null, settings, ), body: _bodyWithoutCategories( downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, reverse: reverse, downloadedChapter: downloadedChapter, continueReaderBtn: continueReaderBtn, language: language, displayType: displayType, ref: ref, localSource: localSource, settings: settings, ), ); }, ); }, error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { return const ProgressCenter(); }, ); }, error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { return const ProgressCenter(); }, ); }, error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { return const ProgressCenter(); }, ), bottomNavigationBar: Consumer( builder: (context, ref, child) { final isLongPressed = ref.watch(isLongPressedMangaStateProvider); final color = Theme.of(context).textTheme.bodyLarge!.color!; final mangaIds = ref.watch(mangasListStateProvider); return AnimatedContainer( curve: Curves.easeIn, decoration: BoxDecoration( color: context.primaryColor.withValues(alpha: 0.2), borderRadius: const BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(20), ), ), duration: const Duration(milliseconds: 100), height: isLongPressed ? 70 : 0, width: context.width(1), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Expanded( child: SizedBox( height: 70, child: ElevatedButton( style: ElevatedButton.styleFrom( shadowColor: Colors.transparent, elevation: 0, backgroundColor: Colors.transparent, ), onPressed: () { final mangaIdsList = ref.watch( mangasListStateProvider, ); final List bulkMangas = mangaIdsList .map((id) => isar.mangas.getSync(id)!) .toList(); showCategorySelectionDialog( context: context, ref: ref, itemType: widget.itemType, bulkMangas: bulkMangas, ); }, child: Icon( Icons.label_outline_rounded, color: color, ), ), ), ), Expanded( child: SizedBox( height: 70, child: ElevatedButton( style: ElevatedButton.styleFrom( elevation: 0, backgroundColor: Colors.transparent, shadowColor: Colors.transparent, ), onPressed: () { ref .read( mangasSetIsReadStateProvider( mangaIds: mangaIds, ).notifier, ) .set(); ref.invalidate( getAllMangaWithoutCategoriesStreamProvider( itemType: widget.itemType, ), ); ref.invalidate( getAllMangaStreamProvider( categoryId: null, itemType: widget.itemType, ), ); }, child: Icon(Icons.done_all_sharp, color: color), ), ), ), Expanded( child: SizedBox( height: 70, child: ElevatedButton( style: ElevatedButton.styleFrom( elevation: 0, backgroundColor: Colors.transparent, shadowColor: Colors.transparent, ), onPressed: () { ref .read( mangasSetUnReadStateProvider( mangaIds: mangaIds, ).notifier, ) .set(); ref.invalidate( getAllMangaWithoutCategoriesStreamProvider( itemType: widget.itemType, ), ); ref.invalidate( getAllMangaStreamProvider( categoryId: null, itemType: widget.itemType, ), ); }, child: Icon(Icons.remove_done_sharp, color: color), ), ), ), // Expanded( // child: SizedBox( // height: 70, // child: ElevatedButton( // style: ElevatedButton.styleFrom( // elevation: 0, // backgroundColor: Colors.transparent, // shadowColor: Colors.transparent, // ), // onPressed: () {}, // child: Icon( // Icons.download_outlined, // color: color, // )), // ), // ), Expanded( child: SizedBox( height: 70, child: ElevatedButton( style: ElevatedButton.styleFrom( elevation: 0, backgroundColor: Colors.transparent, shadowColor: Colors.transparent, ), onPressed: () { _deleteManga(); }, child: Icon( Icons.delete_outline_outlined, color: color, ), ), ), ), ], ), ); }, ), ); }, error: (error, e) { return ErrorText(error); }, loading: () { return const ProgressCenter(); }, ); } Widget _categoriesNumberOfItems({ required int downloadFilterType, required int unreadFilterType, required int startedFilterType, required int bookmarkedFilterType, required bool reverse, required bool downloadedChapter, required bool continueReaderBtn, required int categoryId, required Settings settings, }) { final mangas = ref.watch( getAllMangaStreamProvider( categoryId: categoryId, itemType: widget.itemType, ), ); final sortType = ref .watch( sortLibraryMangaStateProvider( itemType: widget.itemType, settings: settings, ), ) .index; return mangas.when( data: (data) { final categoriNumberOfItemsList = _filterAndSortManga( data: data, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, sortType: sortType!, ); return CircleAvatar( backgroundColor: Theme.of(context).focusColor, radius: 8, child: Text( categoriNumberOfItemsList.length.toString(), style: TextStyle( fontSize: 10, color: Theme.of(context).textTheme.bodySmall!.color, ), ), ); }, error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { return const ProgressCenter(); }, ); } Widget _bodyWithCatories({ required int categoryId, required int downloadFilterType, required int unreadFilterType, required int startedFilterType, required int bookmarkedFilterType, required bool reverse, required bool downloadedChapter, required bool continueReaderBtn, required bool localSource, required bool language, required WidgetRef ref, required DisplayType displayType, required Settings settings, }) { final l10n = l10nLocalizations(context)!; final mangas = ref.watch( getAllMangaStreamProvider( categoryId: categoryId, itemType: widget.itemType, ), ); final sortType = ref .watch( sortLibraryMangaStateProvider( itemType: widget.itemType, settings: settings, ), ) .index; final mangaIdsList = ref.watch(mangasListStateProvider); return Scaffold( body: mangas.when( data: (data) { final entries = _filterAndSortManga( data: data, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, sortType: sortType!, ); if (entries.isNotEmpty) { final entriesManga = reverse ? entries.reversed.toList() : entries; return RefreshIndicator( onRefresh: () async { await _updateLibrary(data); }, child: displayType == DisplayType.list ? LibraryListViewWidget( entriesManga: entriesManga, continueReaderBtn: continueReaderBtn, downloadedChapter: downloadedChapter, language: language, mangaIdsList: mangaIdsList, localSource: localSource, ) : LibraryGridViewWidget( entriesManga: entriesManga, isCoverOnlyGrid: !(displayType == DisplayType.compactGrid), isComfortableGrid: displayType == DisplayType.comfortableGrid, continueReaderBtn: continueReaderBtn, downloadedChapter: downloadedChapter, language: language, mangaIdsList: mangaIdsList, localSource: localSource, itemType: widget.itemType, ), ); } return Center(child: Text(l10n.empty_library)); }, error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { return const ProgressCenter(); }, ), ); } Widget _bodyWithoutCategories({ required int downloadFilterType, required int unreadFilterType, required int startedFilterType, required int bookmarkedFilterType, required bool reverse, required bool downloadedChapter, required bool continueReaderBtn, required bool localSource, required bool language, required DisplayType displayType, required WidgetRef ref, bool withoutCategories = false, required Settings settings, }) { final sortType = ref .watch( sortLibraryMangaStateProvider( itemType: widget.itemType, settings: settings, ), ) .index; final manga = withoutCategories ? ref.watch( getAllMangaWithoutCategoriesStreamProvider( itemType: widget.itemType, ), ) : ref.watch( getAllMangaStreamProvider( categoryId: null, itemType: widget.itemType, ), ); final mangaIdsList = ref.watch(mangasListStateProvider); final l10n = l10nLocalizations(context)!; return manga.when( data: (data) { final entries = _filterAndSortManga( data: data, downloadFilterType: downloadFilterType, unreadFilterType: unreadFilterType, startedFilterType: startedFilterType, bookmarkedFilterType: bookmarkedFilterType, sortType: sortType ?? 0, ); if (entries.isNotEmpty) { final entriesManga = reverse ? entries.reversed.toList() : entries; return RefreshIndicator( onRefresh: () async { await _updateLibrary(data); }, child: displayType == DisplayType.list ? LibraryListViewWidget( entriesManga: entriesManga, continueReaderBtn: continueReaderBtn, downloadedChapter: downloadedChapter, language: language, mangaIdsList: mangaIdsList, localSource: localSource, ) : LibraryGridViewWidget( entriesManga: entriesManga, isCoverOnlyGrid: !(displayType == DisplayType.compactGrid), isComfortableGrid: displayType == DisplayType.comfortableGrid, continueReaderBtn: continueReaderBtn, downloadedChapter: downloadedChapter, language: language, mangaIdsList: mangaIdsList, localSource: localSource, itemType: widget.itemType, ), ); } return Center(child: Text(l10n.empty_library)); }, error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { return const ProgressCenter(); }, ); } bool matchesSearchQuery(Manga manga, String query) { final keywords = query .toLowerCase() .split(',') .map((k) => k.trim()) .where((k) => k.isNotEmpty); return keywords.any( (keyword) => (manga.name?.toLowerCase().contains(keyword) ?? false) || (manga.source?.toLowerCase().contains(keyword) ?? false) || (manga.genre?.any((g) => g.toLowerCase().contains(keyword)) ?? false), ); } List _filterAndSortManga({ required List data, required int downloadFilterType, required int unreadFilterType, required int startedFilterType, required int bookmarkedFilterType, required int sortType, }) { List? mangas; final searchQuery = _textEditingController.text; // Skip all filters, just do search if (searchQuery.isNotEmpty && _ignoreFiltersOnSearch) { mangas = data .where((element) => matchesSearchQuery(element, searchQuery)) .toList(); } else { // Apply filters + search mangas = data .where((element) { // Filter by download List list = []; if (downloadFilterType == 1) { for (var chap in element.chapters) { final modelChapDownload = isar.downloads .filter() .idEqualTo(chap.id) .findAllSync(); if (modelChapDownload.isNotEmpty && modelChapDownload.first.isDownload == true) { list.add(true); } } return list.isNotEmpty; } else if (downloadFilterType == 2) { for (var chap in element.chapters) { final modelChapDownload = isar.downloads .filter() .idEqualTo(chap.id) .findAllSync(); if (!(modelChapDownload.isNotEmpty && modelChapDownload.first.isDownload == true)) { list.add(true); } } return list.length == element.chapters.length; } return true; }) .where((element) { // Filter by unread or started List list = []; if (unreadFilterType == 1 || startedFilterType == 1) { for (var chap in element.chapters) { if (!chap.isRead!) { list.add(true); } } return list.isNotEmpty; } else if (unreadFilterType == 2 || startedFilterType == 2) { List list = []; for (var chap in element.chapters) { if (chap.isRead!) { list.add(true); } } return list.length == element.chapters.length; } return true; }) .where((element) { // Filter by bookmarked List list = []; if (bookmarkedFilterType == 1) { for (var chap in element.chapters) { if (chap.isBookmarked!) { list.add(true); } } return list.isNotEmpty; } else if (bookmarkedFilterType == 2) { List list = []; for (var chap in element.chapters) { if (!chap.isBookmarked!) { list.add(true); } } return list.length == element.chapters.length; } return true; }) .where( (element) => searchQuery.isNotEmpty ? matchesSearchQuery(element, searchQuery) : true, ) .toList(); } // Sorting the data based on selected sort type mangas.sort((a, b) { switch (sortType) { case 0: return a.name!.compareTo(b.name!); case 1: return a.lastRead!.compareTo(b.lastRead!); case 2: return a.lastUpdate?.compareTo(b.lastUpdate ?? 0) ?? 0; case 3: return a.chapters .where((e) => !e.isRead!) .length .compareTo(b.chapters.where((e) => !e.isRead!).length); case 4: return a.chapters.length.compareTo(b.chapters.length); case 5: return (a.chapters.lastOrNull?.dateUpload ?? "").compareTo( b.chapters.lastOrNull?.dateUpload ?? "", ); case 6: return a.dateAdded?.compareTo(b.dateAdded ?? 0) ?? 0; default: return 0; } }); return mangas; } void _deleteManga() { List fromLibList = []; List downloadedChapsList = []; showDialog( context: context, builder: (context) { return Consumer( builder: (context, ref, child) { final mangaIdsList = ref.watch(mangasListStateProvider); final l10n = l10nLocalizations(context)!; final List mangasList = []; for (var id in mangaIdsList) { mangasList.add(isar.mangas.getSync(id)!); } return StatefulBuilder( builder: (context, setState) { return AlertDialog( title: Text(l10n.remove), content: SizedBox( height: 100, width: context.width(0.8), child: Column( children: [ ListTileChapterFilter( label: l10n.from_library, onTap: () { setState(() { if (fromLibList == mangaIdsList) { fromLibList = []; } else { fromLibList = mangaIdsList; } }); }, type: fromLibList.isNotEmpty ? 1 : 0, ), ListTileChapterFilter( label: widget.itemType != ItemType.anime ? l10n.downloaded_chapters : l10n.downloaded_episodes, onTap: () { setState(() { if (downloadedChapsList == mangaIdsList) { downloadedChapsList = []; } else { downloadedChapsList = mangaIdsList; } }); }, type: downloadedChapsList.isNotEmpty ? 1 : 0, ), ], ), ), actions: [ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () { Navigator.pop(context); }, child: Text(l10n.cancel), ), const SizedBox(width: 15), TextButton( onPressed: () async { // From Library if (fromLibList.isNotEmpty) { isar.writeTxnSync(() { for (var manga in mangasList) { if (manga.isLocalArchive ?? false) { _removeImport(ref, manga); } else { manga.favorite = false; manga.updatedAt = DateTime.now().millisecondsSinceEpoch; isar.mangas.putSync(manga); } } }); } // Downloaded Chapters if (downloadedChapsList.isNotEmpty) { for (var manga in mangasList) { String mangaDirectory = ""; if (manga.isLocalArchive ?? false) { mangaDirectory = _deleteImport( manga, mangaDirectory, ); // Also remove item from library // else it has 0 chapters/episodes // and when opened, shows exception // "Null check operator" isar.writeTxnSync(() { _removeImport(ref, manga); }); } else { mangaDirectory = await _deleteDownload( manga, mangaDirectory, ); } if (mangaDirectory.isNotEmpty) { final path = Directory(mangaDirectory); if (path.existsSync() && path.listSync().isEmpty) { path.deleteSync(recursive: true); } } } } ref.read(mangasListStateProvider.notifier).clear(); ref .read(isLongPressedMangaStateProvider.notifier) .update(false); if (mounted) { Navigator.pop(context); } }, child: Text(l10n.ok), ), ], ), ], ); }, ); }, ); }, ); } /// helper method to remove the library entry of an imported item /// does not remove from the device itself. void _removeImport(WidgetRef ref, Manga manga) { final provider = ref.read(synchingProvider(syncId: 1).notifier); final histories = isar.historys .filter() .mangaIdEqualTo(manga.id) .findAllSync(); for (var history in histories) { isar.historys.deleteSync(history.id!); provider.addChangedPart( ActionType.removeHistory, history.id, "{}", false, ); } for (var chapter in manga.chapters) { final updates = isar.updates .filter() .mangaIdEqualTo(chapter.mangaId) .chapterNameEqualTo(chapter.name) .findAllSync(); for (var update in updates) { isar.updates.deleteSync(update.id!); provider.addChangedPart( ActionType.removeUpdate, update.id, "{}", false, ); } isar.chapters.deleteSync(chapter.id!); provider.addChangedPart( ActionType.removeChapter, chapter.id, "{}", false, ); } isar.mangas.deleteSync(manga.id!); provider.addChangedPart(ActionType.removeItem, manga.id, "{}", false); } /// helper method to delete imported mangas/animes String _deleteImport(Manga manga, String mangaDirectory) { for (var chapter in manga.chapters) { final path = chapter.archivePath; if (path == null) continue; final chapterFile = File(path); if (mangaDirectory.isEmpty) { mangaDirectory = p.dirname(path); } try { if (chapterFile.existsSync()) { chapterFile.deleteSync(); } } catch (_) {} } return mangaDirectory; } /// helper method to delete downloaded mangas/animes Future _deleteDownload(Manga manga, String mangaDirectory) async { final storageProvider = StorageProvider(); Directory? mangaDir; final idsToDelete = {}; final downloadedIds = (await isar.downloads.where().idProperty().findAll()) .toSet(); if (downloadedIds.isEmpty) return mangaDirectory; for (var chapter in manga.chapters) { if (chapter.id == null || !downloadedIds.contains(chapter.id)) continue; mangaDir ??= await storageProvider.getMangaMainDirectory(chapter); final chapterDir = await storageProvider.getMangaChapterDirectory( chapter, mangaMainDirectory: mangaDir, ); File? file; if (mangaDirectory.isEmpty) mangaDirectory = mangaDir!.path; if (manga.itemType == ItemType.manga) { // ref: download_page_widget.dart file = File(p.join(mangaDir!.path, "${chapter.name}.cbz")); } else if (manga.itemType == ItemType.anime) { // ref: download_page_widget.dart file = File( p.join( mangaDir!.path, "${chapter.name!.replaceForbiddenCharacters(' ')}.mp4", ), ); } try { if (file != null && file.existsSync()) { file.deleteSync(); } if (chapterDir!.existsSync()) { chapterDir.deleteSync(recursive: true); } } catch (_) {} idsToDelete.add(chapter.id!); } if (idsToDelete.isNotEmpty) { isar.writeTxnSync(() { isar.downloads.deleteAllSync(idsToDelete.toList()); }); } return mangaDirectory; } void _showDraggableMenu(Settings settings) { final l10n = l10nLocalizations(context)!; customDraggableTabBar( tabs: [ Tab(text: l10n.filter), Tab(text: l10n.sort), Tab(text: l10n.display), ], children: [ Consumer( builder: (context, ref, chil) { return Column( children: [ ListTileChapterFilter( label: l10n.downloaded, type: ref.watch( mangaFilterDownloadedStateProvider( itemType: widget.itemType, mangaList: _entries, settings: settings, ), ), onTap: () { ref .read( mangaFilterDownloadedStateProvider( itemType: widget.itemType, mangaList: _entries, settings: settings, ).notifier, ) .update(); }, ), ListTileChapterFilter( label: widget.itemType != ItemType.anime ? l10n.unread : l10n.unwatched, type: ref.watch( mangaFilterUnreadStateProvider( itemType: widget.itemType, mangaList: _entries, settings: settings, ), ), onTap: () { ref .read( mangaFilterUnreadStateProvider( itemType: widget.itemType, mangaList: _entries, settings: settings, ).notifier, ) .update(); }, ), ListTileChapterFilter( label: l10n.started, type: ref.watch( mangaFilterStartedStateProvider( itemType: widget.itemType, mangaList: _entries, settings: settings, ), ), onTap: () { ref .read( mangaFilterStartedStateProvider( itemType: widget.itemType, mangaList: _entries, settings: settings, ).notifier, ) .update(); }, ), ListTileChapterFilter( label: l10n.bookmarked, type: ref.watch( mangaFilterBookmarkedStateProvider( itemType: widget.itemType, mangaList: _entries, settings: settings, ), ), onTap: () { setState(() { ref .read( mangaFilterBookmarkedStateProvider( itemType: widget.itemType, mangaList: _entries, settings: settings, ).notifier, ) .update(); }); }, ), ], ); }, ), Consumer( builder: (context, ref, chil) { final reverse = ref .read( sortLibraryMangaStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ) .isReverse(); final reverseChapter = ref.watch( sortLibraryMangaStateProvider( itemType: widget.itemType, settings: settings, ), ); return Column( children: [ for (var i = 0; i < 7; i++) ListTileChapterSort( label: _getSortNameByIndex(i, context), reverse: reverse, onTap: () { ref .read( sortLibraryMangaStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ) .set(i); }, showLeading: reverseChapter.index == i, ), ], ); }, ), Consumer( builder: (context, ref, chil) { final display = ref.watch( libraryDisplayTypeStateProvider( itemType: widget.itemType, settings: settings, ), ); final displayV = ref.read( libraryDisplayTypeStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ); final showCategoryTabs = ref.watch( libraryShowCategoryTabsStateProvider( itemType: widget.itemType, settings: settings, ), ); final continueReaderBtn = ref.watch( libraryShowContinueReadingButtonStateProvider( itemType: widget.itemType, settings: settings, ), ); final showNumbersOfItems = ref.watch( libraryShowNumbersOfItemsStateProvider( itemType: widget.itemType, settings: settings, ), ); final downloadedChapter = ref.watch( libraryDownloadedChaptersStateProvider( itemType: widget.itemType, settings: settings, ), ); final language = ref.watch( libraryLanguageStateProvider( itemType: widget.itemType, settings: settings, ), ); final localSource = ref.watch( libraryLocalSourceStateProvider( itemType: widget.itemType, settings: settings, ), ); return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only( left: 20, right: 20, top: 10, ), child: Row(children: [Text(l10n.display_mode)]), ), Padding( padding: const EdgeInsets.symmetric( vertical: 5, horizontal: 20, ), child: Wrap( children: DisplayType.values.map( (e) { final selected = e == display; return Padding( padding: const EdgeInsets.only(right: 5), child: ElevatedButton( style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 15, ), surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), side: selected ? null : BorderSide( color: context.isLight ? Colors.black : Colors.white, width: 0.8, ), shadowColor: Colors.transparent, elevation: 0, backgroundColor: selected ? context.primaryColor.withValues( alpha: 0.2, ) : Colors.transparent, ), onPressed: () { displayV.setLibraryDisplayType(e); }, child: Text( displayV.getLibraryDisplayTypeName(e, context), style: TextStyle( color: Theme.of( context, ).textTheme.bodyLarge!.color, fontSize: 14, ), ), ), ); }, // RadioListTile< // DisplayType>( // dense: true, // title: , // value: e, // groupValue: displayV // .getLibraryDisplayTypeValue( // display), // selected: true, // onChanged: (value) { // displayV // .setLibraryDisplayType( // value!); // }, // ), ).toList(), ), ), Consumer( builder: (context, ref, child) { final gridSize = ref.watch( libraryGridSizeStateProvider( itemType: widget.itemType, ), ) ?? 0; return Padding( padding: const EdgeInsets.only( left: 8, right: 8, top: 10, ), child: Row( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 20, ), child: Column( children: [ Text(context.l10n.grid_size), Text( gridSize == 0 ? context.l10n.default0 : context.l10n.n_per_row( gridSize.toString(), ), ), ], ), ), Flexible( flex: 7, child: SliderTheme( data: SliderTheme.of(context).copyWith( overlayShape: const RoundSliderOverlayShape( overlayRadius: 5.0, ), ), child: Slider( min: 0.0, max: 7, divisions: max(7, 0), value: gridSize.toDouble(), onChanged: (value) { HapticFeedback.vibrate(); ref .read( libraryGridSizeStateProvider( itemType: widget.itemType, ).notifier, ) .set(value.toInt()); }, onChangeEnd: (value) { ref .read( libraryGridSizeStateProvider( itemType: widget.itemType, ).notifier, ) .set(value.toInt(), end: true); }, ), ), ), ], ), ); }, ), Padding( padding: const EdgeInsets.only( left: 20, right: 20, top: 10, ), child: Row(children: [Text(l10n.badges)]), ), Padding( padding: const EdgeInsets.only(top: 5), child: Column( children: [ ListTileChapterFilter( label: widget.itemType != ItemType.anime ? l10n.downloaded_chapters : l10n.downloaded_episodes, type: downloadedChapter ? 1 : 0, onTap: () { ref .read( libraryDownloadedChaptersStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ) .set(!downloadedChapter); }, ), ListTileChapterFilter( label: l10n.language, type: language ? 1 : 0, onTap: () { ref .read( libraryLanguageStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ) .set(!language); }, ), ListTileChapterFilter( label: l10n.local_source, type: localSource ? 1 : 0, onTap: () { ref .read( libraryLocalSourceStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ) .set(!localSource); }, ), ListTileChapterFilter( label: widget.itemType != ItemType.anime ? l10n.show_continue_reading_buttons : l10n.show_continue_watching_buttons, type: continueReaderBtn ? 1 : 0, onTap: () { ref .read( libraryShowContinueReadingButtonStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ) .set(!continueReaderBtn); }, ), ], ), ), Padding( padding: const EdgeInsets.only( left: 20, right: 20, top: 10, ), child: Row(children: [Text(l10n.tabs)]), ), Padding( padding: const EdgeInsets.only(top: 5), child: Column( children: [ ListTileChapterFilter( label: l10n.show_category_tabs, type: showCategoryTabs ? 1 : 0, onTap: () { ref .read( libraryShowCategoryTabsStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ) .set(!showCategoryTabs); }, ), ListTileChapterFilter( label: l10n.show_numbers_of_items, type: showNumbersOfItems ? 1 : 0, onTap: () { ref .read( libraryShowNumbersOfItemsStateProvider( itemType: widget.itemType, settings: settings, ).notifier, ) .set(!showNumbersOfItems); }, ), ], ), ), ], ), ); }, ), ], context: context, vsync: this, ); } String _getSortNameByIndex(int index, BuildContext context) { final l10n = l10nLocalizations(context)!; if (index == 0) { return l10n.alphabetically; } else if (index == 1) { return widget.itemType != ItemType.anime ? l10n.last_read : l10n.last_watched; } else if (index == 2) { return l10n.last_update_check; } else if (index == 3) { return widget.itemType != ItemType.anime ? l10n.unread_count : l10n.unwatched_count; } else if (index == 4) { return widget.itemType != ItemType.anime ? l10n.total_chapters : l10n.total_episodes; } else if (index == 5) { return widget.itemType != ItemType.anime ? l10n.latest_chapter : l10n.latest_episode; } return l10n.date_added; } bool _ignoreFiltersOnSearch = false; final bool _isMobile = Platform.isIOS || Platform.isAndroid; PreferredSize _appBar( bool isNotFiltering, bool showNumbersOfItems, int numberOfItems, WidgetRef ref, List mangas, bool isCategory, int? categoryId, Settings settings, ) { final isLongPressed = ref.watch(isLongPressedMangaStateProvider); final mangaIdsList = ref.watch(mangasListStateProvider); final manga = categoryId == null ? ref.watch( getAllMangaWithoutCategoriesStreamProvider( itemType: widget.itemType, ), ) : ref.watch( getAllMangaStreamProvider( categoryId: categoryId, itemType: widget.itemType, ), ); final l10n = l10nLocalizations(context)!; return PreferredSize( preferredSize: Size.fromHeight(AppBar().preferredSize.height), child: isLongPressed ? manga.when( data: (data) => Container( color: Theme.of(context).scaffoldBackgroundColor, child: AppBar( title: Text(mangaIdsList.length.toString()), backgroundColor: context.primaryColor.withValues(alpha: 0.2), leading: IconButton( onPressed: () { ref.read(mangasListStateProvider.notifier).clear(); ref .read(isLongPressedMangaStateProvider.notifier) .update(!isLongPressed); }, icon: const Icon(Icons.clear), ), actions: [ IconButton( onPressed: () { for (var manga in data) { ref .read(mangasListStateProvider.notifier) .selectAll(manga); } }, icon: const Icon(Icons.select_all), ), IconButton( onPressed: () { if (data.length == mangaIdsList.length) { for (var manga in data) { ref .read(mangasListStateProvider.notifier) .selectSome(manga); } ref .read(isLongPressedMangaStateProvider.notifier) .update(false); } else { for (var manga in data) { ref .read(mangasListStateProvider.notifier) .selectSome(manga); } } }, icon: const Icon(Icons.flip_to_back_rounded), ), ], ), ), error: (Object error, StackTrace stackTrace) { return ErrorText(error); }, loading: () { return const ProgressCenter(); }, ) : AppBar( elevation: 0, backgroundColor: Colors.transparent, title: _isSearch ? null : Row( children: [ Text( widget.itemType == ItemType.manga ? l10n.manga : widget.itemType == ItemType.anime ? l10n.anime : l10n.novel, style: TextStyle(color: Theme.of(context).hintColor), ), const SizedBox(width: 10), if (showNumbersOfItems) Padding( padding: const EdgeInsets.only(bottom: 3), child: Badge( backgroundColor: Theme.of(context).focusColor, label: Text( numberOfItems.toString(), style: TextStyle( fontSize: 12, color: Theme.of( context, ).textTheme.bodySmall!.color, ), ), ), ), ], ), actions: [ _isSearch ? SeachFormTextField( onChanged: (value) { setState(() {}); }, onPressed: () { setState(() { _isSearch = false; }); _textEditingController.clear(); }, controller: _textEditingController, onSuffixPressed: () { _textEditingController.clear(); setState(() {}); }, ) : IconButton( splashRadius: 20, onPressed: () { setState(() { _isSearch = true; }); _textEditingController.clear(); }, icon: const Icon(Icons.search), ), // Checkbox when searching library to ignore filters if (_isSearch) Row( mainAxisSize: MainAxisSize.min, children: [ Text( _isMobile // Adds a line break where spaces exist for better mobile layout. // Works for languages that use spaces between words. ? l10n.ignore_filters.replaceFirst(' ', '\n') // Removes manually added line breaks for Thai and Chinese, // where spaces aren’t used, to ensure proper desktop rendering. : l10n.ignore_filters.replaceAll('\n', ''), textAlign: TextAlign.center, ), Checkbox( value: _ignoreFiltersOnSearch, onChanged: (val) { setState(() { _ignoreFiltersOnSearch = val ?? false; }); }, ), ], ), IconButton( splashRadius: 20, onPressed: () { _showDraggableMenu(settings); }, icon: Icon( Icons.filter_list_sharp, color: isNotFiltering ? null : Colors.yellow, ), ), PopupMenuButton( popUpAnimationStyle: popupAnimationStyle, itemBuilder: (context) { return [ PopupMenuItem( value: 0, child: Text(context.l10n.update_library), ), PopupMenuItem( value: 1, child: Text(l10n.open_random_entry), ), PopupMenuItem(value: 2, child: Text(l10n.import)), if (widget.itemType == ItemType.anime) PopupMenuItem( value: 3, child: Text(l10n.torrent_stream), ), ]; }, onSelected: (value) { if (value == 0) { manga.whenData((value) { _updateLibrary(value); }); } else if (value == 1) { manga.whenData((value) { var randomManga = (value..shuffle()).first; pushToMangaReaderDetail( ref: ref, archiveId: randomManga.isLocalArchive ?? false ? randomManga.id : null, context: context, lang: randomManga.lang!, mangaM: randomManga, source: randomManga.source!, ); }); } else if (value == 2) { _importLocal(context, widget.itemType); } else if (value == 3 && widget.itemType == ItemType.anime) { addTorrent(context); } }, ), ], ), ); } } void _importLocal(BuildContext context, ItemType itemType) { final l10n = l10nLocalizations(context)!; final filesText = switch (itemType) { ItemType.manga => ".zip, .cbz", ItemType.anime => ".mp4, .mkv, .avi, and more", ItemType.novel => ".epub", }; bool isLoading = false; showDialog( context: context, barrierDismissible: !isLoading, builder: (context) { return AlertDialog( title: Text(l10n.import_local_file), content: StatefulBuilder( builder: (context, setState) { return Consumer( builder: (context, ref, child) { return SizedBox( height: 100, child: Stack( children: [ Row( children: [ Expanded( child: Padding( padding: const EdgeInsets.all(3), child: ElevatedButton( style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), onPressed: () async { setState(() { isLoading = true; }); await ref.watch( importArchivesFromFileProvider( itemType: itemType, null, init: true, ).future, ); setState(() { isLoading = false; }); Navigator.pop(context); }, child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ const Icon(Icons.archive_outlined), Text( "${l10n.import_files} ( $filesText )", style: TextStyle( color: Theme.of( context, ).textTheme.bodySmall!.color, fontSize: 10, ), ), ], ), ), ), ), ], ), if (isLoading) Container( width: context.width(1), height: context.height(1), color: Colors.transparent, child: UnconstrainedBox( child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: Theme.of( context, ).scaffoldBackgroundColor, ), height: 50, width: 50, child: const Center(child: ProgressCenter()), ), ), ), ], ), ); }, ); }, ), actions: [ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () { Navigator.pop(context); }, child: Text(l10n.cancel), ), const SizedBox(width: 15), ], ), ], ); }, ); } void addTorrent(BuildContext context, {Manga? manga}) { final l10n = l10nLocalizations(context)!; String torrentUrl = ""; bool isLoading = false; showDialog( context: context, barrierDismissible: !isLoading, builder: (context) { return AlertDialog( title: Text(l10n.add_torrent), content: StatefulBuilder( builder: (context, setState) { return Consumer( builder: (context, ref, _) { return SizedBox( height: 150, child: Column( children: [ Row( children: [ Expanded( child: TextFormField( onChanged: (value) { setState(() { torrentUrl = value; }); }, decoration: InputDecoration( hintText: l10n.enter_torrent_hint_text, labelText: l10n.torrent_url, isDense: true, filled: true, fillColor: Colors.transparent, enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: context.secondaryColor, ), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide( color: context.secondaryColor, ), ), border: OutlineInputBorder( borderSide: BorderSide( color: context.secondaryColor, ), ), ), ), ), TextButton( onPressed: isLoading ? null : () async { setState(() { isLoading = true; }); try { await ref.watch( addTorrentFromUrlOrFromFileProvider( manga, init: true, url: torrentUrl, ).future, ); } catch (_) {} setState(() { isLoading = false; }); Navigator.pop(context); }, child: Text(l10n.add), ), ], ), Padding( padding: const EdgeInsets.all(20), child: Text(l10n.or), ), Stack( children: [ Row( children: [ Expanded( child: Padding( padding: const EdgeInsets.all(3), child: ElevatedButton( style: ElevatedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), onPressed: isLoading ? null : () async { setState(() { isLoading = true; }); try { await ref.watch( addTorrentFromUrlOrFromFileProvider( manga, init: true, ).future, ); } catch (_) {} setState(() { isLoading = false; }); Navigator.pop(context); }, child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ const Icon(Icons.archive_outlined), Text( "import .torrent file", style: TextStyle( color: Theme.of( context, ).textTheme.bodySmall!.color, fontSize: 10, ), ), ], ), ), ), ), ], ), if (isLoading) Positioned.fill( child: Container( width: 300, height: 150, color: Colors.transparent, child: UnconstrainedBox( child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), color: Theme.of( context, ).scaffoldBackgroundColor, ), height: 50, width: 50, child: const Center( child: ProgressCenter(), ), ), ), ), ), ], ), ], ), ); }, ); }, ), actions: [ Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () { Navigator.pop(context); }, child: Text(l10n.cancel), ), const SizedBox(width: 15), ], ), ], ); }, ); }