mangayomi-mirror/lib/modules/library/library_screen.dart
Moustapha Kodjo Amadou 1256e608c7 Refactor
2026-03-02 11:49:19 +01:00

704 lines
26 KiB
Dart

// ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mangayomi/main.dart';
import 'package:mangayomi/models/manga.dart';
import 'package:mangayomi/models/settings.dart';
import 'package:mangayomi/modules/library/providers/add_torrent.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/widgets/bottom_select_bar.dart';
import 'package:mangayomi/modules/widgets/category_selection_dialog.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/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;
const LibraryScreen({
required this.itemType,
required this.presetInput,
super.key,
});
@override
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
}
class _LibraryScreenState extends ConsumerState<LibraryScreen>
with TickerProviderStateMixin {
bool _isSearch = false;
bool _ignoreFiltersOnSearch = false;
final List<Manga> _entries = [];
final _textEditingController = TextEditingController();
TabController? tabBarController;
int _tabIndex = 0;
@override
void initState() {
super.initState();
if (widget.presetInput != null) {
_isSearch = true;
_textEditingController.text = widget.presetInput!;
}
}
@override
void dispose() {
_textEditingController.dispose();
tabBarController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settingsStream = ref.watch(getSettingsStreamProvider);
return settingsStream.when(
data: (settingsList) {
final settings = settingsList.first;
return _buildWithSettings(settings);
},
error: (error, _) => ErrorText(error),
loading: () => const ProgressCenter(),
);
}
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),
);
// Read filter/sort settings once for the whole build
T watchWithSettings<T>(
ProviderListenable<T> Function({
required ItemType itemType,
required Settings settings,
})
providerFn,
) {
return ref.watch(
providerFn(itemType: widget.itemType, settings: settings),
);
}
T watchWithSettingsAndManga<T>(
ProviderListenable<T> Function({
required ItemType itemType,
required List<Manga> mangaList,
required Settings settings,
})
providerFn,
) {
return ref.watch(
providerFn(
itemType: widget.itemType,
mangaList: _entries,
settings: settings,
),
);
}
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;
final searchQuery = _textEditingController.text;
// 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,
);
}
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 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: (error, _) => ErrorText(error),
loading: () => const ProgressCenter(),
);
},
error: (error, _) => ErrorText(error),
loading: () => const ProgressCenter(),
);
},
error: (error, _) => ErrorText(error),
loading: () => const ProgressCenter(),
),
bottomNavigationBar: _buildBottomBar(),
);
}
Widget _buildWithCategories({
required List data,
required List<Manga> 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,
}) {
data.sort((a, b) => (a.pos ?? 0).compareTo(b.pos ?? 0));
final entr = data.where((e) => !(e.hide ?? false)).toList();
int tabCount = withoutCategory.isNotEmpty ? entr.length + 1 : entr.length;
if (tabCount <= 0) {
return bodyForCategory();
}
// 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,
);
_tabIndex = newTabIndex;
tabBarController!.addListener(() {
setState(() => _tabIndex = tabBarController!.index);
});
}
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,
),
),
),
],
),
),
);
}
Widget _buildCategoryTabs({
required List entr,
required List<Manga> withoutCategory,
required bool showNumbersOfItems,
required Widget Function(int categoryId) badgeForCategory,
}) {
final l10n = l10nLocalizations(context)!;
return TabBar(
isScrollable: true,
controller: tabBarController,
tabs: [
if (withoutCategory.isNotEmpty)
Row(
children: [
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<Widget> _buildCategoryBodies({
required List entr,
required List<Manga> 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<Manga> 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 = "";
bool isLoading = false;
showDialog(
context: context,
barrierDismissible: !isLoading,
builder: (context) {
return AlertDialog(
title: Text(l10n.add_torrent),
content: StatefulBuilder(
builder: (context, setState) {
return Consumer(
builder: (context, ref, _) {
return SizedBox(
height: 150,
child: Column(
children: [
Row(
children: [
Expanded(
child: TextFormField(
onChanged: (value) {
setState(() {
torrentUrl = value;
});
},
decoration: InputDecoration(
hintText: l10n.enter_torrent_hint_text,
labelText: l10n.torrent_url,
isDense: true,
filled: true,
fillColor: Colors.transparent,
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: context.secondaryColor,
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: context.secondaryColor,
),
),
border: OutlineInputBorder(
borderSide: BorderSide(
color: context.secondaryColor,
),
),
),
),
),
TextButton(
onPressed: isLoading
? null
: () async {
setState(() {
isLoading = true;
});
try {
await ref.watch(
addTorrentFromUrlOrFromFileProvider(
manga,
init: true,
url: torrentUrl,
).future,
);
} catch (_) {}
setState(() {
isLoading = false;
});
Navigator.pop(context);
},
child: Text(l10n.add),
),
],
),
Padding(
padding: const EdgeInsets.all(20),
child: Text(l10n.or),
),
Stack(
children: [
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(3),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
onPressed: isLoading
? null
: () async {
setState(() {
isLoading = true;
});
try {
await ref.watch(
addTorrentFromUrlOrFromFileProvider(
manga,
init: true,
).future,
);
} catch (_) {}
setState(() {
isLoading = false;
});
Navigator.pop(context);
},
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
const Icon(Icons.archive_outlined),
Text(
"import .torrent file",
style: TextStyle(
color: Theme.of(
context,
).textTheme.bodySmall!.color,
fontSize: 10,
),
),
],
),
),
),
),
],
),
if (isLoading)
Positioned.fill(
child: Container(
width: 300,
height: 150,
color: Colors.transparent,
child: UnconstrainedBox(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(
context,
).scaffoldBackgroundColor,
),
height: 50,
width: 50,
child: const Center(
child: ProgressCenter(),
),
),
),
),
),
],
),
],
),
);
},
);
},
),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(l10n.cancel),
),
const SizedBox(width: 15),
],
),
],
);
},
);
}