From 8b1f13cd339ffd1f13386f4881580494aa38312f Mon Sep 17 00:00:00 2001 From: AnxuNA <41771421+axlmly@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:21:22 +0800 Subject: [PATCH] Add Favorite multiple selections (#66) --- assets/translation.json | 8 +- lib/components/comic.dart | 47 +- lib/foundation/favorites.dart | 60 +- lib/pages/favorites/favorites_page.dart | 1 + lib/pages/favorites/local_favorites_page.dart | 717 +++++++++++++----- 5 files changed, 614 insertions(+), 219 deletions(-) diff --git a/assets/translation.json b/assets/translation.json index b7e6389..d8226a3 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -235,7 +235,9 @@ "Also remove files on disk": "同时删除磁盘上的文件", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", - "No new version available": "没有新版本可用" + "No new version available": "没有新版本可用", + "Move": "移动", + "Move to favorites": "移动至收藏" }, "zh_TW": { "Home": "首頁", @@ -473,6 +475,8 @@ "Also remove files on disk": "同時刪除磁盤上的文件", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", - "No new version available": "沒有新版本可用" + "No new version available": "沒有新版本可用", + "Move": "移動", + "Move to favorites": "移動至收藏" } } \ No newline at end of file diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 08a95bc..488fd70 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -1,14 +1,14 @@ part of 'components.dart'; class ComicTile extends StatelessWidget { - const ComicTile({ - super.key, - required this.comic, - this.enableLongPressed = true, - this.badge, - this.menuOptions, - this.onTap, - }); + const ComicTile( + {super.key, + required this.comic, + this.enableLongPressed = true, + this.badge, + this.menuOptions, + this.onTap, + this.onLongPressed}); final Comic comic; @@ -20,6 +20,8 @@ class ComicTile extends StatelessWidget { final VoidCallback? onTap; + final VoidCallback? onLongPressed; + void _onTap() { if (onTap != null) { onTap!(); @@ -29,6 +31,14 @@ class ComicTile extends StatelessWidget { ?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); } + void _onLongPressed(context) { + if (onLongPressed != null) { + onLongPressed!(); + return; + } + onLongPress(context); + } + void onLongPress(BuildContext context) { var renderBox = context.findRenderObject() as RenderBox; var size = renderBox.size; @@ -181,7 +191,7 @@ class ComicTile extends StatelessWidget { return InkWell( borderRadius: BorderRadius.circular(12), onTap: _onTap, - onLongPress: enableLongPressed ? () => onLongPress(context) : null, + onLongPress: enableLongPressed ? () => _onLongPressed(context) : null, onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), @@ -231,7 +241,7 @@ class ComicTile extends StatelessWidget { borderRadius: BorderRadius.circular(8), onTap: _onTap, onLongPress: - enableLongPressed ? () => onLongPress(context) : null, + enableLongPressed ? () => _onLongPressed(context) : null, onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), child: Column( children: [ @@ -644,6 +654,7 @@ class SliverGridComics extends StatefulWidget { this.badgeBuilder, this.menuBuilder, this.onTap, + this.onLongPressed, this.selections}); final List comics; @@ -658,6 +669,8 @@ class SliverGridComics extends StatefulWidget { final void Function(Comic)? onTap; + final void Function(Comic)? onLongPressed; + @override State createState() => _SliverGridComicsState(); } @@ -708,6 +721,7 @@ class _SliverGridComicsState extends State { badgeBuilder: widget.badgeBuilder, menuBuilder: widget.menuBuilder, onTap: widget.onTap, + onLongPressed: widget.onLongPressed, ); } } @@ -719,6 +733,7 @@ class _SliverGridComics extends StatelessWidget { this.badgeBuilder, this.menuBuilder, this.onTap, + this.onLongPressed, this.selection, }); @@ -734,6 +749,8 @@ class _SliverGridComics extends StatelessWidget { final void Function(Comic)? onTap; + final void Function(Comic)? onLongPressed; + @override Widget build(BuildContext context) { return SliverGrid( @@ -750,14 +767,18 @@ class _SliverGridComics extends StatelessWidget { badge: badge, menuOptions: menuBuilder?.call(comics[index]), onTap: onTap != null ? () => onTap!(comics[index]) : null, + onLongPressed: onLongPressed != null + ? () => onLongPressed!(comics[index]) + : null, ); - if(selection == null) { + if (selection == null) { return comic; } - return Container( + return AnimatedContainer( + duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: isSelected - ? Theme.of(context).colorScheme.surfaceContainer + ? Theme.of(context).colorScheme.secondaryContainer : null, borderRadius: BorderRadius.circular(12), ), diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index 1ce41e7..e1a60fc 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -12,10 +12,7 @@ import 'comic_source/comic_source.dart'; import 'comic_type.dart'; String _getTimeString(DateTime time) { - return time - .toIso8601String() - .replaceFirst("T", " ") - .substring(0, 19); + return time.toIso8601String().replaceFirst("T", " ").substring(0, 19); } class FavoriteItem implements Comic { @@ -29,15 +26,14 @@ class FavoriteItem implements Comic { String coverPath; late String time; - FavoriteItem({ - required this.id, - required this.name, - required this.coverPath, - required this.author, - required this.type, - required this.tags, - DateTime? favoriteTime - }) { + FavoriteItem( + {required this.id, + required this.name, + required this.coverPath, + required this.author, + required this.type, + required this.tags, + DateTime? favoriteTime}) { var t = favoriteTime ?? DateTime.now(); time = _getTimeString(t); } @@ -355,7 +351,8 @@ class LocalFavoritesManager with ChangeNotifier { """, [folder, source, networkFolder]); } - bool isLinkedToNetworkFolder(String folder, String source, String networkFolder) { + bool isLinkedToNetworkFolder( + String folder, String source, String networkFolder) { var res = _db.select(""" select * from folder_sync where folder_name == ? and source_key == ? and source_folder == ?; @@ -436,6 +433,41 @@ class LocalFavoritesManager with ChangeNotifier { return true; } + void moveFavorite( + String sourceFolder, String targetFolder, String id, ComicType type) { + _modifiedAfterLastCache = true; + + if (!existsFolder(sourceFolder)) { + throw Exception("Source folder does not exist"); + } + if (!existsFolder(targetFolder)) { + throw Exception("Target folder does not exist"); + } + + var res = _db.select(""" + select * from "$targetFolder" + where id == ? and type == ?; + """, [id, type.value]); + + if (res.isNotEmpty) { + return; + } + + _db.execute(""" + insert into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order) + select id, name, author, type, tags, cover_path, time, ? + from "$sourceFolder" + where id == ? and type == ?; + """, [minValue(targetFolder) - 1, id, type.value]); + + _db.execute(""" + delete from "$sourceFolder" + where id == ? and type == ?; + """, [id, type.value]); + + notifyListeners(); + } + /// delete a folder void deleteFolder(String name) { _modifiedAfterLastCache = true; diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index f815506..afdf3b6 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -13,6 +13,7 @@ import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/download.dart'; +import 'package:venera/pages/comic_page.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index 628ed63..1ca103b 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -17,10 +17,30 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { String? networkSource; String? networkFolder; + Map selectedComics = {}; + + var selectedLocalFolders = {}; + + late List added = []; + + String keyword = ""; + + bool searchMode = false; + + bool multiSelectMode = false; + + int? lastSelectedIndex; + void updateComics() { - setState(() { - comics = LocalFavoritesManager().getAllComics(widget.folder); - }); + if (keyword.isEmpty) { + setState(() { + comics = LocalFavoritesManager().getAllComics(widget.folder); + }); + } else { + setState(() { + comics = LocalFavoritesManager().search(keyword); + }); + } } @override @@ -35,216 +55,533 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { @override Widget build(BuildContext context) { - return SmoothCustomScrollView( - slivers: [ - SliverAppbar( - leading: Tooltip( - message: "Folders".tl, - child: context.width <= _kTwoPanelChangeWidth - ? IconButton( - icon: const Icon(Icons.menu), - color: context.colorScheme.primary, - onPressed: favPage.showFolderSelector, - ) - : const SizedBox(), - ), - title: GestureDetector( - onTap: context.width < _kTwoPanelChangeWidth - ? favPage.showFolderSelector - : null, - child: Text(favPage.folder ?? "Unselected".tl), - ), - actions: [ - if (networkSource != null) + void selectAll() { + setState(() { + selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); + }); + } + + void invertSelection() { + setState(() { + comics.asMap().forEach((k, v) { + selectedComics[v] = !selectedComics.putIfAbsent(v, () => false); + }); + selectedComics.removeWhere((k, v) => !v); + }); + } + + var body = Scaffold( + body: SmoothCustomScrollView(slivers: [ + if (!searchMode && !multiSelectMode) + SliverAppbar( + leading: Tooltip( + message: "Folders".tl, + child: context.width <= _kTwoPanelChangeWidth + ? IconButton( + icon: const Icon(Icons.menu), + color: context.colorScheme.primary, + onPressed: favPage.showFolderSelector, + ) + : const SizedBox(), + ), + title: GestureDetector( + onTap: context.width < _kTwoPanelChangeWidth + ? favPage.showFolderSelector + : null, + child: Text(favPage.folder ?? "Unselected".tl), + ), + actions: [ + if (networkSource != null) + Tooltip( + message: "Sync".tl, + child: Flyout( + flyoutBuilder: (context) { + var sourceName = ComicSource.find(networkSource!)?.name ?? + networkSource!; + var text = "The folder is Linked to @source".tlParams({ + "source": sourceName, + }); + if (networkFolder != null && networkFolder!.isNotEmpty) { + text += "\n${"Source Folder".tl}: $networkFolder"; + } + return FlyoutContent( + title: "Sync".tl, + content: Text(text), + actions: [ + Button.filled( + child: Text("Update".tl), + onPressed: () { + context.pop(); + importNetworkFolder( + networkSource!, + widget.folder, + networkFolder!, + ).then( + (value) { + updateComics(); + }, + ); + }, + ), + ], + ); + }, + child: Builder(builder: (context) { + return IconButton( + icon: const Icon(Icons.sync), + onPressed: () { + Flyout.of(context).show(); + }, + ); + }), + ), + ), Tooltip( - message: "Sync".tl, - child: Flyout( - flyoutBuilder: (context) { - var sourceName = ComicSource.find(networkSource!)?.name ?? - networkSource!; - var text = "The folder is Linked to @source".tlParams({ - "source": sourceName, + message: "Search".tl, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + setState(() { + searchMode = true; }); - if(networkFolder != null && networkFolder!.isNotEmpty) { - text += "\n${"Source Folder".tl}: $networkFolder"; - } - return FlyoutContent( - title: "Sync".tl, - content: Text(text), - actions: [ - Button.filled( - child: Text("Update".tl), - onPressed: () { - context.pop(); - importNetworkFolder( - networkSource!, + }, + ), + ), + MenuButton( + entries: [ + MenuEntry( + icon: Icons.delete_outline, + text: "Delete Folder".tl, + onClick: () { + showConfirmDialog( + context: App.rootContext, + title: "Delete".tl, + content: + "Are you sure you want to delete this folder?".tl, + onConfirm: () { + favPage.setFolder(false, null); + LocalFavoritesManager().deleteFolder(widget.folder); + favPage.folderList?.updateFolders(); + }, + ); + }), + MenuEntry( + icon: Icons.edit_outlined, + text: "Rename".tl, + onClick: () { + showInputDialog( + context: App.rootContext, + title: "Rename".tl, + hintText: "New Name".tl, + onConfirm: (value) { + var err = validateFolderName(value.toString()); + if (err != null) { + return err; + } + LocalFavoritesManager().rename( widget.folder, - networkFolder!, - ).then( - (value) { - updateComics(); + value.toString(), + ); + favPage.folderList?.updateFolders(); + favPage.setFolder(false, value.toString()); + return null; + }, + ); + }), + MenuEntry( + icon: Icons.reorder, + text: "Reorder".tl, + onClick: () { + context.to( + () { + return _ReorderComicsPage( + widget.folder, + (comics) { + this.comics = comics; }, ); }, - ), - ], - ); - }, - child: Builder(builder: (context) { - return IconButton( - icon: const Icon(Icons.sync), - onPressed: () { - Flyout.of(context).show(); - }, - ); - }), - ), + ).then( + (value) { + if (mounted) { + setState(() {}); + } + }, + ); + }), + MenuEntry( + icon: Icons.upload_file, + text: "Export".tl, + onClick: () { + var json = LocalFavoritesManager().folderToJson( + widget.folder, + ); + saveFile( + data: utf8.encode(json), + filename: "${widget.folder}.json", + ); + }), + MenuEntry( + icon: Icons.update, + text: "Update Comics Info".tl, + onClick: () { + updateComicsInfo(widget.folder).then((newComics) { + if (mounted) { + setState(() { + comics = newComics; + }); + } + }); + }), + ], ), - MenuButton( - entries: [ + ], + ) + else if (multiSelectMode) + SliverAppbar( + leading: Tooltip( + message: "Cancel".tl, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + multiSelectMode = false; + selectedComics.clear(); + }); + }, + ), + ), + title: Text( + "Selected @c comics".tlParams({"c": selectedComics.length})), + actions: [ + MenuButton(entries: [ + MenuEntry( + icon: Icons.star, + text: "Add to favorites".tl, + onClick: () => favoriteOption('move')), + MenuEntry( + icon: Icons.drive_file_move, + text: "Move to favorites".tl, + onClick: () => favoriteOption('add')), + MenuEntry( + icon: Icons.select_all, + text: "Select All".tl, + onClick: selectAll), + MenuEntry( + icon: Icons.deselect, + text: "Deselect".tl, + onClick: _cancel), + MenuEntry( + icon: Icons.flip, + text: "Invert Selection".tl, + onClick: invertSelection), MenuEntry( icon: Icons.delete_outline, text: "Delete Folder".tl, onClick: () { showConfirmDialog( - context: App.rootContext, + context: context, title: "Delete".tl, content: - "Are you sure you want to delete this folder?".tl, + "Are you sure you want to delete this comic?".tl, onConfirm: () { - favPage.setFolder(false, null); - LocalFavoritesManager().deleteFolder(widget.folder); - favPage.folderList?.updateFolders(); + _deleteComicWithId(); }, ); }), - MenuEntry( - icon: Icons.edit_outlined, - text: "Rename".tl, - onClick: () { - showInputDialog( - context: App.rootContext, - title: "Rename".tl, - hintText: "New Name".tl, - onConfirm: (value) { - var err = validateFolderName(value.toString()); - if (err != null) { - return err; - } - LocalFavoritesManager().rename( - widget.folder, - value.toString(), - ); - favPage.folderList?.updateFolders(); - favPage.setFolder(false, value.toString()); - return null; - }, - ); - }), - MenuEntry( - icon: Icons.reorder, - text: "Reorder".tl, - onClick: () { - context.to( - () { - return _ReorderComicsPage( - widget.folder, - (comics) { - this.comics = comics; - }, - ); - }, - ).then( - (value) { - if (mounted) { - setState(() {}); - } - }, - ); - }), - MenuEntry( - icon: Icons.upload_file, - text: "Export".tl, - onClick: () { - var json = LocalFavoritesManager().folderToJson( - widget.folder, - ); - saveFile( - data: utf8.encode(json), - filename: "${widget.folder}.json", - ); - }), - MenuEntry( - icon: Icons.update, - text: "Update Comics Info".tl, - onClick: () { - updateComicsInfo(widget.folder).then((newComics) { - if (mounted) { - setState(() { - comics = newComics; - }); - } - }); - }), - MenuEntry( - icon: Icons.download, - text: "Download All".tl, - onClick: () async { - int count = 0; - for (var c in comics) { - if (await LocalManager().isDownloaded(c.id, c.type)) { - continue; - } - var comicSource = c.type.comicSource; - if (comicSource == null) { - continue; - } - LocalManager().addTask(ImagesDownloadTask( - source: comicSource, - comicId: c.id, - comic: null, - comicTitle: c.name, - )); - count++; - } - context.showMessage( - message: "Added @count comics to download queue." - .tlParams({ - "count": count.toString(), - })); - }), - ], - ), - ], - ), - SliverGridComics( - comics: comics, - menuBuilder: (c) { - return [ - MenuEntry( - icon: Icons.delete_outline, - text: "Delete".tl, - onClick: () { - showConfirmDialog( - context: context, - title: "Delete".tl, - content: "Are you sure you want to delete this comic?".tl, - onConfirm: () { - LocalFavoritesManager().deleteComicWithId( - widget.folder, - c.id, - (c as FavoriteItem).type, - ); - updateComics(); - }, - ); + ]), + ], + ) + else if (searchMode) + SliverAppbar( + leading: Tooltip( + message: "Cancel".tl, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + searchMode = false; + keyword = ""; + updateComics(); + }); }, ), - ]; + ), + title: TextField( + autofocus: true, + decoration: InputDecoration( + hintText: "Search".tl, + border: InputBorder.none, + ), + onChanged: (v) { + keyword = v; + updateComics(); + }, + ), + ), + SliverGridComics( + comics: comics, + selections: selectedComics, + onTap: multiSelectMode + ? (c) { + setState(() { + if (selectedComics.containsKey(c as FavoriteItem)) { + selectedComics.remove(c); + _checkExitSelectMode(); + } else { + selectedComics[c] = true; + } + lastSelectedIndex = comics.indexOf(c); + }); + } + : (c) { + App.mainNavigatorKey?.currentContext + ?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey)); + }, + onLongPressed: (c) { + setState(() { + if (!multiSelectMode) { + multiSelectMode = true; + if (!selectedComics.containsKey(c as FavoriteItem)) { + selectedComics[c] = true; + } + lastSelectedIndex = comics.indexOf(c); + } else { + if (lastSelectedIndex != null) { + int start = lastSelectedIndex!; + int end = comics.indexOf(c as FavoriteItem); + if (start > end) { + int temp = start; + start = end; + end = temp; + } + + for (int i = start; i <= end; i++) { + if (i == lastSelectedIndex) continue; + + var comic = comics[i]; + if (selectedComics.containsKey(comic)) { + selectedComics.remove(comic); + } else { + selectedComics[comic] = true; + } + } + } + lastSelectedIndex = comics.indexOf(c as FavoriteItem); + } + _checkExitSelectMode(); + }); }, ), - ], + ]), ); + return PopScope( + canPop: !multiSelectMode && !searchMode, + onPopInvokedWithResult: (didPop, result) { + if (multiSelectMode) { + setState(() { + multiSelectMode = false; + selectedComics.clear(); + }); + } else if (searchMode) { + setState(() { + searchMode = false; + keyword = ""; + updateComics(); + }); + } + }, + child: body, + ); + } + + void favoriteOption(String option) { + var targetFolders = LocalFavoritesManager() + .folderNames + .where((folder) => folder != favPage.folder) + .toList(); + + showDialog( + context: App.rootContext, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 50), + child: Container( + constraints: + const BoxConstraints(maxHeight: 700, maxWidth: 500), + child: Column( + children: [ + Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.0), + ), + ), + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + favPage.folder ?? "Unselected".tl, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: targetFolders.length + 1, + itemBuilder: (context, index) { + if (index == targetFolders.length) { + return SizedBox( + height: 36, + child: Center( + child: TextButton( + onPressed: () { + newFolder().then((v) { + setState(() { + targetFolders = + LocalFavoritesManager() + .folderNames + .where((folder) => + folder != + favPage.folder) + .toList(); + }); + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 20), + const SizedBox(width: 4), + Text("New Folder".tl), + ], + ), + ), + ), + ); + } + var folder = targetFolders[index]; + var disabled = false; + if (selectedLocalFolders.isNotEmpty) { + if (added.contains(folder) && + !added + .contains(selectedLocalFolders.first)) { + disabled = true; + } else if (!added.contains(folder) && + added + .contains(selectedLocalFolders.first)) { + disabled = true; + } + } + return CheckboxListTile( + title: Row( + children: [ + Text(folder), + const SizedBox(width: 8), + ], + ), + value: selectedLocalFolders.contains(folder), + onChanged: disabled + ? null + : (v) { + setState(() { + if (v!) { + selectedLocalFolders.add(folder); + } else { + selectedLocalFolders.remove(folder); + } + }); + }, + ); + }, + ), + ), + Center( + child: FilledButton( + onPressed: () { + if (selectedLocalFolders.isEmpty) { + return; + } + if (option == 'move') { + for (var c in selectedComics.keys) { + for (var s in selectedLocalFolders) { + LocalFavoritesManager().moveFavorite( + favPage.folder as String, + s, + c.id, + (c as FavoriteItem).type); + } + } + } else { + for (var c in selectedComics.keys) { + for (var s in selectedLocalFolders) { + LocalFavoritesManager().addComic( + s, + FavoriteItem( + id: c.id, + name: c.title, + coverPath: c.cover, + author: c.subtitle ?? '', + type: ComicType((c.sourceKey == 'local' + ? 0 + : c.sourceKey.hashCode)), + tags: c.tags ?? [], + ), + ); + } + } + } + context.pop(); + updateComics(); + _cancel(); + }, + child: + Text(option == 'move' ? "Move".tl : "Add".tl), + ).paddingVertical(16), + ), + ], + ), + ), + )); + }, + ); + }, + ); + } + + void _checkExitSelectMode() { + if (selectedComics.isEmpty) { + setState(() { + multiSelectMode = false; + }); + } + } + + void _cancel() { + setState(() { + selectedComics.clear(); + multiSelectMode = false; + }); + } + + void _deleteComicWithId() { + for (var c in selectedComics.keys) { + LocalFavoritesManager().deleteComicWithId( + widget.folder, + c.id, + (c as FavoriteItem).type, + ); + } + updateComics(); + _cancel(); } }