import 'package:flutter/material.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/utils/cbz.dart'; import 'package:venera/utils/epub.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/pdf.dart'; import 'package:venera/utils/translations.dart'; import 'package:zip_flutter/zip_flutter.dart'; class LocalComicsPage extends StatefulWidget { const LocalComicsPage({super.key}); @override State createState() => _LocalComicsPageState(); } class _LocalComicsPageState extends State { late List comics; late LocalSortType sortType; String keyword = ""; bool searchMode = false; bool multiSelectMode = false; Map selectedComics = {}; void update() { if (keyword.isEmpty) { setState(() { comics = LocalManager().getComics(sortType); }); } else { setState(() { comics = LocalManager().search(keyword); }); } } @override void initState() { var sort = appdata.implicitData["local_sort"] ?? "name"; sortType = LocalSortType.fromString(sort); comics = LocalManager().getComics(sortType); LocalManager().addListener(update); super.initState(); } @override void dispose() { LocalManager().removeListener(update); super.dispose(); } void sort() { showDialog( context: context, builder: (context) { return StatefulBuilder(builder: (context, setState) { return ContentDialog( title: "Sort".tl, content: Column( children: [ RadioListTile( title: Text("Name".tl), value: LocalSortType.name, groupValue: sortType, onChanged: (v) { setState(() { sortType = v!; }); }, ), RadioListTile( title: Text("Date".tl), value: LocalSortType.timeAsc, groupValue: sortType, onChanged: (v) { setState(() { sortType = v!; }); }, ), RadioListTile( title: Text("Date Desc".tl), value: LocalSortType.timeDesc, groupValue: sortType, onChanged: (v) { setState(() { sortType = v!; }); }, ), ], ), actions: [ FilledButton( onPressed: () { appdata.implicitData["local_sort"] = sortType.value; appdata.writeImplicitData(); Navigator.pop(context); update(); }, child: Text("Confirm".tl), ), ], ); }); }, ); } Widget buildMultiSelectMenu() { return MenuButton(entries: [ MenuEntry( icon: Icons.delete_outline, text: "Delete".tl, onClick: () { deleteComics(selectedComics.keys.toList()).then((value) { if (value) { setState(() { multiSelectMode = false; selectedComics.clear(); }); } }); }, ), MenuEntry( icon: Icons.favorite_border, text: "Add to favorites".tl, onClick: () { addFavorite(selectedComics.keys.toList()); }, ), if (selectedComics.length == 1) MenuEntry( icon: Icons.chrome_reader_mode_outlined, text: "View Detail".tl, onClick: () { context.to(() => ComicPage( id: selectedComics.keys.first.id, sourceKey: selectedComics.keys.first.sourceKey, )); }, ), if (selectedComics.isNotEmpty) ...exportActions(selectedComics.keys.toList()), ]); } void selectAll() { setState(() { selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); }); } void deSelect() { setState(() { selectedComics.clear(); }); } void invertSelection() { setState(() { comics.asMap().forEach((k, v) { selectedComics[v] = !selectedComics.putIfAbsent(v, () => false); }); selectedComics.removeWhere((k, v) => !v); }); } @override Widget build(BuildContext context) { List selectActions = [ IconButton( icon: const Icon(Icons.select_all), tooltip: "Select All".tl, onPressed: selectAll), IconButton( icon: const Icon(Icons.deselect), tooltip: "Deselect".tl, onPressed: deSelect), IconButton( icon: const Icon(Icons.flip), tooltip: "Invert Selection".tl, onPressed: invertSelection), buildMultiSelectMenu(), ]; List normalActions = [ Tooltip( message: "Search".tl, child: IconButton( icon: const Icon(Icons.search), onPressed: () { setState(() { searchMode = true; }); }, ), ), Tooltip( message: "Sort".tl, child: IconButton( icon: const Icon(Icons.sort), onPressed: sort, ), ), Tooltip( message: "Downloading".tl, child: IconButton( icon: const Icon(Icons.download), onPressed: () { showPopUpWidget(context, const DownloadingPage()); }, ), ), ]; var body = Scaffold( body: SmoothCustomScrollView( slivers: [ if (!searchMode) SliverAppbar( leading: Tooltip( message: multiSelectMode ? "Cancel".tl : "Back".tl, child: IconButton( onPressed: () { if (multiSelectMode) { setState(() { multiSelectMode = false; selectedComics.clear(); }); } else { context.pop(); } }, icon: multiSelectMode ? const Icon(Icons.close) : const Icon(Icons.arrow_back), ), ), title: multiSelectMode ? Text(selectedComics.length.toString()) : Text("Local".tl), actions: multiSelectMode ? selectActions : normalActions, ) else if (searchMode) SliverAppbar( leading: Tooltip( message: "Cancel".tl, child: IconButton( icon: const Icon(Icons.close), onPressed: () { setState(() { searchMode = false; keyword = ""; update(); }); }, ), ), title: TextField( autofocus: true, decoration: InputDecoration( hintText: "Search".tl, border: InputBorder.none, ), onChanged: (v) { keyword = v; update(); }, ), ), SliverGridComics( comics: comics, selections: selectedComics, onLongPressed: (c) { setState(() { multiSelectMode = true; selectedComics[c as LocalComic] = true; }); }, onTap: (c) { if (multiSelectMode) { setState(() { if (selectedComics.containsKey(c as LocalComic)) { selectedComics.remove(c); } else { selectedComics[c] = true; } if (selectedComics.isEmpty) { multiSelectMode = false; } }); } else { // prevent dirty data var comic = LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!; comic.read(); } }, menuBuilder: (c) { return [ MenuEntry( icon: Icons.delete, text: "Delete".tl, onClick: () { deleteComics([c as LocalComic]).then((value) { if (value && multiSelectMode) { setState(() { multiSelectMode = false; selectedComics.clear(); }); } }); }, ), ...exportActions([c as LocalComic]), ]; }, ), ], ), ); return PopScope( canPop: !multiSelectMode && !searchMode, onPopInvokedWithResult: (didPop, result) { if (multiSelectMode) { setState(() { multiSelectMode = false; selectedComics.clear(); }); } else if (searchMode) { setState(() { searchMode = false; keyword = ""; update(); }); } }, child: body, ); } Future deleteComics(List comics) async { bool isDeleted = false; await showDialog( context: App.rootContext, builder: (context) { bool removeComicFile = true; return StatefulBuilder(builder: (context, state) { return ContentDialog( title: "Delete".tl, content: CheckboxListTile( title: Text("Also remove files on disk".tl), value: removeComicFile, onChanged: (v) { state(() { removeComicFile = !removeComicFile; }); }, ), actions: [ FilledButton( onPressed: () { context.pop(); for (var comic in comics) { LocalManager().deleteComic( comic, removeComicFile, ); } isDeleted = true; }, child: Text("Confirm".tl), ), ], ); }); }, ); return isDeleted; } List exportActions(List comics) { return [ MenuEntry( icon: Icons.outbox_outlined, text: "Export as cbz".tl, onClick: () { exportComics(comics, CBZ.export, ".cbz"); }, ), MenuEntry( icon: Icons.picture_as_pdf_outlined, text: "Export as pdf".tl, onClick: () async { exportComics(comics, createPdfFromComicIsolate, ".pdf"); }, ), MenuEntry( icon: Icons.import_contacts_outlined, text: "Export as epub".tl, onClick: () async { exportComics(comics, createEpubWithLocalComic, ".epub"); }, ) ]; } /// Export given comics to a file void exportComics( List comics, ExportComicFunc export, String ext) async { var current = 0; var cacheDir = FilePath.join(App.cachePath, 'comics_export'); var outFile = FilePath.join(App.cachePath, 'comics_export.zip'); bool canceled = false; if (Directory(cacheDir).existsSync()) { Directory(cacheDir).deleteSync(recursive: true); } Directory(cacheDir).createSync(); var loadingController = showLoadingDialog( context, allowCancel: true, message: "${"Exporting".tl} $current/${comics.length}", withProgress: comics.length > 1, onCancel: () { canceled = true; }, ); try { var fileName = ""; // For each comic, export it to a file for (var comic in comics) { fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext); await export(comic, fileName); current++; if (comics.length > 1) { loadingController .setMessage("${"Exporting".tl} $current/${comics.length}"); loadingController.setProgress(current / comics.length); } if (canceled) { return; } } // For single comic, just save the file if (comics.length == 1) { await saveFile( file: File(fileName), filename: File(fileName).name, ); Directory(cacheDir).deleteSync(recursive: true); loadingController.close(); return; } // For multiple comics, compress the folder loadingController.setProgress(null); loadingController.setMessage("Compressing".tl); await ZipFile.compressFolderAsync(cacheDir, outFile); if (canceled) { File(outFile).deleteIgnoreError(); return; } } catch (e, s) { Log.error("Export Comics", e, s); context.showMessage(message: e.toString()); loadingController.close(); return; } finally { Directory(cacheDir).deleteIgnoreError(recursive: true); } await saveFile( file: File(outFile), filename: "comics_export.zip", ); loadingController.close(); File(outFile).deleteIgnoreError(); } } typedef ExportComicFunc = Future Function( LocalComic comic, String outFilePath);