This commit is contained in:
Moustapha Kodjo Amadou 2026-03-02 11:49:19 +01:00
parent 1c9a915c30
commit 1256e608c7
26 changed files with 2206 additions and 2054 deletions

View file

@ -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,

View file

@ -48,7 +48,8 @@ class _SubtitlesWidgetSearchState extends ConsumerState<SubtitlesWidgetSearch> {
}
Future<void> _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) {

View file

@ -96,7 +96,8 @@ class _CodeEditorPageState extends ConsumerState<CodeEditorPage> {
_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);
}

View file

@ -76,7 +76,8 @@ class _GlobalSearchScreenState extends ConsumerState<GlobalSearchScreen> {
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;
});

File diff suppressed because it is too large Load diff

View file

@ -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<int>] for O(1) lookup instead of per-chapter queries.
@riverpod
Set<int> downloadedChapterIds(Ref ref) {
final downloads = isar.downloads
.filter()
.isDownloadEqualTo(true)
.idProperty()
.findAllSync();
return downloads.whereType<int>().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<Manga> filteredLibraryManga(
Ref ref, {
required List<Manga> 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<Manga> _filterAndSortManga({
required List<Manga> 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<int> downloadedIds,
}) {
List<Manga> 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;
}

View file

@ -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<int>] 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<int>] for O(1) lookup instead of per-chapter queries.
final class DownloadedChapterIdsProvider
extends $FunctionalProvider<Set<int>, Set<int>, Set<int>>
with $Provider<Set<int>> {
/// Pre-fetches all downloaded chapter IDs in a single Isar query.
/// Returns a [Set<int>] 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<Set<int>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Set<int> create(Ref ref) {
return downloadedChapterIds(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Set<int> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Set<int>>(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<Manga>, List<Manga>, List<Manga>>
with $Provider<List<Manga>> {
/// 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<Manga> 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<List<Manga>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
List<Manga> create(Ref ref) {
final argument =
this.argument
as ({
List<Manga> 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<Manga> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<Manga>>(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<Manga>,
({
List<Manga> 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<Manga> 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';
}

View file

@ -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<Manga> 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<bool> 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<int>(
value: 0,
child: Text(context.l10n.update_library),
),
PopupMenuItem<int>(value: 1, child: Text(l10n.open_random_entry)),
PopupMenuItem<int>(value: 2, child: Text(l10n.import)),
if (itemType == ItemType.anime)
PopupMenuItem<int>(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<int> mangaIdsList;
final List<Manga> 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),
),
],
),
);
}
}

View file

@ -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(),
);
}
}

View file

@ -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<int> fromLibList = [];
List<int> downloadedChapsList = [];
showDialog(
context: context,
builder: (context) {
return Consumer(
builder: (context, ref, child) {
final mangaIdsList = ref.watch(mangasListStateProvider);
final l10n = l10nLocalizations(context)!;
final List<Manga> 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<String> _deleteDownload(Manga manga, String mangaDirectory) async {
final storageProvider = StorageProvider();
Directory? mangaDir;
final idsToDelete = <int>{};
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),
],
),
],
);
},
);
}

View file

@ -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<Manga> 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<Manga> 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);
},
),
),
),
],
),
);
}
}

View file

@ -25,7 +25,8 @@ class _MangaReaderDetailState extends ConsumerState<MangaReaderDetail> {
}
Future<void> _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,
);

View file

@ -373,7 +373,8 @@ class ChaptersListttState extends _$ChaptersListttState {
}
void set(List<Chapter> chapters) async {
await Future.delayed(const Duration(milliseconds: 10));
// Yield to the event loop to avoid setState during build
await Future(() {});
state = chapters;
}
}

View file

@ -958,7 +958,7 @@ final class ChaptersListttStateProvider
}
String _$chaptersListttStateHash() =>
r'f45ebd9a5b1fd86b279e263813098564830c2536';
r'55c0093bb370d4d103129eeca67e652a0241f2c0';
abstract class _$ChaptersListttState extends $Notifier<List<Chapter>> {
List<Chapter> build();

View file

@ -64,7 +64,7 @@ final class UpdateMangaDetailProvider
}
}
String _$updateMangaDetailHash() => r'37da5f23f30126d15cedfaf42087f9ce11c3fc26';
String _$updateMangaDetailHash() => r'7071020d9d5dd6477875cc8fa0f226bd1d676620';
final class UpdateMangaDetailFamily extends $Family
with

View file

@ -92,7 +92,8 @@ class _MigrationScreenScreenState extends ConsumerState<MigrationScreen> {
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;
});

View file

@ -38,7 +38,8 @@ class _TrackerWidgetSearchState extends ConsumerState<TrackerWidgetSearch> {
late List<TrackSearch>? tracks = [];
String? _errorMsg;
Future<void> _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(

View file

@ -40,7 +40,8 @@ class _TrackerWidgetState extends ConsumerState<TrackerWidget> {
}
Future<void> _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(

View file

@ -136,7 +136,7 @@ final class DownloadChapterProvider
}
}
String _$downloadChapterHash() => r'c503cef46aa7083316b023400f0aa470ae3a3bc4';
String _$downloadChapterHash() => r'db235f856cf106c89d6124c361a51f2e312e9aa3';
final class DownloadChapterFamily extends $Family
with

View file

@ -147,7 +147,7 @@ final class ReaderControllerProvider
}
}
String _$readerControllerHash() => r'23eece0ca4e7b6cbf425488636ef942fe0d4c2bc';
String _$readerControllerHash() => r'89679c9f9542b8f3c7194190e08d0676d611e119';
final class ReaderControllerFamily extends $Family
with

View file

@ -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),

View file

@ -129,7 +129,8 @@ class _ChapterListWidgetState extends State<ChapterListWidget> {
}
Future<void> _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 *

View file

@ -66,7 +66,7 @@ final class GetHtmlContentProvider
}
}
String _$getHtmlContentHash() => r'ef15133ac4066d556a03b42addf01be916e529bc';
String _$getHtmlContentHash() => r'03e421b7f7e821526c47f3b460fc9d866f56c9f6';
final class GetHtmlContentFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<(String, EpubNovel?)>, Chapter> {

View file

@ -66,7 +66,7 @@ final class SourceBaseUrlProvider
}
}
String _$sourceBaseUrlHash() => r'ead3cca719e2530502d97613e3168e0031eecde7';
String _$sourceBaseUrlHash() => r'8b39ad1c4c8283700b2d16dfa3036acc766bb5d4';
final class SourceBaseUrlFamily extends $Family
with $FunctionalFamilyOverride<String, Source> {

View file

@ -65,7 +65,7 @@ final class SupportsLatestProvider extends $FunctionalProvider<bool, bool, bool>
}
}
String _$supportsLatestHash() => r'e2d9b73adde86f78f1ab1c97d91ea2d3a59dc78d';
String _$supportsLatestHash() => r'1fbe2d182136169b88af7ba44d83676f4ec52d9f';
final class SupportsLatestFamily extends $Family
with $FunctionalFamilyOverride<bool, Source> {

View file

@ -91,7 +91,7 @@ final class HeadersProvider
}
}
String _$headersHash() => r'6ad2d5394456d7c054f1270a9f774329ccbb5dad';
String _$headersHash() => r'6d6fd92c9b4137f0c7189ed29a8730fecea6fc99';
final class HeadersFamily extends $Family
with