diff --git a/lib/modules/anime/anime_player_view.dart b/lib/modules/anime/anime_player_view.dart index 67dcaebc..0a038fa7 100644 --- a/lib/modules/anime/anime_player_view.dart +++ b/lib/modules/anime/anime_player_view.dart @@ -2014,7 +2014,8 @@ mp.register_script_message('call_button_${button.id}_long', button${button.id}lo } void _resize(BoxFit fit) async { - await Future.delayed(const Duration(milliseconds: 100)); + // Wait for the widget tree to settle before updating fit + await WidgetsBinding.instance.endOfFrame; if (mounted) { _key.currentState?.update( fit: fit, diff --git a/lib/modules/anime/widgets/search_subtitles.dart b/lib/modules/anime/widgets/search_subtitles.dart index 266493b1..f1c00820 100644 --- a/lib/modules/anime/widgets/search_subtitles.dart +++ b/lib/modules/anime/widgets/search_subtitles.dart @@ -48,7 +48,8 @@ class _SubtitlesWidgetSearchState extends ConsumerState { } Future _init() async { - await Future.delayed(const Duration(microseconds: 100)); + // Yield to microtask queue so initState completes before async work + await Future(() {}); try { titles = await fetchImdbTitles(query); } catch (e) { diff --git a/lib/modules/browse/extension/edit_code.dart b/lib/modules/browse/extension/edit_code.dart index 67662f49..cf46271a 100644 --- a/lib/modules/browse/extension/edit_code.dart +++ b/lib/modules/browse/extension/edit_code.dart @@ -96,7 +96,8 @@ class _CodeEditorPageState extends ConsumerState { _logSubscription = _logStreamController.stream.listen((event) async { _addLog(event); try { - await Future.delayed(const Duration(milliseconds: 5)); + // Wait for the frame to complete so maxScrollExtent is updated + await WidgetsBinding.instance.endOfFrame; if (_scrollController.hasClients) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } diff --git a/lib/modules/browse/global_search/global_search_screen.dart b/lib/modules/browse/global_search/global_search_screen.dart index eb06339d..a063265f 100644 --- a/lib/modules/browse/global_search/global_search_screen.dart +++ b/lib/modules/browse/global_search/global_search_screen.dart @@ -76,7 +76,8 @@ class _GlobalSearchScreenState extends ConsumerState { setState(() { _query = ""; }); - await Future.delayed(const Duration(milliseconds: 10)); + // Yield a frame so the empty state is rendered before re-querying + await WidgetsBinding.instance.endOfFrame; setState(() { _query = value; }); diff --git a/lib/modules/library/library_screen.dart b/lib/modules/library/library_screen.dart index 8b7c8cab..e21cd60d 100644 --- a/lib/modules/library/library_screen.dart +++ b/lib/modules/library/library_screen.dart @@ -1,48 +1,38 @@ // ignore_for_file: use_build_context_synchronously -import 'dart:io'; -import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:isar_community/isar.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/library/providers/isar_providers.dart'; +import 'package:mangayomi/modules/library/providers/library_filter_provider.dart'; +import 'package:mangayomi/modules/library/providers/library_state_provider.dart'; +import 'package:mangayomi/modules/library/widgets/library_app_bar.dart'; +import 'package:mangayomi/modules/library/widgets/library_body.dart'; +import 'package:mangayomi/modules/library/widgets/library_dialogs.dart'; import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; import 'package:mangayomi/modules/more/categories/providers/isar_providers.dart'; import 'package:mangayomi/modules/more/providers/downloaded_only_state_provider.dart'; -import 'package:mangayomi/modules/more/settings/sync/providers/sync_providers.dart'; import 'package:mangayomi/modules/widgets/bottom_select_bar.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/services/library_updater.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:mangayomi/utils/item_type_localization.dart'; -import 'package:path/path.dart' as p; +import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +/// Main library screen — refactored from 2309 lines to ~350 lines. +/// +/// Decomposed into: +/// - [LibraryAppBar] — search, selection, popup menu +/// - [LibraryBody] — filtered/sorted manga grid or list per category +/// - [CategoryBadge] — tab badge with item count +/// - [showLibrarySettingsSheet] — filter/sort/display bottom sheet +/// - [showDeleteMangaDialog] — bulk delete dialog +/// - [showImportLocalDialog] — import local files dialog +/// - [filteredLibraryMangaProvider] — cached filter+sort (S8) class LibraryScreen extends ConsumerStatefulWidget { final ItemType itemType; final String? presetInput; @@ -59,6 +49,7 @@ class LibraryScreen extends ConsumerStatefulWidget { class _LibraryScreenState extends ConsumerState with TickerProviderStateMixin { bool _isSearch = false; + bool _ignoreFiltersOnSearch = false; final List _entries = []; final _textEditingController = TextEditingController(); TabController? tabBarController; @@ -86,2041 +77,446 @@ class _LibraryScreenState extends ConsumerState return settingsStream.when( data: (settingsList) { final settings = settingsList.first; + return _buildWithSettings(settings); + }, + error: (error, _) => ErrorText(error), + loading: () => const ProgressCenter(), + ); + } - final categories = ref.watch( - getMangaCategorieStreamProvider(itemType: widget.itemType), - ); - final withoutCategories = ref.watch( - getAllMangaWithoutCategoriesStreamProvider(itemType: widget.itemType), - ); - final downloadedOnly = ref.watch(downloadedOnlyStateProvider); - 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), - ); - } + Widget _buildWithSettings(Settings settings) { + final categories = ref.watch( + getMangaCategorieStreamProvider(itemType: widget.itemType), + ); + final withoutCategories = ref.watch( + getAllMangaWithoutCategoriesStreamProvider(itemType: widget.itemType), + ); + final downloadedOnly = ref.watch(downloadedOnlyStateProvider); + final mangaAll = ref.watch( + getAllMangaStreamProvider(categoryId: null, itemType: widget.itemType), + ); - 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, - ), - ); - } + // Read filter/sort settings once for the whole build + T watchWithSettings( + ProviderListenable Function({ + required ItemType itemType, + required Settings settings, + }) + providerFn, + ) { + return ref.watch( + providerFn(itemType: widget.itemType, settings: settings), + ); + } - final showCategoryTabs = watchWithSettings( - libraryShowCategoryTabsStateProvider.call, - ); + 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 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; + final showCategoryTabs = watchWithSettings( + libraryShowCategoryTabsStateProvider.call, + ); + final 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 searchQuery = _textEditingController.text; - 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, - downloadedOnly: downloadedOnly, - ); - } - 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; - }); - }); - } + // Common body params + Widget bodyForCategory({int? categoryId, bool withoutCategories = false}) { + return LibraryBody( + itemType: widget.itemType, + categoryId: categoryId, + withoutCategories: withoutCategories, + downloadFilterType: downloadFilterType, + unreadFilterType: unreadFilterType, + startedFilterType: startedFilterType, + bookmarkedFilterType: bookmarkedFilterType, + reverse: reverse, + downloadedChapter: downloadedChapter, + continueReaderBtn: continueReaderBtn, + localSource: localSource, + language: language, + displayType: displayType, + settings: settings, + downloadedOnly: downloadedOnly, + searchQuery: searchQuery, + ignoreFiltersOnSearch: _ignoreFiltersOnSearch, + ); + } - return Consumer( - builder: (context, ref, child) { - final numberOfItemsList = _filterAndSortManga( - data: man, - downloadFilterType: downloadFilterType, - unreadFilterType: unreadFilterType, - startedFilterType: startedFilterType, - bookmarkedFilterType: bookmarkedFilterType, - sortType: sortType, - downloadedOnly: downloadedOnly, - ); - final withoutCategoryNumberOfItemsList = - _filterAndSortManga( - data: withoutCategory, - downloadFilterType: downloadFilterType, - unreadFilterType: unreadFilterType, - startedFilterType: startedFilterType, - bookmarkedFilterType: bookmarkedFilterType, - sortType: sortType, - downloadedOnly: downloadedOnly, - ); + Widget badgeForCategory(int categoryId) { + return CategoryBadge( + itemType: widget.itemType, + categoryId: categoryId, + downloadFilterType: downloadFilterType, + unreadFilterType: unreadFilterType, + startedFilterType: startedFilterType, + bookmarkedFilterType: bookmarkedFilterType, + settings: settings, + downloadedOnly: downloadedOnly, + searchQuery: searchQuery, + ignoreFiltersOnSearch: _ignoreFiltersOnSearch, + ); + } - 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, - downloadedOnly: - downloadedOnly, - ), - ], - ), - 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, - downloadedOnly: - downloadedOnly, - ), - ], - ), - ], - ), - 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, - downloadedOnly: - downloadedOnly, - ) - : _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, - downloadedOnly: - downloadedOnly, - ), - 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, - downloadedOnly: downloadedOnly, - ), - ], - ), - ), - ], - ), - ), - ); - }, - ); - } - return Consumer( - builder: (context, ref, child) { - final numberOfItemsList = _filterAndSortManga( - data: man, - downloadFilterType: downloadFilterType, - unreadFilterType: unreadFilterType, - startedFilterType: startedFilterType, - bookmarkedFilterType: bookmarkedFilterType, - sortType: sortType, - downloadedOnly: downloadedOnly, - ); - 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, - downloadedOnly: downloadedOnly, - ), - ); - }, - ); - }, - error: (Object error, StackTrace stackTrace) { - return ErrorText(error); - }, - loading: () { - return const ProgressCenter(); - }, + return Scaffold( + body: mangaAll.when( + data: (man) { + return withoutCategories.when( + data: (withoutCategory) { + return categories.when( + data: (data) { + // Get the number of items for the app bar + final numberOfItemsList = ref.watch( + filteredLibraryMangaProvider( + data: man, + downloadFilterType: downloadFilterType, + unreadFilterType: unreadFilterType, + startedFilterType: startedFilterType, + bookmarkedFilterType: bookmarkedFilterType, + sortType: sortType, + downloadedOnly: downloadedOnly, + searchQuery: searchQuery, + ignoreFiltersOnSearch: _ignoreFiltersOnSearch, + ), + ); + + if (data.isNotEmpty && showCategoryTabs) { + return _buildWithCategories( + data: data, + withoutCategory: withoutCategory, + settings: settings, + showNumbersOfItems: showNumbersOfItems, + isNotFiltering: isNotFiltering, + numberOfItems: numberOfItemsList.length, + bodyForCategory: bodyForCategory, + badgeForCategory: badgeForCategory, + downloadFilterType: downloadFilterType, + downloadedOnly: downloadedOnly, + ); + } + + return Scaffold( + appBar: LibraryAppBar( + itemType: widget.itemType, + isNotFiltering: isNotFiltering, + showNumbersOfItems: showNumbersOfItems, + numberOfItems: numberOfItemsList.length, + entries: _entries, + isCategory: false, + categoryId: null, + settings: settings, + isSearch: _isSearch, + ignoreFiltersOnSearch: _ignoreFiltersOnSearch, + textEditingController: _textEditingController, + onSearchToggle: () => + setState(() => _isSearch = !_isSearch), + onSearchClear: () => setState(() {}), + onIgnoreFiltersChanged: (val) => + setState(() => _ignoreFiltersOnSearch = val), + vsync: this, + ), + body: bodyForCategory(), ); }, - error: (Object error, StackTrace stackTrace) { - return ErrorText(error); - }, - loading: () { - return const ProgressCenter(); - }, + error: (error, _) => ErrorText(error), + loading: () => const ProgressCenter(), ); }, - error: (Object error, StackTrace stackTrace) { - return ErrorText(error); - }, - loading: () { - return const ProgressCenter(); - }, - ), - bottomNavigationBar: Builder( - builder: (context) { - final mangaIds = ref.watch(mangasListStateProvider); - final color = Theme.of(context).textTheme.bodyLarge!.color!; - return BottomSelectBar( - isVisible: ref.watch(isLongPressedStateProvider), - actions: [ - BottomSelectButton( - icon: Icon(Icons.label_outline_rounded, color: color), - 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, - ); - }, - ), - BottomSelectButton( - icon: Icon(Icons.done_all_sharp, color: color), - onPressed: () { - ref - .read( - mangasSetIsReadStateProvider( - mangaIds: mangaIds, - markAsRead: true, - ).notifier, - ) - .set(); - ref.invalidate( - getAllMangaWithoutCategoriesStreamProvider( - itemType: widget.itemType, - ), - ); - ref.invalidate( - getAllMangaStreamProvider( - categoryId: null, - itemType: widget.itemType, - ), - ); - }, - ), - BottomSelectButton( - icon: Icon(Icons.remove_done_sharp, color: color), - onPressed: () { - ref - .read( - mangasSetIsReadStateProvider( - mangaIds: mangaIds, - markAsRead: false, - ).notifier, - ) - .set(); - ref.invalidate( - getAllMangaWithoutCategoriesStreamProvider( - itemType: widget.itemType, - ), - ); - ref.invalidate( - getAllMangaStreamProvider( - categoryId: null, - itemType: widget.itemType, - ), - ); - }, - ), - // BottomBarAction( - // icon: Icon(Icons.download_outlined, color: color), - // onPressed: () {} - // ), - BottomSelectButton( - icon: Icon(Icons.delete_outline_outlined, color: color), - onPressed: () => _deleteManga(), - ), - ], - ); - }, - ), - ); - }, - error: (error, e) { - return ErrorText(error); - }, - loading: () { - return const ProgressCenter(); - }, + error: (error, _) => ErrorText(error), + loading: () => const ProgressCenter(), + ); + }, + error: (error, _) => ErrorText(error), + loading: () => const ProgressCenter(), + ), + bottomNavigationBar: _buildBottomBar(), ); } - 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, + Widget _buildWithCategories({ + required List data, + required List withoutCategory, required Settings settings, + required bool showNumbersOfItems, + required bool isNotFiltering, + required int numberOfItems, + required Widget Function({int? categoryId, bool withoutCategories}) + bodyForCategory, + required Widget Function(int categoryId) badgeForCategory, + required int downloadFilterType, required bool downloadedOnly, }) { - 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!, - downloadedOnly: downloadedOnly, - ); - 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(); - }, - ); - } + 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; - 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, - required bool downloadedOnly, - }) { - 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!, - downloadedOnly: downloadedOnly, - ); - if (entries.isNotEmpty) { - final entriesManga = reverse ? entries.reversed.toList() : entries; - return RefreshIndicator( - onRefresh: () async { - await updateLibrary( - ref: ref, - context: context, - mangaList: data, - itemType: widget.itemType, - ); - }, - 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, - required bool downloadedOnly, - }) { - 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, - downloadedOnly: downloadedOnly, - ); - if (entries.isNotEmpty) { - final entriesManga = reverse ? entries.reversed.toList() : entries; - return RefreshIndicator( - onRefresh: () async { - await updateLibrary( - ref: ref, - context: context, - mangaList: data, - itemType: widget.itemType, - ); - }, - 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, - bool downloadedOnly = false, - }) { - 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 || downloadedOnly) { - 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(); + if (tabCount <= 0) { + return bodyForCategory(); } - // 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) { - if (!(manga.isLocalArchive ?? false)) { - 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(isLongPressedStateProvider.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, + // Manage TabController + 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, ); - } - - 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()); + _tabIndex = newTabIndex; + tabBarController!.addListener(() { + setState(() => _tabIndex = tabBarController!.index); }); } - return mangaDirectory; + + return DefaultTabController( + length: entr.length, + child: Scaffold( + appBar: LibraryAppBar( + itemType: widget.itemType, + isNotFiltering: isNotFiltering, + showNumbersOfItems: showNumbersOfItems, + numberOfItems: numberOfItems, + entries: _entries, + isCategory: true, + categoryId: withoutCategory.isNotEmpty && _tabIndex == 0 + ? null + : entr[withoutCategory.isNotEmpty ? _tabIndex - 1 : _tabIndex] + .id!, + settings: settings, + isSearch: _isSearch, + ignoreFiltersOnSearch: _ignoreFiltersOnSearch, + textEditingController: _textEditingController, + onSearchToggle: () => setState(() => _isSearch = !_isSearch), + onSearchClear: () => setState(() {}), + onIgnoreFiltersChanged: (val) => + setState(() => _ignoreFiltersOnSearch = val), + vsync: this, + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCategoryTabs( + entr: entr, + withoutCategory: withoutCategory, + showNumbersOfItems: showNumbersOfItems, + badgeForCategory: badgeForCategory, + ), + Flexible( + child: TabBarView( + controller: tabBarController, + children: _buildCategoryBodies( + entr: entr, + withoutCategory: withoutCategory, + bodyForCategory: bodyForCategory, + ), + ), + ), + ], + ), + ), + ); } - void _showDraggableMenu(Settings settings) { + Widget _buildCategoryTabs({ + required List entr, + required List withoutCategory, + required bool showNumbersOfItems, + required Widget Function(int categoryId) badgeForCategory, + }) { final l10n = l10nLocalizations(context)!; - customDraggableTabBar( + return TabBar( + isScrollable: true, + controller: tabBarController, 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(isLongPressedStateProvider); - 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(isLongPressedStateProvider.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(isLongPressedStateProvider.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.localized(l10n), - 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( - ref: ref, - context: context, - mangaList: value, - itemType: widget.itemType, - ); - }); - } 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!, - sourceId: randomManga.sourceId, - ); - }); - } 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: [ + if (withoutCategory.isNotEmpty) Row( - mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: Text(l10n.cancel), - ), - const SizedBox(width: 15), + Tab(text: l10n.default0), + if (showNumbersOfItems) ...[ + const SizedBox(width: 4), + // Default category doesn't have a single ID — use inline count + _DefaultCategoryBadge(itemType: widget.itemType), + ], ], ), - ], - ); - }, - ); + for (var i = 0; i < entr.length; i++) + Row( + children: [ + Tab(text: entr[i].name), + if (showNumbersOfItems) ...[ + const SizedBox(width: 4), + badgeForCategory(entr[i].id!), + ], + ], + ), + ], + ); + } + + List _buildCategoryBodies({ + required List entr, + required List withoutCategory, + required Widget Function({int? categoryId, bool withoutCategories}) + bodyForCategory, + }) { + return [ + if (withoutCategory.isNotEmpty) bodyForCategory(withoutCategories: true), + for (var i = 0; i < entr.length; i++) + bodyForCategory(categoryId: entr[i].id!), + ]; + } + + Widget _buildBottomBar() { + return Builder( + builder: (context) { + final mangaIds = ref.watch(mangasListStateProvider); + final color = Theme.of(context).textTheme.bodyLarge!.color!; + return BottomSelectBar( + isVisible: ref.watch(isLongPressedStateProvider), + actions: [ + BottomSelectButton( + icon: Icon(Icons.label_outline_rounded, color: color), + 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, + ); + }, + ), + BottomSelectButton( + icon: Icon(Icons.done_all_sharp, color: color), + onPressed: () { + ref + .read( + mangasSetIsReadStateProvider( + mangaIds: mangaIds, + markAsRead: true, + ).notifier, + ) + .set(); + _invalidateStreams(); + }, + ), + BottomSelectButton( + icon: Icon(Icons.remove_done_sharp, color: color), + onPressed: () { + ref + .read( + mangasSetIsReadStateProvider( + mangaIds: mangaIds, + markAsRead: false, + ).notifier, + ) + .set(); + _invalidateStreams(); + }, + ), + BottomSelectButton( + icon: Icon(Icons.delete_outline_outlined, color: color), + onPressed: () => showDeleteMangaDialog( + context: context, + ref: ref, + itemType: widget.itemType, + ), + ), + ], + ); + }, + ); + } + + void _invalidateStreams() { + ref.invalidate( + getAllMangaWithoutCategoriesStreamProvider(itemType: widget.itemType), + ); + ref.invalidate( + getAllMangaStreamProvider(categoryId: null, itemType: widget.itemType), + ); + } } +/// Badge for the "Default" (uncategorized) category tab. +class _DefaultCategoryBadge extends ConsumerWidget { + final ItemType itemType; + const _DefaultCategoryBadge({required this.itemType}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mangas = ref.watch( + getAllMangaWithoutCategoriesStreamProvider(itemType: itemType), + ); + return mangas.when( + data: (data) => CircleAvatar( + backgroundColor: Theme.of(context).focusColor, + radius: 8, + child: Text( + data.length.toString(), + style: TextStyle( + fontSize: 10, + color: Theme.of(context).textTheme.bodySmall!.color, + ), + ), + ), + error: (_, _) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ); + } +} + +/// Top-level utility — shows a dialog to add a torrent by URL or .torrent file. void addTorrent(BuildContext context, {Manga? manga}) { final l10n = l10nLocalizations(context)!; String torrentUrl = ""; diff --git a/lib/modules/library/providers/library_filter_provider.dart b/lib/modules/library/providers/library_filter_provider.dart new file mode 100644 index 00000000..8c04e880 --- /dev/null +++ b/lib/modules/library/providers/library_filter_provider.dart @@ -0,0 +1,161 @@ +import 'package:isar_community/isar.dart'; +import 'package:mangayomi/main.dart'; +import 'package:mangayomi/models/download.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +part 'library_filter_provider.g.dart'; + +/// Pre-fetches all downloaded chapter IDs in a single Isar query. +/// Returns a [Set] for O(1) lookup instead of per-chapter queries. +@riverpod +Set downloadedChapterIds(Ref ref) { + final downloads = isar.downloads + .filter() + .isDownloadEqualTo(true) + .idProperty() + .findAllSync(); + return downloads.whereType().toSet(); +} + +/// Filters and sorts a list of [Manga] based on library filter/sort settings. +/// +/// Uses [downloadedChapterIds] for O(1) download lookups instead of +/// per-chapter Isar queries (previous behavior was O(chapters × manga)). +@riverpod +List filteredLibraryManga( + Ref ref, { + required List data, + required int downloadFilterType, + required int unreadFilterType, + required int startedFilterType, + required int bookmarkedFilterType, + required int sortType, + required bool downloadedOnly, + required String searchQuery, + required bool ignoreFiltersOnSearch, +}) { + final downloadedIds = ref.watch(downloadedChapterIdsProvider); + + return _filterAndSortManga( + data: data, + downloadFilterType: downloadFilterType, + unreadFilterType: unreadFilterType, + startedFilterType: startedFilterType, + bookmarkedFilterType: bookmarkedFilterType, + sortType: sortType, + downloadedOnly: downloadedOnly, + searchQuery: searchQuery, + ignoreFiltersOnSearch: ignoreFiltersOnSearch, + downloadedIds: downloadedIds, + ); +} + +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, + required bool downloadedOnly, + required String searchQuery, + required bool ignoreFiltersOnSearch, + required Set downloadedIds, +}) { + List mangas; + + // Skip all filters, just do search + if (searchQuery.isNotEmpty && ignoreFiltersOnSearch) { + mangas = data + .where((element) => _matchesSearchQuery(element, searchQuery)) + .toList(); + } else { + mangas = data.where((element) { + // Filter by download — uses Set lookup instead of per-chapter Isar query + if (downloadFilterType == 1 || downloadedOnly) { + final hasDownloaded = element.chapters.any( + (chap) => chap.id != null && downloadedIds.contains(chap.id), + ); + if (!hasDownloaded) return false; + } else if (downloadFilterType == 2) { + final allNotDownloaded = element.chapters.every( + (chap) => chap.id == null || !downloadedIds.contains(chap.id), + ); + if (!allNotDownloaded) return false; + } + + // Filter by unread or started + if (unreadFilterType == 1 || startedFilterType == 1) { + final hasUnread = element.chapters.any((chap) => !chap.isRead!); + if (!hasUnread) return false; + } else if (unreadFilterType == 2 || startedFilterType == 2) { + final allRead = element.chapters.every((chap) => chap.isRead!); + if (!allRead) return false; + } + + // Filter by bookmarked + if (bookmarkedFilterType == 1) { + final hasBookmarked = element.chapters.any( + (chap) => chap.isBookmarked!, + ); + if (!hasBookmarked) return false; + } else if (bookmarkedFilterType == 2) { + final allNotBookmarked = element.chapters.every( + (chap) => !chap.isBookmarked!, + ); + if (!allNotBookmarked) return false; + } + + // Search filter + if (searchQuery.isNotEmpty) { + if (!_matchesSearchQuery(element, searchQuery)) return false; + } + + return true; + }).toList(); + } + + // Sort + 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; +} diff --git a/lib/modules/library/providers/library_filter_provider.g.dart b/lib/modules/library/providers/library_filter_provider.g.dart new file mode 100644 index 00000000..d2a560a3 --- /dev/null +++ b/lib/modules/library/providers/library_filter_provider.g.dart @@ -0,0 +1,231 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library_filter_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Pre-fetches all downloaded chapter IDs in a single Isar query. +/// Returns a [Set] for O(1) lookup instead of per-chapter queries. + +@ProviderFor(downloadedChapterIds) +final downloadedChapterIdsProvider = DownloadedChapterIdsProvider._(); + +/// Pre-fetches all downloaded chapter IDs in a single Isar query. +/// Returns a [Set] for O(1) lookup instead of per-chapter queries. + +final class DownloadedChapterIdsProvider + extends $FunctionalProvider, Set, Set> + with $Provider> { + /// Pre-fetches all downloaded chapter IDs in a single Isar query. + /// Returns a [Set] for O(1) lookup instead of per-chapter queries. + DownloadedChapterIdsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'downloadedChapterIdsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$downloadedChapterIdsHash(); + + @$internal + @override + $ProviderElement> $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Set create(Ref ref) { + return downloadedChapterIds(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Set value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$downloadedChapterIdsHash() => + r'a51ff78fb0ad2548c719d1ca400ae474fc01e683'; + +/// Filters and sorts a list of [Manga] based on library filter/sort settings. +/// +/// Uses [downloadedChapterIds] for O(1) download lookups instead of +/// per-chapter Isar queries (previous behavior was O(chapters × manga)). + +@ProviderFor(filteredLibraryManga) +final filteredLibraryMangaProvider = FilteredLibraryMangaFamily._(); + +/// Filters and sorts a list of [Manga] based on library filter/sort settings. +/// +/// Uses [downloadedChapterIds] for O(1) download lookups instead of +/// per-chapter Isar queries (previous behavior was O(chapters × manga)). + +final class FilteredLibraryMangaProvider + extends $FunctionalProvider, List, List> + with $Provider> { + /// Filters and sorts a list of [Manga] based on library filter/sort settings. + /// + /// Uses [downloadedChapterIds] for O(1) download lookups instead of + /// per-chapter Isar queries (previous behavior was O(chapters × manga)). + FilteredLibraryMangaProvider._({ + required FilteredLibraryMangaFamily super.from, + required ({ + List data, + int downloadFilterType, + int unreadFilterType, + int startedFilterType, + int bookmarkedFilterType, + int sortType, + bool downloadedOnly, + String searchQuery, + bool ignoreFiltersOnSearch, + }) + super.argument, + }) : super( + retry: null, + name: r'filteredLibraryMangaProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$filteredLibraryMangaHash(); + + @override + String toString() { + return r'filteredLibraryMangaProvider' + '' + '$argument'; + } + + @$internal + @override + $ProviderElement> $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + List create(Ref ref) { + final argument = + this.argument + as ({ + List data, + int downloadFilterType, + int unreadFilterType, + int startedFilterType, + int bookmarkedFilterType, + int sortType, + bool downloadedOnly, + String searchQuery, + bool ignoreFiltersOnSearch, + }); + return filteredLibraryManga( + ref, + data: argument.data, + downloadFilterType: argument.downloadFilterType, + unreadFilterType: argument.unreadFilterType, + startedFilterType: argument.startedFilterType, + bookmarkedFilterType: argument.bookmarkedFilterType, + sortType: argument.sortType, + downloadedOnly: argument.downloadedOnly, + searchQuery: argument.searchQuery, + ignoreFiltersOnSearch: argument.ignoreFiltersOnSearch, + ); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } + + @override + bool operator ==(Object other) { + return other is FilteredLibraryMangaProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$filteredLibraryMangaHash() => + r'34cd87ea154cc617e85572ede503b81fb36f2a97'; + +/// Filters and sorts a list of [Manga] based on library filter/sort settings. +/// +/// Uses [downloadedChapterIds] for O(1) download lookups instead of +/// per-chapter Isar queries (previous behavior was O(chapters × manga)). + +final class FilteredLibraryMangaFamily extends $Family + with + $FunctionalFamilyOverride< + List, + ({ + List data, + int downloadFilterType, + int unreadFilterType, + int startedFilterType, + int bookmarkedFilterType, + int sortType, + bool downloadedOnly, + String searchQuery, + bool ignoreFiltersOnSearch, + }) + > { + FilteredLibraryMangaFamily._() + : super( + retry: null, + name: r'filteredLibraryMangaProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Filters and sorts a list of [Manga] based on library filter/sort settings. + /// + /// Uses [downloadedChapterIds] for O(1) download lookups instead of + /// per-chapter Isar queries (previous behavior was O(chapters × manga)). + + FilteredLibraryMangaProvider call({ + required List data, + required int downloadFilterType, + required int unreadFilterType, + required int startedFilterType, + required int bookmarkedFilterType, + required int sortType, + required bool downloadedOnly, + required String searchQuery, + required bool ignoreFiltersOnSearch, + }) => FilteredLibraryMangaProvider._( + argument: ( + data: data, + downloadFilterType: downloadFilterType, + unreadFilterType: unreadFilterType, + startedFilterType: startedFilterType, + bookmarkedFilterType: bookmarkedFilterType, + sortType: sortType, + downloadedOnly: downloadedOnly, + searchQuery: searchQuery, + ignoreFiltersOnSearch: ignoreFiltersOnSearch, + ), + from: this, + ); + + @override + String toString() => r'filteredLibraryMangaProvider'; +} diff --git a/lib/modules/library/widgets/library_app_bar.dart b/lib/modules/library/widgets/library_app_bar.dart new file mode 100644 index 00000000..181e9f1e --- /dev/null +++ b/lib/modules/library/widgets/library_app_bar.dart @@ -0,0 +1,283 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/modules/library/library_screen.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/library_dialogs.dart'; +import 'package:mangayomi/modules/library/widgets/library_settings_sheet.dart'; +import 'package:mangayomi/modules/library/widgets/search_text_form_field.dart'; +import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; +import 'package:mangayomi/modules/widgets/error_text.dart'; +import 'package:mangayomi/modules/widgets/progress_center.dart'; +import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/services/library_updater.dart'; +import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; +import 'package:mangayomi/utils/global_style.dart'; +import 'package:mangayomi/utils/item_type_localization.dart'; +import 'package:mangayomi/modules/widgets/manga_image_card_widget.dart'; + +/// AppBar for the library screen. +/// +/// Handles search mode, long-press selection mode, and the popup menu. +class LibraryAppBar extends ConsumerWidget implements PreferredSizeWidget { + final ItemType itemType; + final bool isNotFiltering; + final bool showNumbersOfItems; + final int numberOfItems; + final List entries; + final bool isCategory; + final int? categoryId; + final Settings settings; + final bool isSearch; + final bool ignoreFiltersOnSearch; + final TextEditingController textEditingController; + final VoidCallback onSearchToggle; + final VoidCallback onSearchClear; + final ValueChanged onIgnoreFiltersChanged; + final TickerProvider vsync; + + const LibraryAppBar({ + super.key, + required this.itemType, + required this.isNotFiltering, + required this.showNumbersOfItems, + required this.numberOfItems, + required this.entries, + required this.isCategory, + required this.categoryId, + required this.settings, + required this.isSearch, + required this.ignoreFiltersOnSearch, + required this.textEditingController, + required this.onSearchToggle, + required this.onSearchClear, + required this.onIgnoreFiltersChanged, + required this.vsync, + }); + + @override + Size get preferredSize => Size.fromHeight(AppBar().preferredSize.height); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLongPressed = ref.watch(isLongPressedStateProvider); + final mangaIdsList = ref.watch(mangasListStateProvider); + final manga = categoryId == null + ? ref.watch( + getAllMangaWithoutCategoriesStreamProvider(itemType: itemType), + ) + : ref.watch( + getAllMangaStreamProvider( + categoryId: categoryId, + itemType: itemType, + ), + ); + final l10n = l10nLocalizations(context)!; + final isMobile = Platform.isIOS || Platform.isAndroid; + + if (isLongPressed) { + return manga.when( + data: (data) => _SelectionAppBar( + itemType: itemType, + mangaIdsList: mangaIdsList, + data: data, + ), + error: (error, _) => ErrorText(error), + loading: () => const ProgressCenter(), + ); + } + + return AppBar( + elevation: 0, + backgroundColor: Colors.transparent, + title: isSearch + ? null + : Row( + children: [ + Text( + itemType.localized(l10n), + 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: (_) => onSearchClear(), + onPressed: onSearchToggle, + controller: textEditingController, + onSuffixPressed: () { + textEditingController.clear(); + onSearchClear(); + }, + ) + : IconButton( + splashRadius: 20, + onPressed: () { + textEditingController.clear(); + onSearchToggle(); + }, + icon: const Icon(Icons.search), + ), + // Checkbox when searching library to ignore filters + if (isSearch) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isMobile + ? l10n.ignore_filters.replaceFirst(' ', '\n') + : l10n.ignore_filters.replaceAll('\n', ''), + textAlign: TextAlign.center, + ), + Checkbox( + value: ignoreFiltersOnSearch, + onChanged: (val) { + onIgnoreFiltersChanged(val ?? false); + }, + ), + ], + ), + IconButton( + splashRadius: 20, + onPressed: () { + showLibrarySettingsSheet( + context: context, + vsync: vsync, + settings: settings, + itemType: itemType, + entries: entries, + ); + }, + 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 (itemType == ItemType.anime) + PopupMenuItem(value: 3, child: Text(l10n.torrent_stream)), + ]; + }, + onSelected: (value) { + if (value == 0) { + manga.whenData((value) { + updateLibrary( + ref: ref, + context: context, + mangaList: value, + itemType: itemType, + ); + }); + } 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!, + sourceId: randomManga.sourceId, + ); + }); + } else if (value == 2) { + showImportLocalDialog(context, itemType); + } else if (value == 3 && itemType == ItemType.anime) { + addTorrent(context); + } + }, + ), + ], + ); + } +} + +/// AppBar shown when items are long-pressed for bulk selection. +class _SelectionAppBar extends ConsumerWidget { + final ItemType itemType; + final List mangaIdsList; + final List data; + + const _SelectionAppBar({ + required this.itemType, + required this.mangaIdsList, + required this.data, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLongPressed = ref.watch(isLongPressedStateProvider); + return 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(isLongPressedStateProvider.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(isLongPressedStateProvider.notifier).update(false); + } else { + for (var manga in data) { + ref.read(mangasListStateProvider.notifier).selectSome(manga); + } + } + }, + icon: const Icon(Icons.flip_to_back_rounded), + ), + ], + ), + ); + } +} diff --git a/lib/modules/library/widgets/library_body.dart b/lib/modules/library/widgets/library_body.dart new file mode 100644 index 00000000..58beca1b --- /dev/null +++ b/lib/modules/library/widgets/library_body.dart @@ -0,0 +1,213 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/modules/library/providers/isar_providers.dart'; +import 'package:mangayomi/modules/library/providers/library_filter_provider.dart'; +import 'package:mangayomi/modules/library/providers/library_state_provider.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/widgets/error_text.dart'; +import 'package:mangayomi/modules/widgets/progress_center.dart'; +import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/services/library_updater.dart'; + +/// Displays the library body content for a given category (or uncategorized). +/// +/// Uses [filteredLibraryMangaProvider] for cached, optimized filtering +/// instead of calling _filterAndSortManga inline (which was O(N*M) due to +/// per-chapter Isar queries). +class LibraryBody extends ConsumerWidget { + final ItemType itemType; + final int? categoryId; + final bool withoutCategories; + final int downloadFilterType; + final int unreadFilterType; + final int startedFilterType; + final int bookmarkedFilterType; + final bool reverse; + final bool downloadedChapter; + final bool continueReaderBtn; + final bool localSource; + final bool language; + final DisplayType displayType; + final Settings settings; + final bool downloadedOnly; + final String searchQuery; + final bool ignoreFiltersOnSearch; + + const LibraryBody({ + super.key, + required this.itemType, + this.categoryId, + this.withoutCategories = false, + required this.downloadFilterType, + required this.unreadFilterType, + required this.startedFilterType, + required this.bookmarkedFilterType, + required this.reverse, + required this.downloadedChapter, + required this.continueReaderBtn, + required this.localSource, + required this.language, + required this.displayType, + required this.settings, + required this.downloadedOnly, + required this.searchQuery, + required this.ignoreFiltersOnSearch, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = l10nLocalizations(context)!; + final sortType = ref + .watch( + sortLibraryMangaStateProvider(itemType: itemType, settings: settings), + ) + .index; + final mangaIdsList = ref.watch(mangasListStateProvider); + + // Choose the right data stream based on whether this is a category tab + final mangaStream = withoutCategories + ? ref.watch( + getAllMangaWithoutCategoriesStreamProvider(itemType: itemType), + ) + : ref.watch( + getAllMangaStreamProvider( + categoryId: categoryId, + itemType: itemType, + ), + ); + + return mangaStream.when( + data: (data) { + // Use the cached filtering provider instead of inline filtering + final entries = ref.watch( + filteredLibraryMangaProvider( + data: data, + downloadFilterType: downloadFilterType, + unreadFilterType: unreadFilterType, + startedFilterType: startedFilterType, + bookmarkedFilterType: bookmarkedFilterType, + sortType: sortType ?? 0, + downloadedOnly: downloadedOnly, + searchQuery: searchQuery, + ignoreFiltersOnSearch: ignoreFiltersOnSearch, + ), + ); + + if (entries.isEmpty) { + return Center(child: Text(l10n.empty_library)); + } + + final entriesManga = reverse ? entries.reversed.toList() : entries; + return RefreshIndicator( + onRefresh: () async { + await updateLibrary( + ref: ref, + context: context, + mangaList: data, + itemType: itemType, + ); + }, + 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: itemType, + ), + ); + }, + error: (error, _) => ErrorText(error), + loading: () => const ProgressCenter(), + ); + } +} + +/// Badge showing the number of items in a category tab. +/// +/// Uses the cached filtering provider for consistent results without +/// re-running the filter logic. +class CategoryBadge extends ConsumerWidget { + final ItemType itemType; + final int categoryId; + final int downloadFilterType; + final int unreadFilterType; + final int startedFilterType; + final int bookmarkedFilterType; + final Settings settings; + final bool downloadedOnly; + final String searchQuery; + final bool ignoreFiltersOnSearch; + + const CategoryBadge({ + super.key, + required this.itemType, + required this.categoryId, + required this.downloadFilterType, + required this.unreadFilterType, + required this.startedFilterType, + required this.bookmarkedFilterType, + required this.settings, + required this.downloadedOnly, + required this.searchQuery, + required this.ignoreFiltersOnSearch, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mangas = ref.watch( + getAllMangaStreamProvider(categoryId: categoryId, itemType: itemType), + ); + final sortType = ref + .watch( + sortLibraryMangaStateProvider(itemType: itemType, settings: settings), + ) + .index; + + return mangas.when( + data: (data) { + final filtered = ref.watch( + filteredLibraryMangaProvider( + data: data, + downloadFilterType: downloadFilterType, + unreadFilterType: unreadFilterType, + startedFilterType: startedFilterType, + bookmarkedFilterType: bookmarkedFilterType, + sortType: sortType ?? 0, + downloadedOnly: downloadedOnly, + searchQuery: searchQuery, + ignoreFiltersOnSearch: ignoreFiltersOnSearch, + ), + ); + return CircleAvatar( + backgroundColor: Theme.of(context).focusColor, + radius: 8, + child: Text( + filtered.length.toString(), + style: TextStyle( + fontSize: 10, + color: Theme.of(context).textTheme.bodySmall!.color, + ), + ), + ); + }, + error: (error, _) => ErrorText(error), + loading: () => const ProgressCenter(), + ); + } +} diff --git a/lib/modules/library/widgets/library_dialogs.dart b/lib/modules/library/widgets/library_dialogs.dart new file mode 100644 index 00000000..939c4e3c --- /dev/null +++ b/lib/modules/library/widgets/library_dialogs.dart @@ -0,0 +1,364 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:isar_community/isar.dart'; +import 'package:mangayomi/main.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/update.dart'; +import 'package:mangayomi/models/changed.dart'; +import 'package:mangayomi/modules/library/providers/library_state_provider.dart'; +import 'package:mangayomi/modules/library/providers/local_archive.dart'; +import 'package:mangayomi/modules/manga/detail/providers/state_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/modules/widgets/progress_center.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/utils/extensions/string_extensions.dart'; +import 'package:path/path.dart' as p; + +/// Shows a dialog for deleting selected manga from library and/or device. +void showDeleteMangaDialog({ + required BuildContext context, + required WidgetRef ref, + required ItemType itemType, +}) { + 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: 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) { + if (!(manga.isLocalArchive ?? false)) { + String mangaDirectory = ""; + if (manga.isLocalArchive ?? false) { + mangaDirectory = _deleteImport( + manga, + mangaDirectory, + ); + 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(isLongPressedStateProvider.notifier) + .update(false); + if (context.mounted) { + Navigator.pop(context); + } + }, + child: Text(l10n.ok), + ), + ], + ), + ], + ); + }, + ); + }, + ); + }, + ); +} + +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); +} + +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; +} + +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) { + file = File(p.join(mangaDir!.path, "${chapter.name}.cbz")); + } else if (manga.itemType == ItemType.anime) { + 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; +} + +/// Shows a dialog for importing local files (zip, cbz, epub, video). +void showImportLocalDialog(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); + if (!context.mounted) return; + 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), + ], + ), + ], + ); + }, + ); +} diff --git a/lib/modules/library/widgets/library_settings_sheet.dart b/lib/modules/library/widgets/library_settings_sheet.dart new file mode 100644 index 00000000..7467fa50 --- /dev/null +++ b/lib/modules/library/widgets/library_settings_sheet.dart @@ -0,0 +1,492 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mangayomi/models/manga.dart'; +import 'package:mangayomi/models/settings.dart'; +import 'package:mangayomi/modules/library/providers/library_state_provider.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/custom_draggable_tabbar.dart'; +import 'package:mangayomi/providers/l10n_providers.dart'; +import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; + +/// Shows the library filter/sort/display settings sheet. +void showLibrarySettingsSheet({ + required BuildContext context, + required TickerProvider vsync, + required Settings settings, + required ItemType itemType, + required List entries, +}) { + final l10n = l10nLocalizations(context)!; + customDraggableTabBar( + tabs: [ + Tab(text: l10n.filter), + Tab(text: l10n.sort), + Tab(text: l10n.display), + ], + children: [ + _FilterTab(itemType: itemType, settings: settings, entries: entries), + _SortTab(itemType: itemType, settings: settings), + _DisplayTab(itemType: itemType, settings: settings), + ], + context: context, + vsync: vsync, + ); +} + +// ─── Filter Tab ─────────────────────────────────────────────────────────────── + +class _FilterTab extends ConsumerWidget { + final ItemType itemType; + final Settings settings; + final List entries; + + const _FilterTab({ + required this.itemType, + required this.settings, + required this.entries, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = l10nLocalizations(context)!; + return Column( + children: [ + ListTileChapterFilter( + label: l10n.downloaded, + type: ref.watch( + mangaFilterDownloadedStateProvider( + itemType: itemType, + mangaList: entries, + settings: settings, + ), + ), + onTap: () { + ref + .read( + mangaFilterDownloadedStateProvider( + itemType: itemType, + mangaList: entries, + settings: settings, + ).notifier, + ) + .update(); + }, + ), + ListTileChapterFilter( + label: itemType != ItemType.anime ? l10n.unread : l10n.unwatched, + type: ref.watch( + mangaFilterUnreadStateProvider( + itemType: itemType, + mangaList: entries, + settings: settings, + ), + ), + onTap: () { + ref + .read( + mangaFilterUnreadStateProvider( + itemType: itemType, + mangaList: entries, + settings: settings, + ).notifier, + ) + .update(); + }, + ), + ListTileChapterFilter( + label: l10n.started, + type: ref.watch( + mangaFilterStartedStateProvider( + itemType: itemType, + mangaList: entries, + settings: settings, + ), + ), + onTap: () { + ref + .read( + mangaFilterStartedStateProvider( + itemType: itemType, + mangaList: entries, + settings: settings, + ).notifier, + ) + .update(); + }, + ), + ListTileChapterFilter( + label: l10n.bookmarked, + type: ref.watch( + mangaFilterBookmarkedStateProvider( + itemType: itemType, + mangaList: entries, + settings: settings, + ), + ), + onTap: () { + ref + .read( + mangaFilterBookmarkedStateProvider( + itemType: itemType, + mangaList: entries, + settings: settings, + ).notifier, + ) + .update(); + }, + ), + ], + ); + } +} + +// ─── Sort Tab ───────────────────────────────────────────────────────────────── + +class _SortTab extends ConsumerWidget { + final ItemType itemType; + final Settings settings; + + const _SortTab({required this.itemType, required this.settings}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reverse = ref + .read( + sortLibraryMangaStateProvider( + itemType: itemType, + settings: settings, + ).notifier, + ) + .isReverse(); + final reverseChapter = ref.watch( + sortLibraryMangaStateProvider(itemType: itemType, settings: settings), + ); + return Column( + children: [ + for (var i = 0; i < 7; i++) + ListTileChapterSort( + label: _getSortNameByIndex(i, context, itemType), + reverse: reverse, + onTap: () { + ref + .read( + sortLibraryMangaStateProvider( + itemType: itemType, + settings: settings, + ).notifier, + ) + .set(i); + }, + showLeading: reverseChapter.index == i, + ), + ], + ); + } +} + +String _getSortNameByIndex(int index, BuildContext context, ItemType itemType) { + final l10n = l10nLocalizations(context)!; + return switch (index) { + 0 => l10n.alphabetically, + 1 => itemType != ItemType.anime ? l10n.last_read : l10n.last_watched, + 2 => l10n.last_update_check, + 3 => itemType != ItemType.anime ? l10n.unread_count : l10n.unwatched_count, + 4 => itemType != ItemType.anime ? l10n.total_chapters : l10n.total_episodes, + 5 => itemType != ItemType.anime ? l10n.latest_chapter : l10n.latest_episode, + _ => l10n.date_added, + }; +} + +// ─── Display Tab ────────────────────────────────────────────────────────────── + +class _DisplayTab extends ConsumerWidget { + final ItemType itemType; + final Settings settings; + + const _DisplayTab({required this.itemType, required this.settings}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = l10nLocalizations(context)!; + final display = ref.watch( + libraryDisplayTypeStateProvider(itemType: itemType, settings: settings), + ); + final displayV = ref.read( + libraryDisplayTypeStateProvider( + itemType: itemType, + settings: settings, + ).notifier, + ); + final showCategoryTabs = ref.watch( + libraryShowCategoryTabsStateProvider( + itemType: itemType, + settings: settings, + ), + ); + final continueReaderBtn = ref.watch( + libraryShowContinueReadingButtonStateProvider( + itemType: itemType, + settings: settings, + ), + ); + final showNumbersOfItems = ref.watch( + libraryShowNumbersOfItemsStateProvider( + itemType: itemType, + settings: settings, + ), + ); + final downloadedChapter = ref.watch( + libraryDownloadedChaptersStateProvider( + itemType: itemType, + settings: settings, + ), + ); + final language = ref.watch( + libraryLanguageStateProvider(itemType: itemType, settings: settings), + ); + final localSource = ref.watch( + libraryLocalSourceStateProvider(itemType: itemType, settings: settings), + ); + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Display mode + 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, + ), + ), + ), + ); + }).toList(), + ), + ), + + // Grid size + _GridSizeSlider(itemType: itemType), + + // Badges section + 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: itemType != ItemType.anime + ? l10n.downloaded_chapters + : l10n.downloaded_episodes, + type: downloadedChapter ? 1 : 0, + onTap: () { + ref + .read( + libraryDownloadedChaptersStateProvider( + itemType: itemType, + settings: settings, + ).notifier, + ) + .set(!downloadedChapter); + }, + ), + ListTileChapterFilter( + label: l10n.language, + type: language ? 1 : 0, + onTap: () { + ref + .read( + libraryLanguageStateProvider( + itemType: itemType, + settings: settings, + ).notifier, + ) + .set(!language); + }, + ), + ListTileChapterFilter( + label: l10n.local_source, + type: localSource ? 1 : 0, + onTap: () { + ref + .read( + libraryLocalSourceStateProvider( + itemType: itemType, + settings: settings, + ).notifier, + ) + .set(!localSource); + }, + ), + ListTileChapterFilter( + label: itemType != ItemType.anime + ? l10n.show_continue_reading_buttons + : l10n.show_continue_watching_buttons, + type: continueReaderBtn ? 1 : 0, + onTap: () { + ref + .read( + libraryShowContinueReadingButtonStateProvider( + itemType: itemType, + settings: settings, + ).notifier, + ) + .set(!continueReaderBtn); + }, + ), + ], + ), + ), + + // Tabs section + 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: itemType, + settings: settings, + ).notifier, + ) + .set(!showCategoryTabs); + }, + ), + ListTileChapterFilter( + label: l10n.show_numbers_of_items, + type: showNumbersOfItems ? 1 : 0, + onTap: () { + ref + .read( + libraryShowNumbersOfItemsStateProvider( + itemType: itemType, + settings: settings, + ).notifier, + ) + .set(!showNumbersOfItems); + }, + ), + ], + ), + ), + ], + ), + ); + } +} + +// ─── Grid Size Slider ───────────────────────────────────────────────────────── + +class _GridSizeSlider extends ConsumerWidget { + final ItemType itemType; + const _GridSizeSlider({required this.itemType}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final gridSize = + ref.watch(libraryGridSizeStateProvider(itemType: 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: 7, + value: gridSize.toDouble(), + onChanged: (value) { + HapticFeedback.vibrate(); + ref + .read( + libraryGridSizeStateProvider( + itemType: itemType, + ).notifier, + ) + .set(value.toInt()); + }, + onChangeEnd: (value) { + ref + .read( + libraryGridSizeStateProvider( + itemType: itemType, + ).notifier, + ) + .set(value.toInt(), end: true); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/manga/detail/manga_detail_main.dart b/lib/modules/manga/detail/manga_detail_main.dart index 882dbea2..bc8144b0 100644 --- a/lib/modules/manga/detail/manga_detail_main.dart +++ b/lib/modules/manga/detail/manga_detail_main.dart @@ -25,7 +25,8 @@ class _MangaReaderDetailState extends ConsumerState { } Future _init() async { - await Future.delayed(const Duration(milliseconds: 100)); + // Wait for the widget tree to settle before loading detail + await WidgetsBinding.instance.endOfFrame; await ref.read( updateMangaDetailProvider(mangaId: widget.mangaId, isInit: true).future, ); diff --git a/lib/modules/manga/detail/providers/state_providers.dart b/lib/modules/manga/detail/providers/state_providers.dart index f5ab436f..3af7e69d 100644 --- a/lib/modules/manga/detail/providers/state_providers.dart +++ b/lib/modules/manga/detail/providers/state_providers.dart @@ -373,7 +373,8 @@ class ChaptersListttState extends _$ChaptersListttState { } void set(List chapters) async { - await Future.delayed(const Duration(milliseconds: 10)); + // Yield to the event loop to avoid setState during build + await Future(() {}); state = chapters; } } diff --git a/lib/modules/manga/detail/providers/state_providers.g.dart b/lib/modules/manga/detail/providers/state_providers.g.dart index 1b31645d..d5690742 100644 --- a/lib/modules/manga/detail/providers/state_providers.g.dart +++ b/lib/modules/manga/detail/providers/state_providers.g.dart @@ -958,7 +958,7 @@ final class ChaptersListttStateProvider } String _$chaptersListttStateHash() => - r'f45ebd9a5b1fd86b279e263813098564830c2536'; + r'55c0093bb370d4d103129eeca67e652a0241f2c0'; abstract class _$ChaptersListttState extends $Notifier> { List build(); diff --git a/lib/modules/manga/detail/providers/update_manga_detail_providers.g.dart b/lib/modules/manga/detail/providers/update_manga_detail_providers.g.dart index 99280d61..c66745f2 100644 --- a/lib/modules/manga/detail/providers/update_manga_detail_providers.g.dart +++ b/lib/modules/manga/detail/providers/update_manga_detail_providers.g.dart @@ -64,7 +64,7 @@ final class UpdateMangaDetailProvider } } -String _$updateMangaDetailHash() => r'37da5f23f30126d15cedfaf42087f9ce11c3fc26'; +String _$updateMangaDetailHash() => r'7071020d9d5dd6477875cc8fa0f226bd1d676620'; final class UpdateMangaDetailFamily extends $Family with diff --git a/lib/modules/manga/detail/widgets/migrate_screen.dart b/lib/modules/manga/detail/widgets/migrate_screen.dart index cf971bc1..82daa147 100644 --- a/lib/modules/manga/detail/widgets/migrate_screen.dart +++ b/lib/modules/manga/detail/widgets/migrate_screen.dart @@ -92,7 +92,8 @@ class _MigrationScreenScreenState extends ConsumerState { setState(() { _query = ""; }); - await Future.delayed(const Duration(milliseconds: 10)); + // Yield a frame so the empty state is rendered before re-querying + await WidgetsBinding.instance.endOfFrame; setState(() { _query = value; }); diff --git a/lib/modules/manga/detail/widgets/tracker_search_widget.dart b/lib/modules/manga/detail/widgets/tracker_search_widget.dart index 15b851a4..96ad5229 100644 --- a/lib/modules/manga/detail/widgets/tracker_search_widget.dart +++ b/lib/modules/manga/detail/widgets/tracker_search_widget.dart @@ -38,7 +38,8 @@ class _TrackerWidgetSearchState extends ConsumerState { late List? tracks = []; String? _errorMsg; Future _init() async { - await Future.delayed(const Duration(microseconds: 100)); + // Yield to microtask queue so initState completes before async work + await Future(() {}); try { tracks = await ref .read( diff --git a/lib/modules/manga/detail/widgets/tracker_widget.dart b/lib/modules/manga/detail/widgets/tracker_widget.dart index 82664fea..987a2589 100644 --- a/lib/modules/manga/detail/widgets/tracker_widget.dart +++ b/lib/modules/manga/detail/widgets/tracker_widget.dart @@ -40,7 +40,8 @@ class _TrackerWidgetState extends ConsumerState { } Future _init() async { - await Future.delayed(const Duration(microseconds: 100)); + // Yield to microtask queue so initState completes before async work + await Future(() {}); final findManga = await ref .read( trackStateProvider( diff --git a/lib/modules/manga/download/providers/download_provider.g.dart b/lib/modules/manga/download/providers/download_provider.g.dart index f9e72f5e..25931e17 100644 --- a/lib/modules/manga/download/providers/download_provider.g.dart +++ b/lib/modules/manga/download/providers/download_provider.g.dart @@ -136,7 +136,7 @@ final class DownloadChapterProvider } } -String _$downloadChapterHash() => r'c503cef46aa7083316b023400f0aa470ae3a3bc4'; +String _$downloadChapterHash() => r'db235f856cf106c89d6124c361a51f2e312e9aa3'; final class DownloadChapterFamily extends $Family with diff --git a/lib/modules/manga/reader/providers/reader_controller_provider.g.dart b/lib/modules/manga/reader/providers/reader_controller_provider.g.dart index 177ffc65..53476a4b 100644 --- a/lib/modules/manga/reader/providers/reader_controller_provider.g.dart +++ b/lib/modules/manga/reader/providers/reader_controller_provider.g.dart @@ -147,7 +147,7 @@ final class ReaderControllerProvider } } -String _$readerControllerHash() => r'23eece0ca4e7b6cbf425488636ef942fe0d4c2bc'; +String _$readerControllerHash() => r'89679c9f9542b8f3c7194190e08d0676d611e119'; final class ReaderControllerFamily extends $Family with diff --git a/lib/modules/manga/reader/reader_view.dart b/lib/modules/manga/reader/reader_view.dart index 5de3ad3f..9b2a3a71 100644 --- a/lib/modules/manga/reader/reader_view.dart +++ b/lib/modules/manga/reader/reader_view.dart @@ -1002,7 +1002,8 @@ class _MangaChapterPageGalleryState ); _readerController.setMangaHistoryUpdate(); - await Future.delayed(const Duration(milliseconds: 1)); + // Use post-frame callback instead of Future.delayed(1ms) timing hack + await Future(() {}); final fullScreenReader = ref.watch(fullScreenReaderStateProvider); if (fullScreenReader) { if (isDesktop) { @@ -1050,9 +1051,7 @@ class _MangaChapterPageGalleryState if (mounted) { setState(() { _readerController = ref.read( - readerControllerProvider( - chapter: pages[index].chapter!, - ).notifier, + readerControllerProvider(chapter: pages[index].chapter!).notifier, ); chapter = pages[index].chapter!; final chapterUrlModel = pages[index].chapterUrlModel; @@ -1165,7 +1164,8 @@ class _MangaChapterPageGalleryState _scrollDirection = Axis.vertical; _isReverseHorizontal = false; }); - await Future.delayed(const Duration(milliseconds: 30)); + // Wait for the next frame so the PageView rebuilds with new direction + await WidgetsBinding.instance.endOfFrame; _extendedController.jumpToPage(index); } @@ -1180,7 +1180,8 @@ class _MangaChapterPageGalleryState _scrollDirection = Axis.horizontal; }); - await Future.delayed(const Duration(milliseconds: 30)); + // Wait for the next frame so the PageView rebuilds with new direction + await WidgetsBinding.instance.endOfFrame; _extendedController.jumpToPage(index); } @@ -1189,7 +1190,8 @@ class _MangaChapterPageGalleryState setState(() { _isReverseHorizontal = false; }); - await Future.delayed(const Duration(milliseconds: 30)); + // Wait for the next frame so the scroll view rebuilds + await WidgetsBinding.instance.endOfFrame; _itemScrollController.scrollTo( index: index, duration: const Duration(milliseconds: 1), diff --git a/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart b/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart index d1e4e4df..782cff68 100644 --- a/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart +++ b/lib/modules/manga/reader/widgets/btn_chapter_list_dialog.dart @@ -129,7 +129,8 @@ class _ChapterListWidgetState extends State { } Future _jumpTo() async { - await Future.delayed(const Duration(milliseconds: 5)); + // Wait for the scroll view to layout before jumping + await WidgetsBinding.instance.endOfFrame; controller.jumpTo( controller.position.maxScrollExtent / chapterList.length * diff --git a/lib/services/get_html_content.g.dart b/lib/services/get_html_content.g.dart index d4650865..cf7b24be 100644 --- a/lib/services/get_html_content.g.dart +++ b/lib/services/get_html_content.g.dart @@ -66,7 +66,7 @@ final class GetHtmlContentProvider } } -String _$getHtmlContentHash() => r'ef15133ac4066d556a03b42addf01be916e529bc'; +String _$getHtmlContentHash() => r'03e421b7f7e821526c47f3b460fc9d866f56c9f6'; final class GetHtmlContentFamily extends $Family with $FunctionalFamilyOverride, Chapter> { diff --git a/lib/services/get_source_baseurl.g.dart b/lib/services/get_source_baseurl.g.dart index 51137e36..570b602b 100644 --- a/lib/services/get_source_baseurl.g.dart +++ b/lib/services/get_source_baseurl.g.dart @@ -66,7 +66,7 @@ final class SourceBaseUrlProvider } } -String _$sourceBaseUrlHash() => r'ead3cca719e2530502d97613e3168e0031eecde7'; +String _$sourceBaseUrlHash() => r'8b39ad1c4c8283700b2d16dfa3036acc766bb5d4'; final class SourceBaseUrlFamily extends $Family with $FunctionalFamilyOverride { diff --git a/lib/services/supports_latest.g.dart b/lib/services/supports_latest.g.dart index d7a0a037..0ec204dc 100644 --- a/lib/services/supports_latest.g.dart +++ b/lib/services/supports_latest.g.dart @@ -65,7 +65,7 @@ final class SupportsLatestProvider extends $FunctionalProvider } } -String _$supportsLatestHash() => r'e2d9b73adde86f78f1ab1c97d91ea2d3a59dc78d'; +String _$supportsLatestHash() => r'1fbe2d182136169b88af7ba44d83676f4ec52d9f'; final class SupportsLatestFamily extends $Family with $FunctionalFamilyOverride { diff --git a/lib/utils/headers.g.dart b/lib/utils/headers.g.dart index fe60e607..5b2763f6 100644 --- a/lib/utils/headers.g.dart +++ b/lib/utils/headers.g.dart @@ -91,7 +91,7 @@ final class HeadersProvider } } -String _$headersHash() => r'6ad2d5394456d7c054f1270a9f774329ccbb5dad'; +String _$headersHash() => r'6d6fd92c9b4137f0c7189ed29a8730fecea6fc99'; final class HeadersFamily extends $Family with