diff --git a/.gitignore b/.gitignore index d0754d1..4b70806 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ migrate_working_dir/ *.ipr *.iws .idea/ +.vscode/ # The .vscode folder contains launch configuration and tasks you configure in # VS Code which you may wish to be included in version control, so this line diff --git a/android/gradle.properties b/android/gradle.properties index 5f5d39d..85eda9d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -3,4 +3,4 @@ android.useAndroidX=true android.enableJetifier=true android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false -android.nonFinalResIds=false +android.nonFinalResIds=false \ No newline at end of file diff --git a/assets/translation.json b/assets/translation.json index 24a5e6c..bbce784 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -18,7 +18,7 @@ "help": "帮助", "Select": "选择", "Selected @a comics": "已选择 @a 部漫画", - "Imported @a comics": "已导入 @a 部漫画", + "Imported @a comics, loaded @b pages, received @c comics": "已导入 @a 部漫画, 加载 @b 页, 接收到 @c 部漫画", "Downloading": "下载中", "Back": "后退", "Delete": "删除", @@ -41,6 +41,7 @@ "Select a folder": "选择一个文件夹", "Folder": "文件夹", "Confirm": "确认", + "Reversed successfully": "反转成功", "Remove comic from favorite?": "从收藏中移除漫画?", "Move": "移动", "Move to folder": "移动到文件夹", @@ -164,7 +165,7 @@ "Date Desc": "日期降序", "Start": "开始", "Export App Data": "导出应用数据", - "Import App Data": "导入应用数据", + "Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)", "Export": "导出", "Download Threads": "下载线程数", "Update Time": "更新时间", @@ -248,6 +249,47 @@ "Export as pdf": "导出为pdf", "Export as epub": "导出为epub", "Aggregated Search": "聚合搜索", + "Local comic collection is not supported at present": "本地收藏暂不支持", + "The cover cannot be uncollected here": "封面不能在此取消收藏", + "Uncollected the image": "取消收藏图片", + "Successfully collected": "收藏成功", + "Collect the image": "收藏图片", + "Quick collect image": "快速收藏图片", + "Not enable": "不启用", + "Double Tap": "双击", + "Swipe": "滑动", + "On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在图片浏览页面, 你可以根据你的阅读模式横滑或者竖滑快速收藏图片", + "Calculate your favorite from @a comics and @b images": "从 @a 本漫画和 @b 张图片中, 计算你最喜欢的", + "After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括号后是图片数量或图片数比漫画页数", + "The chapter order of the comic may have changed, temporarily not supported for collection": "漫画的章节顺序可能发生了变化, 暂不支持收藏此章节", + "Author: ": "作者: ", + "Tags: ": "标签: ", + "Comics(number): ": "漫画(数量): ", + "Comics(percentage): ": "漫画(比例): ", + "Time Filter": "时间筛选", + "Image Favorites Greater Than": "图片收藏数大于", + "Collection time": "收藏时间", + "favoritesCompareComicPages": "收藏数与漫画页数比较", + "Cover": "封面", + "Page @a": "第 @a 页", + "Time Asc": "时间升序", + "Time Desc": "时间降序", + "Favorite Num": "收藏数", + "Favorite Num Compare Comic Pages": "收藏数比漫画页数", + "All": "全部", + "Last Week": "上周", + "Last Month": "上月", + "Last Half Year": "半年", + "Last Year": "一年", + "Filter": "筛选", + "Image Favorites": "图片收藏", + "Title": "标题", + "@a Cover": "@a 封面", + "Photo View": "图片浏览", + "Delete @a images": "删除 @a 张图片", + "Update the page number by the latest collection": "按最新收藏更新页数", + "Copy the title successfully": "复制标题成功", + "The comic is invalid, please long press to delete, you can double click the title to copy": "该漫画已失效, 请长按删除, 可以双击标题进行复制", "No search results found": "未找到搜索结果", "Added @c comics to download queue." : "已添加 @c 本漫画到下载队列", "Download started": "下载已开始", @@ -265,7 +307,10 @@ "Aggregated": "聚合", "Default Search Target": "默认搜索目标", "Auto Language Filters": "自动语言筛选", - "Check for updates on startup": "启动时检查更新" + "Check for updates on startup": "启动时检查更新", + "Start Time": "开始时间", + "End Time": "结束时间", + "Custom": "自定义" }, "zh_TW": { "Home": "首頁", @@ -287,7 +332,7 @@ "help": "幫助", "Select": "選擇", "Selected @a comics": "已選擇 @a 部漫畫", - "Imported @a comics": "已匯入 @a 部漫畫", + "Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 加載 @b 頁, 接收到 @c 部漫畫", "Downloading": "下載中", "Back": "後退", "Delete": "刪除", @@ -431,8 +476,9 @@ "Date": "日期", "Date Desc": "日期降序", "Start": "開始", + "Reversed successfully": "反轉成功", "Export App Data": "匯出應用數據", - "Import App Data": "匯入應用數據", + "Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)", "Export": "匯出", "Download Threads": "下載線程數", "Update Time": "更新時間", @@ -520,6 +566,47 @@ "Added @c comics to download queue." : "已添加 @c 本漫畫到下載隊列", "Download started": "下載已開始", "Click favorite": "點擊收藏", + "Local comic collection is not supported at present": "本地收藏暫不支持", + "The cover cannot be uncollected here": "封面不能在此取消收藏", + "Uncollected the image": "取消收藏圖片", + "Successfully collected": "收藏成功", + "Collect the image": "收藏圖片", + "Quick collect image": "快速收藏圖片", + "On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在圖片瀏覽頁面, 你可以根據你的閱讀模式橫向或者縱向滑動快速收藏圖片", + "Calculate your favorite from @a comics and @b images": "從 @a 本漫畫和 @b 張圖片中, 計算你最喜歡的", + "After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括號後是圖片數量或圖片數比漫畫頁數", + "The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支持收藏此章節", + "Author: ": "作者: ", + "Tags: ": "標籤: ", + "Comics(number): ": "漫畫(數量): ", + "Comics(percentage): ": "漫畫(比例): ", + "Time Filter": "時間篩選", + "Image Favorites Greater Than": "圖片收藏數大於", + "Collection time": "收藏時間", + "Not enable": "不启用", + "Double Tap": "雙擊", + "Swipe": "滑動", + "favoritesCompareComicPages": "收藏數與漫畫頁數比較", + "Cover": "封面", + "Page @a": "第 @a 頁", + "Time Asc": "時間升序", + "Time Desc": "時間降序", + "Favorite Num": "收藏數", + "Favorite Num Compare Comic Pages": "收藏數比漫畫頁數", + "All": "全部", + "Last Week": "上周", + "Last Month": "上月", + "Last Half Year": "半年", + "Last Year": "一年", + "Filter": "篩選", + "Image Favorites": "圖片收藏", + "Title": "標題", + "@a Cover": "@a 封面", + "Photo View": "圖片瀏覽", + "Delete @a images": "刪除 @a 張圖片", + "Update the page number by the latest collection": "按最新收藏更新頁數", + "Copy the title successfully": "複製標題成功", + "The comic is invalid, please long press to delete, you can double click the title to copy": "該漫畫已失效, 請長按刪除, 可以雙擊標題進行複製", "End": "末尾", "None": "無", "View Detail": "查看詳情", @@ -533,6 +620,9 @@ "Aggregated": "聚合", "Default Search Target": "默認搜索目標", "Auto Language Filters": "自動語言篩選", - "Check for updates on startup": "啟動時檢查更新" + "Check for updates on startup": "啟動時檢查更新", + "Start Time": "開始時間", + "End Time": "結束時間", + "Custom": "自定義" } } \ No newline at end of file diff --git a/lib/components/appbar.dart b/lib/components/appbar.dart index 841d5fe..56f5dc4 100644 --- a/lib/components/appbar.dart +++ b/lib/components/appbar.dart @@ -577,6 +577,51 @@ class _IndicatorPainter extends CustomPainter { } } +class TabViewBody extends StatefulWidget { + /// Create a tab view body, which will show the child at the current tab index. + const TabViewBody({super.key, required this.children, this.controller}); + + final List children; + + final TabController? controller; + + @override + State createState() => _TabViewBodyState(); +} + +class _TabViewBodyState extends State { + late TabController _controller; + + int _currentIndex = 0; + + void updateIndex() { + if (_controller.index != _currentIndex) { + setState(() { + _currentIndex = _controller.index; + }); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _controller = widget.controller ?? DefaultTabController.of(context); + _controller.addListener(updateIndex); + } + + @override + void dispose() { + super.dispose(); + _controller.removeListener(updateIndex); + } + + @override + Widget build(BuildContext context) { + return widget.children[_currentIndex]; + } +} + + class SearchBarController { _SearchBarMixin? _state; diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 95c8818..71799f7 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -726,9 +726,16 @@ class _SliverGridComicsState extends State { comics.add(comic); } } + HistoryManager().addListener(update); super.initState(); } + @override + void dispose() { + HistoryManager().removeListener(update); + super.dispose(); + } + void update() { setState(() { comics.clear(); diff --git a/lib/components/image.dart b/lib/components/image.dart index 0ec5edf..4a95eb8 100644 --- a/lib/components/image.dart +++ b/lib/components/image.dart @@ -22,6 +22,7 @@ class AnimatedImage extends StatefulWidget { this.filterQuality = FilterQuality.medium, this.isAntiAlias = false, this.part, + this.onError, Map? headers, int? cacheWidth, int? cacheHeight, @@ -63,6 +64,8 @@ class AnimatedImage extends StatefulWidget { final ImagePart? part; + final Function? onError; + static void clear() => _AnimatedImageState.clear(); @override @@ -169,6 +172,8 @@ class _AnimatedImageState extends State _handleImageFrame, onChunk: _handleImageChunk, onError: (Object error, StackTrace? stackTrace) { + // 图片加错错误回调 + widget.onError?.call(error, stackTrace); setState(() { _lastException = error; }); @@ -271,7 +276,7 @@ class _AnimatedImageState extends State Widget result; if (_imageInfo != null) { - if(widget.part != null) { + if (widget.part != null) { return CustomPaint( painter: ImagePainter( image: _imageInfo!.image, diff --git a/lib/components/message.dart b/lib/components/message.dart index e5038de..cfa07ff 100644 --- a/lib/components/message.dart +++ b/lib/components/message.dart @@ -5,6 +5,7 @@ void showToast({ required BuildContext context, Widget? icon, Widget? trailing, + int? seconds, }) { var newEntry = OverlayEntry( builder: (context) => _ToastOverlay( @@ -17,7 +18,7 @@ void showToast({ state?.addOverlay(newEntry); - Timer(const Duration(seconds: 2), () => state?.remove(newEntry)); + Timer(Duration(seconds: seconds ?? 2), () => state?.remove(newEntry)); } class _ToastOverlay extends StatelessWidget { @@ -48,7 +49,8 @@ class _ToastOverlay extends StatelessWidget { color: Theme.of(context).colorScheme.onInverseSurface), child: IntrinsicWidth( child: Container( - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), + padding: + const EdgeInsets.symmetric(vertical: 6, horizontal: 16), constraints: BoxConstraints( maxWidth: context.width - 32, ), @@ -241,13 +243,13 @@ LoadingDialogController showLoadingDialog(BuildContext context, class ContentDialog extends StatelessWidget { const ContentDialog({ super.key, - required this.title, + this.title, // 如果不传 title 将不会展示 required this.content, this.dismissible = true, this.actions = const [], }); - final String title; + final String? title; final Widget content; @@ -261,14 +263,16 @@ class ContentDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Appbar( - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: dismissible ? context.pop : null, - ), - title: Text(title), - backgroundColor: Colors.transparent, - ), + title != null + ? Appbar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: dismissible ? context.pop : null, + ), + title: Text(title!), + backgroundColor: Colors.transparent, + ) + : const SizedBox.shrink(), this.content, const SizedBox(height: 16), Row( @@ -360,7 +364,7 @@ Future showInputDialog({ } else { result = futureOr; } - if(result == null) { + if (result == null) { context.pop(); } else { setState(() => error = result.toString()); diff --git a/lib/components/scroll.dart b/lib/components/scroll.dart index 98fd287..9eeedd1 100644 --- a/lib/components/scroll.dart +++ b/lib/components/scroll.dart @@ -102,13 +102,36 @@ class _SmoothScrollProviderState extends State { duration: _fastAnimationDuration, curve: Curves.linear); } }, - child: widget.builder( - context, - _controller, - _isMouseScroll - ? const NeverScrollableScrollPhysics() - : const BouncingScrollPhysics(), + child: ScrollControllerProvider._( + controller: _controller, + child: widget.builder( + context, + _controller, + _isMouseScroll + ? const NeverScrollableScrollPhysics() + : const BouncingScrollPhysics(), + ), ), ); } } + +class ScrollControllerProvider extends InheritedWidget { + const ScrollControllerProvider._({ + required this.controller, + required super.child, + }); + + final ScrollController controller; + + static ScrollController of(BuildContext context) { + final ScrollControllerProvider? provider = + context.dependOnInheritedWidgetOfExactType(); + return provider!.controller; + } + + @override + bool updateShouldNotify(ScrollControllerProvider oldWidget) { + return oldWidget.controller != controller; + } +} diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index b193f81..648e841 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -143,6 +143,7 @@ class _Settings with ChangeNotifier { 'quickFavorite': null, 'enableTurnPageByVolumeKey': true, 'enableClockAndBatteryInfoInReader': true, + 'quickCollectImage': 'No', // No, DoubleTap, Swipe 'authorizationRequired': false, 'onClickFavorite': 'viewDetail', // viewDetail, read 'enableDnsOverrides': false, @@ -179,4 +180,4 @@ const _defaultCustomImageProcessing = ''' async function processImage(image, cid, eid) { return image; } -'''; \ No newline at end of file +'''; diff --git a/lib/foundation/comic_source/favorites.dart b/lib/foundation/comic_source/favorites.dart index 8fe8651..a6423f5 100644 --- a/lib/foundation/comic_source/favorites.dart +++ b/lib/foundation/comic_source/favorites.dart @@ -10,6 +10,10 @@ class FavoriteData { final bool multiFolder; + // 这个收藏时间新旧顺序, 是为了最小成本同步远端的收藏, 只拉取远程最新收藏的漫画, 就不需要全拉取一遍了 + // 如果为 null, 当做从新到旧 + final bool? isOldToNewSort; + final Future>> Function(int page, [String? folder])? loadComic; @@ -44,6 +48,7 @@ class FavoriteData { this.addFolder, this.allFavoritesId, this.addOrDelFavorite, + this.isOldToNewSort, }); } diff --git a/lib/foundation/comic_source/models.dart b/lib/foundation/comic_source/models.dart index 7a137d6..7f08c83 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -73,7 +73,8 @@ class Comic { this.sourceKey, this.maxPage, this.language, - ): favoriteId = null, stars = null; + ) : favoriteId = null, + stars = null; Map toJson() { return { @@ -231,6 +232,34 @@ class ComicDetails with HistoryMixin { String get id => comicId; ComicType get comicType => ComicType(sourceKey.hashCode); + + /// Convert tags map to plain list + List get plainTags { + var res = []; + tags.forEach((key, value) { + res.addAll(value.map((e) => "$key:$e")); + }); + return res; + } + + /// Find the first author tag + String? findAuthor() { + var authorNamespaces = [ + "author", + "authors", + "artist", + "artists", + "作者", + "画师" + ]; + for (var entry in tags.entries) { + if (authorNamespaces.contains(entry.key.toLowerCase()) && + entry.value.isNotEmpty) { + return entry.value.first; + } + } + return null; + } } class ArchiveInfo { @@ -242,4 +271,4 @@ class ArchiveInfo { : title = json["title"], description = json["description"], id = json["id"]; -} \ No newline at end of file +} diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index 36218e0..ea1b3b1 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -193,7 +193,7 @@ class ComicSourceParser { login = (account, pwd) async { try { await JsEngine().runCode(""" - ComicSource.sources.$_key.account.login(${jsonEncode(account)}, + ComicSource.sources.$_key.account.login(${jsonEncode(account)}, ${jsonEncode(pwd)}) """); var source = ComicSource.find(_key!)!; @@ -502,9 +502,9 @@ class ComicSourceParser { try { var res = await JsEngine().runCode(""" ComicSource.sources.$_key.categoryComics.load( - ${jsonEncode(category)}, - ${jsonEncode(param)}, - ${jsonEncode(options)}, + ${jsonEncode(category)}, + ${jsonEncode(param)}, + ${jsonEncode(options)}, ${jsonEncode(page)} ) """); @@ -618,6 +618,7 @@ class ComicSourceParser { if (!_checkExists("favorites")) return null; final bool multiFolder = _getValue("favorites.multiFolder"); + final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort"); Future> retryZone(Future> Function() func) async { if (!ComicSource.find(_key!)!.isLogged) { @@ -770,6 +771,7 @@ class ComicSourceParser { addFolder: addFolder, deleteFolder: deleteFolder, addOrDelFavorite: addOrDelFavFunc, + isOldToNewSort: isOldToNewSort, ); } diff --git a/lib/foundation/consts.dart b/lib/foundation/consts.dart index 4c9211c..1e1cdf2 100644 --- a/lib/foundation/consts.dart +++ b/lib/foundation/consts.dart @@ -1,6 +1,17 @@ +/// If window width is less than this value, it is considered as mobile. const changePoint = 600; +/// If window width is less than this value, it is considered as tablet. +/// +/// If it is more than this value, it is considered as desktop. const changePoint2 = 1300; +/// Default user agent for http requests. const webUA = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; \ No newline at end of file + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; + +/// Pages for all comics is started from this value. +const firstPage = 1; + +/// Chapters for all comics is started from this value. +const firstChapter = 1; \ No newline at end of file diff --git a/lib/foundation/context.dart b/lib/foundation/context.dart index 00ae620..b8b1a58 100644 --- a/lib/foundation/context.dart +++ b/lib/foundation/context.dart @@ -36,6 +36,8 @@ extension Navigation on BuildContext { Brightness get brightness => Theme.of(this).brightness; + bool get isDarkMode => brightness == Brightness.dark; + void showMessage({required String message}) { showToast(message: message, context: this); } diff --git a/lib/foundation/history.dart b/lib/foundation/history.dart index 1867264..920f1b6 100644 --- a/lib/foundation/history.dart +++ b/lib/foundation/history.dart @@ -1,12 +1,23 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:sqlite3/sqlite3.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; +import 'package:venera/foundation/image_provider/image_favorites_provider.dart'; +import 'package:venera/foundation/log.dart'; +import 'package:venera/utils/ext.dart'; import 'package:venera/utils/translations.dart'; import 'app.dart'; +import 'consts.dart'; + +part "image_favorites.dart"; typedef HistoryType = ComicType; @@ -37,7 +48,7 @@ class History implements Comic { @override String cover; - + int ep; int page; @@ -201,7 +212,12 @@ class HistoryManager with ChangeNotifier { Map? _cachedHistory; + bool isInitialized = false; + Future init() async { + if (isInitialized) { + return; + } _db = sqlite3.open("${App.dataPath}/history.db"); _db.execute(""" @@ -220,6 +236,8 @@ class HistoryManager with ChangeNotifier { """); notifyListeners(); + ImageFavoriteManager().init(); + isInitialized = true; } /// add history. if exists, update time. @@ -275,7 +293,7 @@ class HistoryManager with ChangeNotifier { } History? findSync(String id, ComicType type) { - if(_cachedHistory == null) { + if (_cachedHistory == null) { updateCache(); } if (!_cachedHistory!.containsKey(id)) { diff --git a/lib/foundation/image_favorites.dart b/lib/foundation/image_favorites.dart new file mode 100644 index 0000000..865ef9f --- /dev/null +++ b/lib/foundation/image_favorites.dart @@ -0,0 +1,535 @@ +part of "history.dart"; + +class ImageFavorite { + final String eid; + final String id; // 漫画id + final int ep; + final String epName; + final String sourceKey; + String imageKey; + int page; + bool? isAutoFavorite; + + ImageFavorite( + this.page, + this.imageKey, + this.isAutoFavorite, + this.eid, + this.id, + this.ep, + this.sourceKey, + this.epName, + ); + + Map toJson() { + return { + 'page': page, + 'imageKey': imageKey, + 'isAutoFavorite': isAutoFavorite, + 'eid': eid, + 'id': id, + 'ep': ep, + 'sourceKey': sourceKey, + 'epName': epName, + }; + } + + ImageFavorite.fromJson(Map json) + : page = json['page'], + imageKey = json['imageKey'], + isAutoFavorite = json['isAutoFavorite'], + eid = json['eid'], + id = json['id'], + ep = json['ep'], + sourceKey = json['sourceKey'], + epName = json['epName']; + + ImageFavorite copyWith({ + int? page, + String? imageKey, + bool? isAutoFavorite, + String? eid, + String? id, + int? ep, + String? sourceKey, + String? epName, + }) { + return ImageFavorite( + page ?? this.page, + imageKey ?? this.imageKey, + isAutoFavorite ?? this.isAutoFavorite, + eid ?? this.eid, + id ?? this.id, + ep ?? this.ep, + sourceKey ?? this.sourceKey, + epName ?? this.epName, + ); + } + + @override + bool operator ==(Object other) { + return other is ImageFavorite && + other.id == id && + other.sourceKey == sourceKey && + other.page == page && + other.eid == eid && + other.ep == ep; + } + + @override + int get hashCode => Object.hash(id, sourceKey, page, eid, ep); +} + +class ImageFavoritesEp { + // 小心拷贝等多章节的可能更新章节顺序 + String eid; + final int ep; + int maxPage; + String epName; + List imageFavorites; + + ImageFavoritesEp( + this.eid, this.ep, this.imageFavorites, this.epName, this.maxPage); + + // 是否有封面 + bool get isHasFirstPage { + return imageFavorites[0].page == firstPage; + } + + // 是否都有imageKey + bool get isHasImageKey { + return imageFavorites.every((e) => e.imageKey != ""); + } + + Map toJson() { + return { + 'eid': eid, + 'ep': ep, + 'maxPage': maxPage, + 'epName': epName, + 'imageFavorites': imageFavorites.map((e) => e.toJson()).toList(), + }; + } +} + +class ImageFavoritesComic { + final String id; + final String title; + String subTitle; + String author; + final String sourceKey; + + // 不一定是真的这本漫画的所有页数, 如果是多章节的时候 + int maxPage; + List tags; + List translatedTags; + final DateTime time; + List imageFavoritesEp; + final Map other; + + ImageFavoritesComic( + this.id, + this.imageFavoritesEp, + this.title, + this.sourceKey, + this.tags, + this.translatedTags, + this.time, + this.author, + this.other, + this.subTitle, + this.maxPage, + ); + + // 是否都有imageKey + bool get isAllHasImageKey { + return imageFavoritesEp + .every((e) => e.imageFavorites.every((j) => j.imageKey != "")); + } + + int get maxPageFromEp { + int temp = 0; + for (var e in imageFavoritesEp) { + temp += e.maxPage; + } + return temp; + } + + // 是否都有封面 + bool get isAllHasFirstPage { + return imageFavoritesEp.every((e) => e.isHasFirstPage); + } + + Iterable get images sync*{ + for (var e in imageFavoritesEp) { + yield* e.imageFavorites; + } + } + + @override + bool operator ==(Object other) { + return other is ImageFavoritesComic && + other.id == id && + other.sourceKey == sourceKey; + } + + @override + int get hashCode => Object.hash(id, sourceKey); + + factory ImageFavoritesComic.fromRow(Row r) { + var tempImageFavoritesEp = jsonDecode(r["image_favorites_ep"]); + List finalImageFavoritesEp = []; + tempImageFavoritesEp.forEach((i) { + List temp = []; + i["imageFavorites"].forEach((j) { + temp.add(ImageFavorite( + j["page"], + j["imageKey"], + j["isAutoFavorite"], + i["eid"], + r["id"], + i["ep"], + r["source_key"], + i["epName"], + )); + }); + finalImageFavoritesEp.add(ImageFavoritesEp( + i["eid"], i["ep"], temp, i["epName"], i["maxPage"] ?? 1)); + }); + return ImageFavoritesComic( + r["id"], + finalImageFavoritesEp, + r["title"], + r["source_key"], + r["tags"].split(","), + r["translated_tags"].split(","), + DateTime.fromMillisecondsSinceEpoch(r["time"]), + r["author"], + jsonDecode(r["other"]), + r["sub_title"], + r["max_page"], + ); + } +} + +class ImageFavoriteManager with ChangeNotifier { + Database get _db => HistoryManager()._db; + + List get comics => getAll(); + + static ImageFavoriteManager? _cache; + + ImageFavoriteManager._(); + + factory ImageFavoriteManager() => (_cache ??= ImageFavoriteManager._()); + + /// 检查表image_favorites是否存在, 不存在则创建 + void init() { + _db.execute("CREATE TABLE IF NOT EXISTS image_favorites (" + "id TEXT," + "title TEXT NOT NULL," + "sub_title TEXT," + "author TEXT," + "tags TEXT," + "translated_tags TEXT," + "time int," + "max_page int," + "source_key TEXT NOT NULL," + "image_favorites_ep TEXT NOT NULL," + "other TEXT NOT NULL," + "PRIMARY KEY (id,source_key)" + ");"); + } + + // 做排序和去重的操作 + void addOrUpdateOrDelete(ImageFavoritesComic favorite, [bool notify = true]) { + // 没有章节了就删掉 + if (favorite.imageFavoritesEp.isEmpty) { + _db.execute(""" + delete from image_favorites + where id == ? and source_key == ?; + """, [favorite.id, favorite.sourceKey]); + } else { + // 去重章节 + List tempImageFavoritesEp = []; + for (var e in favorite.imageFavoritesEp) { + int index = tempImageFavoritesEp.indexWhere((i) { + return i.ep == e.ep; + }); + // 再做一层保险, 防止出现ep为0的脏数据 + if (index == -1 && e.ep > 0) { + tempImageFavoritesEp.add(e); + } + } + tempImageFavoritesEp.sort((a, b) => a.ep.compareTo(b.ep)); + List finalImageFavoritesEp = + jsonDecode(jsonEncode(tempImageFavoritesEp)); + for (var e in tempImageFavoritesEp) { + List finalImageFavorites = []; + int epIndex = tempImageFavoritesEp.indexOf(e); + for (ImageFavorite j in e.imageFavorites) { + int index = + finalImageFavorites.indexWhere((i) => i["page"] == j.page); + if (index == -1 && j.page > 0) { + // isAutoFavorite 为 null 不写入数据库, 同时只保留需要的属性, 避免增加太多重复字段在数据库里 + if (j.isAutoFavorite != null) { + finalImageFavorites.add({ + "page": j.page, + "imageKey": j.imageKey, + "isAutoFavorite": j.isAutoFavorite + }); + } else { + finalImageFavorites.add({"page": j.page, "imageKey": j.imageKey}); + } + } + } + finalImageFavorites.sort((a, b) => a["page"].compareTo(b["page"])); + finalImageFavoritesEp[epIndex]["imageFavorites"] = finalImageFavorites; + } + if (tempImageFavoritesEp.isEmpty) { + throw "Error: No ImageFavoritesEp"; + } + _db.execute(""" + insert or replace into image_favorites(id, title, sub_title, author, tags, translated_tags, time, max_page, source_key, image_favorites_ep, other) + values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + """, [ + favorite.id, + favorite.title, + favorite.subTitle, + favorite.author, + favorite.tags.join(","), + favorite.translatedTags.join(","), + favorite.time.millisecondsSinceEpoch, + favorite.maxPage, + favorite.sourceKey, + jsonEncode(finalImageFavoritesEp), + jsonEncode(favorite.other) + ]); + } + if (notify) { + notifyListeners(); + } + } + + bool has(String id, String sourceKey, String eid, int page, int ep) { + var comic = find(id, sourceKey); + if (comic == null) { + return false; + } + var epIndex = comic.imageFavoritesEp.where((e) => e.eid == eid).firstOrNull; + if (epIndex == null) { + return false; + } + return epIndex.imageFavorites.any((e) => e.page == page && e.ep == ep); + } + + List getAll([String? keyword]) { + ResultSet res; + if (keyword == null || keyword == "") { + res = _db.select("select * from image_favorites;"); + } else { + res = _db.select( + """ + select * from image_favorites + WHERE title LIKE ? + OR sub_title LIKE ? + OR LOWER(tags) LIKE LOWER(?) + OR LOWER(translated_tags) LIKE LOWER(?) + OR author LIKE ?; + """, + ['%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%'], + ); + } + try { + return res.map((e) => ImageFavoritesComic.fromRow(e)).toList(); + } catch (e, stackTrace) { + Log.error("Unhandled Exception", e.toString(), stackTrace); + return []; + } + } + + void deleteImageFavorite(Iterable imageFavoriteList) { + if (imageFavoriteList.isEmpty) { + return; + } + for (var i in imageFavoriteList) { + ImageFavoritesProvider.deleteFromCache(i); + } + var comics = {}; + for (var i in imageFavoriteList) { + var comic = comics + .where((c) => c.id == i.id && c.sourceKey == i.sourceKey) + .firstOrNull ?? + find(i.id, i.sourceKey); + if (comic == null) { + continue; + } + var ep = comic.imageFavoritesEp.firstWhereOrNull((e) => e.ep == i.ep); + if (ep == null) { + continue; + } + ep.imageFavorites.remove(i); + if (ep.imageFavorites.isEmpty) { + comic.imageFavoritesEp.remove(ep); + } + comics.add(comic); + } + for (var i in comics) { + addOrUpdateOrDelete(i, false); + } + notifyListeners(); + } + + int get length { + var res = _db.select("select count(*) from image_favorites;"); + return res.first.values.first! as int; + } + + List search(String keyword) { + if (keyword == "") { + return []; + } + return getAll(keyword); + } + + static Future computeImageFavorites() { + var token = ServicesBinding.rootIsolateToken!; + var count = ImageFavoriteManager().length; + if (count == 0) { + return Future.value(ImageFavoritesComputed([], [], [])); + } else if (count > 100) { + return Isolate.run(() async { + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + await App.init(); + await HistoryManager().init(); + return _computeImageFavorites(); + }); + } else { + return Future.value(_computeImageFavorites()); + } + } + + static ImageFavoritesComputed _computeImageFavorites() { + const maxLength = 20; + + var comics = ImageFavoriteManager().getAll(); + // 去掉这些没有意义的标签 + const List exceptTags = [ + '連載中', + '', + 'translated', + 'chinese', + 'sole male', + 'sole female', + 'original', + 'doujinshi', + 'manga', + 'multi-work series', + 'mosaic censorship', + 'dilf', + 'bbm', + 'uncensored', + 'full censorship' + ]; + + Map tagCount = {}; + Map authorCount = {}; + Map comicImageCount = {}; + Map comicMaxPages = {}; + + for (var comic in comics) { + for (var tag in comic.tags) { + String finalTag = tag; + tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1; + } + + if (comic.author != "") { + String finalAuthor = comic.author; + authorCount[finalAuthor] = + (authorCount[finalAuthor] ?? 0) + comic.images.length; + } + // 小于10页的漫画不统计 + if (comic.maxPageFromEp < 10) { + continue; + } + comicImageCount[comic] = + (comicImageCount[comic] ?? 0) + comic.images.length; + comicMaxPages[comic] = (comicMaxPages[comic] ?? 0) + comic.maxPageFromEp; + } + + // 按数量排序标签 + List sortedTags = tagCount.keys.toList() + ..sort((a, b) => tagCount[b]!.compareTo(tagCount[a]!)); + + // 按数量排序作者 + List sortedAuthors = authorCount.keys.toList() + ..sort((a, b) => authorCount[b]!.compareTo(authorCount[a]!)); + + // 按收藏数量排序漫画 + List> sortedComicsByNum = + comicImageCount.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + validateTag(String tag) { + if (tag.startsWith("Category:")) { + return false; + } + return !exceptTags.contains(tag.split(":").last.toLowerCase()) && + !tag.isNum; + } + + return ImageFavoritesComputed( + sortedTags + .where(validateTag) + .map((tag) => TextWithCount(tag, tagCount[tag]!)) + .take(maxLength) + .toList(), + sortedAuthors + .map((author) => TextWithCount(author, authorCount[author]!)) + .take(maxLength) + .toList(), + sortedComicsByNum + .map((comic) => TextWithCount(comic.key.title, comic.value)) + .take(maxLength) + .toList(), + ); + } + + ImageFavoritesComic? find(String id, String sourceKey) { + var row = _db.select(""" + select * from image_favorites + where id == ? and source_key == ?; + """, [id, sourceKey]); + if (row.isEmpty) { + return null; + } + return ImageFavoritesComic.fromRow(row.first); + } +} + +class TextWithCount { + final String text; + final int count; + + const TextWithCount(this.text, this.count); +} + +class ImageFavoritesComputed { + /// 基于收藏的标签数排序 + final List tags; + + /// 基于收藏的作者数排序 + final List authors; + + /// 基于喜欢的图片数排序 + final List comics; + + /// 计算后的图片收藏数据 + const ImageFavoritesComputed( + this.tags, + this.authors, + this.comics, + ); + + bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty; +} diff --git a/lib/foundation/image_provider/base_image_provider.dart b/lib/foundation/image_provider/base_image_provider.dart index 788456a..e0b8f2c 100644 --- a/lib/foundation/image_provider/base_image_provider.dart +++ b/lib/foundation/image_provider/base_image_provider.dart @@ -6,6 +6,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/foundation/cache_manager.dart'; +import 'package:venera/foundation/log.dart'; abstract class BaseImageProvider> extends ImageProvider { @@ -126,10 +127,11 @@ abstract class BaseImageProvider> } rethrow; } - } catch (e) { + } catch (e, s) { scheduleMicrotask(() { PaintingBinding.instance.imageCache.evict(key); }); + Log.error("Image Loading", e, s); rethrow; } finally { chunkEvents.close(); diff --git a/lib/foundation/image_provider/image_favorites_provider.dart b/lib/foundation/image_provider/image_favorites_provider.dart new file mode 100644 index 0000000..ec273f1 --- /dev/null +++ b/lib/foundation/image_provider/image_favorites_provider.dart @@ -0,0 +1,146 @@ +import 'dart:async' show Future, StreamController; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; +import 'package:venera/foundation/comic_type.dart'; +import 'package:venera/foundation/local.dart'; +import 'package:venera/network/images.dart'; +import 'package:venera/utils/io.dart'; +import '../history.dart'; +import 'base_image_provider.dart'; +import 'image_favorites_provider.dart' as image_provider; + +class ImageFavoritesProvider + extends BaseImageProvider { + /// Image provider for imageFavorites + const ImageFavoritesProvider(this.imageFavorite); + + final ImageFavorite imageFavorite; + + int get page => imageFavorite.page; + + String get sourceKey => imageFavorite.sourceKey; + + String get cid => imageFavorite.id; + + String get eid => imageFavorite.eid; + + @override + Future load(StreamController? chunkEvents) async { + var imageKey = imageFavorite.imageKey; + var localImage = await getImageFromLocal(); + if (localImage != null) { + return localImage; + } + var cacheImage = await readFromCache(); + if (cacheImage != null) { + return cacheImage; + } + var gotImageKey = false; + if (imageKey == "") { + imageKey = await getImageKey(); + gotImageKey = true; + } + Uint8List image; + try { + image = await getImageFromNetwork(imageKey, chunkEvents); + } catch (e) { + if (gotImageKey) { + rethrow; + } else { + imageKey = await getImageKey(); + image = await getImageFromNetwork(imageKey, chunkEvents); + } + } + await writeToCache(image); + return image; + } + + Future writeToCache(Uint8List image) async { + var fileName = md5.convert(key.codeUnits).toString(); + var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName)); + if (!file.existsSync()) { + file.createSync(recursive: true); + } + await file.writeAsBytes(image); + } + + Future readFromCache() async { + var fileName = md5.convert(key.codeUnits).toString(); + var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName)); + if (!file.existsSync()) { + return null; + } + return await file.readAsBytes(); + } + + /// Delete a image favorite cache + static Future deleteFromCache(ImageFavorite imageFavorite) async { + var fileName = md5.convert(imageFavorite.imageKey.codeUnits).toString(); + var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName)); + if (file.existsSync()) { + await file.delete(); + } + } + + Future getImageFromLocal() async { + var localComic = + LocalManager().find(sourceKey, ComicType.fromKey(sourceKey)); + if (localComic == null) { + return null; + } + var epIndex = localComic.chapters?.keys.toList().indexOf(eid) ?? -1; + if (epIndex == -1 && localComic.hasChapters) { + return null; + } + var images = await LocalManager().getImages( + sourceKey, + ComicType.fromKey(sourceKey), + epIndex, + ); + var data = await File(images[page]).readAsBytes(); + return data; + } + + Future getImageFromNetwork( + String imageKey, StreamController? chunkEvents) async { + await for (var progress + in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) { + if (chunkEvents != null) { + chunkEvents.add(ImageChunkEvent( + cumulativeBytesLoaded: progress.currentBytes, + expectedTotalBytes: progress.totalBytes, + )); + } + if (progress.imageBytes != null) { + return progress.imageBytes!; + } + } + throw "Error: Empty response body."; + } + + Future getImageKey() async { + String sourceKey = imageFavorite.sourceKey; + String cid = imageFavorite.id; + String eid = imageFavorite.eid; + var page = imageFavorite.page; + var comicSource = ComicSource.find(sourceKey); + if (comicSource == null) { + throw "Error: Comic source not found."; + } + var res = await comicSource.loadComicPages!(cid, eid); + return res.data[page - 1]; + } + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + String get key => + "ImageFavorites ${imageFavorite.imageKey}@${imageFavorite.sourceKey}@${imageFavorite.id}@${imageFavorite.eid}"; +} diff --git a/lib/foundation/image_provider/local_favorite_image.dart b/lib/foundation/image_provider/local_favorite_image.dart index ade4a7b..feae47f 100644 --- a/lib/foundation/image_provider/local_favorite_image.dart +++ b/lib/foundation/image_provider/local_favorite_image.dart @@ -22,7 +22,7 @@ class LocalFavoriteImageProvider static void delete(String id, int intKey) { var fileName = (id + intKey.toString()).hashCode.toString(); var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName)); - if(file.existsSync()) { + if (file.existsSync()) { file.delete(); } } @@ -42,7 +42,7 @@ class LocalFavoriteImageProvider cumulativeBytesLoaded: progress.currentBytes, expectedTotalBytes: progress.totalBytes, )); - if(progress.imageBytes != null) { + if (progress.imageBytes != null) { var data = progress.imageBytes!; await file.writeAsBytes(data); return data; @@ -52,7 +52,8 @@ class LocalFavoriteImageProvider } @override - Future obtainKey(ImageConfiguration configuration) { + Future obtainKey( + ImageConfiguration configuration) { return SynchronousFuture(this); } diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 5d00c64..2eabae4 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -36,6 +36,8 @@ class LocalComic with HistoryMixin implements Comic { /// chapter id is the name of the directory in `LocalManager.path/$directory` final Map? chapters; + bool get hasChapters => chapters != null; + /// relative path to the cover image @override final String cover; @@ -119,6 +121,8 @@ class LocalComic with HistoryMixin implements Comic { ep: 0, page: 0, ), + author: subtitle, + tags: tags, ), ); } @@ -266,7 +270,7 @@ class LocalManager with ChangeNotifier { String findValidId(ComicType type) { final res = _db.select( ''' - SELECT id FROM comics WHERE comic_type = ? + SELECT id FROM comics WHERE comic_type = ? ORDER BY CAST(id AS INTEGER) DESC LIMIT 1; ''', @@ -318,8 +322,8 @@ class LocalManager with ChangeNotifier { List getComics(LocalSortType sortType) { var res = _db.select(''' SELECT * FROM comics - ORDER BY - ${sortType.value == 'name' ? 'title' : 'created_at'} + ORDER BY + ${sortType.value == 'name' ? 'title' : 'created_at'} ${sortType.value == 'time_asc' ? 'ASC' : 'DESC'} ; '''); @@ -361,7 +365,7 @@ class LocalManager with ChangeNotifier { LocalComic? findByName(String name) { final res = _db.select(''' - SELECT * FROM comics + SELECT * FROM comics WHERE title = ? OR directory = ?; ''', [name, name]); if (res.isEmpty) { @@ -385,7 +389,7 @@ class LocalManager with ChangeNotifier { } var comic = find(id, type) ?? (throw "Comic Not Found"); var directory = Directory(comic.baseDir); - if (comic.chapters != null) { + if (comic.hasChapters) { var cid = ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String); directory = Directory(FilePath.join(directory.path, cid)); diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index d78c893..ed84ff4 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -145,6 +145,8 @@ class _ComicPageState extends LoadingState ep: 0, page: 0, ), + author: localComic.subTitle ?? '', + tags: localComic.tags, ); }); App.mainNavigatorKey!.currentContext!.pop(); @@ -663,6 +665,8 @@ abstract mixin class _ComicPageActions { initialChapter: ep, initialPage: page, history: History.fromModel(model: comic, ep: 0, page: 0), + author: comic.findAuthor() ?? '', + tags: comic.plainTags, ), ); } diff --git a/lib/pages/favorites/favorite_actions.dart b/lib/pages/favorites/favorite_actions.dart index b9512d7..4d466ea 100644 --- a/lib/pages/favorites/favorite_actions.dart +++ b/lib/pages/favorites/favorite_actions.dart @@ -147,13 +147,13 @@ Future> updateComicsInfo(String folder) async { var newInfo = (await comicSource.loadComicInfo!(c.id)).data; var newTags = []; - for(var entry in newInfo.tags.entries) { + for (var entry in newInfo.tags.entries) { const shouldIgnore = ['author', 'artist', 'time']; var namespace = entry.key; if (shouldIgnore.contains(namespace.toLowerCase())) { continue; } - for(var tag in entry.value) { + for (var tag in entry.value) { newTags.add("$namespace:$tag"); } } @@ -305,6 +305,7 @@ Future sortFolders() async { Future importNetworkFolder( String source, + int updatePageNum, String? folder, String? folderID, ) async { @@ -312,7 +313,7 @@ Future importNetworkFolder( if (comicSource == null) { return; } - if(folder != null && folder.isEmpty) { + if (folder != null && folder.isEmpty) { folder = null; } var resultName = folder ?? comicSource.name; @@ -324,7 +325,7 @@ Future importNetworkFolder( return; } } - if(!exists) { + if (!exists) { LocalFavoritesManager().createFolder(resultName); LocalFavoritesManager().linkFolderToNetwork( resultName, @@ -332,37 +333,46 @@ Future importNetworkFolder( folderID ?? "", ); } - + bool isOldToNewSort = comicSource.favoriteData?.isOldToNewSort ?? false; var current = 0; + int receivedComics = 0; + int requestCount = 0; var isFinished = false; + int maxPage = 1; + List comics = []; String? next; - + // 如果是从旧到新, 先取一下maxPage + if (isOldToNewSort) { + var res = await comicSource.favoriteData?.loadComic!(1, folderID); + maxPage = res?.subData ?? 1; + } Future fetchNext() async { var retry = 3; - - while (true) { + while (updatePageNum > requestCount && !isFinished) { try { if (comicSource.favoriteData?.loadComic != null) { - next ??= '1'; + // 从旧到新的情况下, 假设有10页, 更新3页, 则从第8页开始, 8, 9, 10 三页 + next ??= + isOldToNewSort ? (maxPage - updatePageNum + 1).toString() : '1'; var page = int.parse(next!); var res = await comicSource.favoriteData!.loadComic!(page, folderID); var count = 0; + receivedComics += res.data.length; for (var c in res.data) { - var result = LocalFavoritesManager().addComic( - resultName, - FavoriteItem( + if (!LocalFavoritesManager() + .comicExists(resultName, c.id, ComicType(source.hashCode))) { + count++; + comics.add(FavoriteItem( id: c.id, name: c.title, coverPath: c.cover, type: ComicType(source.hashCode), author: c.subtitle ?? '', tags: c.tags ?? [], - ), - ); - if (result) { - count++; + )); } } + requestCount++; current += count; if (res.data.isEmpty || res.subData == page) { isFinished = true; @@ -373,22 +383,22 @@ Future importNetworkFolder( } else if (comicSource.favoriteData?.loadNext != null) { var res = await comicSource.favoriteData!.loadNext!(next, folderID); var count = 0; + receivedComics += res.data.length; for (var c in res.data) { - var result = LocalFavoritesManager().addComic( - resultName, - FavoriteItem( + if (!LocalFavoritesManager() + .comicExists(resultName, c.id, ComicType(source.hashCode))) { + count++; + comics.add(FavoriteItem( id: c.id, name: c.title, coverPath: c.cover, type: ComicType(source.hashCode), author: c.subtitle ?? '', tags: c.tags ?? [], - ), - ); - if (result) { - count++; + )); } } + requestCount++; current += count; if (res.data.isEmpty || res.subData == null) { isFinished = true; @@ -408,6 +418,8 @@ Future importNetworkFolder( continue; } } + // 跳出循环, 表示已经完成, 强制为 true, 避免死循环 + isFinished = true; } bool isCanceled = false; @@ -415,6 +427,7 @@ Future importNetworkFolder( bool isErrored() => errorMsg != null; void Function()? updateDialog; + void Function()? closeDialog; showDialog( context: App.rootContext, @@ -422,6 +435,7 @@ Future importNetworkFolder( return StatefulBuilder( builder: (context, setState) { updateDialog = () => setState(() {}); + closeDialog = () => Navigator.pop(context); return ContentDialog( title: isFinished ? "Finished".tl @@ -437,8 +451,11 @@ Future importNetworkFolder( value: isFinished ? 1 : null, ), const SizedBox(height: 4), - Text("Imported @c comics".tlParams({ - "c": current, + Text("Imported @a comics, loaded @b pages, received @c comics" + .tlParams({ + "a": current, + "b": requestCount, + "c": receivedComics, })), const SizedBox(height: 4), if (isErrored()) Text("Error: $errorMsg"), @@ -476,4 +493,18 @@ Future importNetworkFolder( break; } } + try { + if (appdata.settings['newFavoriteAddTo'] == "start" && !isOldToNewSort) { + // 如果是插到最前, 并且是从新到旧, 反转一下 + comics = comics.reversed.toList(); + } + for (var c in comics) { + LocalFavoritesManager().addComic(resultName, c); + } + // 延迟一点, 让用户看清楚到底新增了多少 + await Future.delayed(const Duration(milliseconds: 500)); + closeDialog?.call(); + } catch (e, stackTrace) { + Log.error("Unhandled Exception", e.toString(), stackTrace); + } } diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 47b868f..80445b2 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -11,6 +11,7 @@ import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/local.dart'; +import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/download.dart'; import 'package:venera/pages/comic_page.dart'; @@ -35,7 +36,7 @@ class FavoritesPage extends StatefulWidget { State createState() => _FavoritesPageState(); } -class _FavoritesPageState extends State { +class _FavoritesPageState extends State { String? folder; bool isNetwork = false; @@ -58,7 +59,7 @@ class _FavoritesPageState extends State { @override void initState() { var data = appdata.implicitData['favoriteFolder']; - if(data != null){ + if (data != null) { folder = data['name']; isNetwork = data['isNetwork'] ?? false; } @@ -101,7 +102,7 @@ class _FavoritesPageState extends State { alignment: Alignment.centerLeft, child: Material( child: SizedBox( - width: min(300, context.width-16), + width: min(300, context.width - 16), child: _LeftBar( withAppbar: true, favPage: this, @@ -153,14 +154,16 @@ class _FavoritesPageState extends State { ); } if (!isNetwork) { - return _LocalFavoritesPage(folder: folder!, key: PageStorageKey("local_$folder")); + return _LocalFavoritesPage( + folder: folder!, key: PageStorageKey("local_$folder")); } else { var favoriteData = getFavoriteDataOrNull(folder!); if (favoriteData == null) { folder = null; return buildBody(); } else { - return NetworkFavoritePage(favoriteData, key: PageStorageKey("network_$folder")); + return NetworkFavoritePage(favoriteData, + key: PageStorageKey("network_$folder")); } } } diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index 2a8f344..6b08e14 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -136,17 +136,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { 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"; - } + final GlobalKey<_SelectUpdatePageNumState> + selectUpdatePageNumKey = + GlobalKey<_SelectUpdatePageNumState>(); + var updatePageWidget = _SelectUpdatePageNum( + networkSource: networkSource!, + networkFolder: networkFolder, + key: selectUpdatePageNumKey, + ); return FlyoutContent( title: "Sync".tl, - content: Text(text), + content: updatePageWidget, actions: [ Button.filled( child: Text("Update".tl), @@ -154,6 +154,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { context.pop(); importNetworkFolder( networkSource!, + selectUpdatePageNumKey + .currentState!.updatePageNum, widget.folder, networkFolder!, ).then( @@ -741,6 +743,17 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> { ); }, ), + IconButton( + icon: const Icon(Icons.swap_vert), + onPressed: () { + setState(() { + comics = comics.reversed.toList(); + changed = true; + showToast( + message: "Reversed successfully".tl, context: context); + }); + }, + ), ], ), body: ReorderableBuilder( @@ -776,3 +789,76 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> { ); } } + +class _SelectUpdatePageNum extends StatefulWidget { + const _SelectUpdatePageNum({ + required this.networkSource, + this.networkFolder, + super.key, + }); + + final String? networkFolder; + final String networkSource; + + @override + State<_SelectUpdatePageNum> createState() => _SelectUpdatePageNumState(); +} + +class _SelectUpdatePageNumState extends State<_SelectUpdatePageNum> { + int updatePageNum = 9999999; + + String get _allPageText => 'All'.tl; + + List get pageNumList => + ['1', '2', '3', '5', '10', '20', '50', '100', '200', _allPageText]; + + @override + void initState() { + updatePageNum = + appdata.implicitData["local_favorites_update_page_num"] ?? 9999999; + super.initState(); + } + + @override + Widget build(BuildContext context) { + var source = ComicSource.find(widget.networkSource); + var sourceName = source?.name ?? widget.networkSource; + var text = "The folder is Linked to @source".tlParams({ + "source": sourceName, + }); + if (widget.networkFolder != null && widget.networkFolder!.isNotEmpty) { + text += "\n${"Source Folder".tl}: ${widget.networkFolder}"; + } + + return Column( + children: [ + Row( + children: [Text(text)], + ), + Row( + children: [ + Text("Update the page number by the latest collection".tl), + Spacer(), + Select( + current: updatePageNum.toString() == '9999999' + ? _allPageText + : updatePageNum.toString(), + values: pageNumList, + minWidth: 48, + onTap: (index) { + setState(() { + updatePageNum = int.parse(pageNumList[index] == _allPageText + ? '9999999' + : pageNumList[index]); + appdata.implicitData["local_favorites_update_page_num"] = + updatePageNum; + appdata.writeImplicitData(); + }); + }, + ) + ], + ), + ], + ); + } +} diff --git a/lib/pages/favorites/network_favorites_page.dart b/lib/pages/favorites/network_favorites_page.dart index 5cd0f53..8acc3b1 100644 --- a/lib/pages/favorites/network_favorites_page.dart +++ b/lib/pages/favorites/network_favorites_page.dart @@ -20,8 +20,7 @@ Future _deleteComic( return StatefulBuilder(builder: (context, setState) { return ContentDialog( title: "Remove".tl, - content: Text("Remove comic from favorite?".tl) - .paddingHorizontal(16), + content: Text("Remove comic from favorite?".tl).paddingHorizontal(16), actions: [ Button.filled( isLoading: loading, @@ -94,9 +93,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> { return ComicList( key: comicListKey, leadingSliver: SliverAppbar( - style: context.width < changePoint - ? AppbarStyle.shadow - : AppbarStyle.blur, + style: + context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur, leading: Tooltip( message: "Folders".tl, child: context.width <= _kTwoPanelChangeWidth @@ -117,7 +115,7 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> { icon: Icons.sync, text: "Convert to local".tl, onClick: () { - importNetworkFolder(widget.data.key, null, null); + importNetworkFolder(widget.data.key, 9999999, null, null); }, ) ]), @@ -215,9 +213,8 @@ class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> { @override Widget build(BuildContext context) { var sliverAppBar = SliverAppbar( - style: context.width < changePoint - ? AppbarStyle.shadow - : AppbarStyle.blur, + style: + context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur, leading: Tooltip( message: "Folders".tl, child: context.width <= _kTwoPanelChangeWidth @@ -431,8 +428,7 @@ class _FolderTile extends StatelessWidget { return StatefulBuilder(builder: (context, setState) { return ContentDialog( title: "Delete".tl, - content: Text("Delete folder?".tl) - .paddingHorizontal(16), + content: Text("Delete folder?".tl).paddingHorizontal(16), actions: [ Button.filled( isLoading: loading, @@ -558,7 +554,7 @@ class _FavoriteFolder extends StatelessWidget { icon: Icons.sync, text: "Convert to local".tl, onClick: () { - importNetworkFolder(data.key, title, folderID); + importNetworkFolder(data.key, 9999999, title, folderID); }, ) ]), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index e6cf73b..67cb9a3 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -9,14 +9,17 @@ import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/history_image_provider.dart'; import 'package:venera/foundation/image_provider/local_comic_image.dart'; import 'package:venera/foundation/local.dart'; +import 'package:venera/foundation/log.dart'; import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/history_page.dart'; +import 'package:venera/pages/image_favorites_page/image_favorites_page.dart'; import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/import_comic.dart'; +import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; import 'local_comics_page.dart'; @@ -35,6 +38,7 @@ class HomePage extends StatelessWidget { const _Local(), const _ComicSourceWidget(), const _AccountsWidget(), + const ImageFavorites(), SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), ], ); @@ -83,7 +87,8 @@ class _SyncDataWidget extends StatefulWidget { State<_SyncDataWidget> createState() => _SyncDataWidgetState(); } -class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver { +class _SyncDataWidgetState extends State<_SyncDataWidget> + with WidgetsBindingObserver { @override void initState() { super.initState(); @@ -93,7 +98,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs } void update() { - if(mounted) { + if (mounted) { setState(() {}); } } @@ -110,8 +115,8 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); - if(state == AppLifecycleState.resumed) { - if(DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) { + if (state == AppLifecycleState.resumed) { + if (DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) { lastCheck = DateTime.now(); DataSync().downloadData(); } @@ -121,7 +126,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs @override Widget build(BuildContext context) { Widget child; - if(!DataSync().isEnabled) { + if (!DataSync().isEnabled) { child = const SliverPadding(padding: EdgeInsets.zero); } else if (DataSync().isUploading || DataSync().isDownloading) { child = SliverToBoxAdapter( @@ -159,17 +164,15 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: const Icon(Icons.cloud_upload_outlined), - onPressed: () async { - DataSync().uploadData(); - } - ), + icon: const Icon(Icons.cloud_upload_outlined), + onPressed: () async { + DataSync().uploadData(); + }), IconButton( - icon: const Icon(Icons.cloud_download_outlined), - onPressed: () async { - DataSync().downloadData(); - } - ), + icon: const Icon(Icons.cloud_download_outlined), + onPressed: () async { + DataSync().downloadData(); + }), ], ), ), @@ -518,50 +521,50 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { ), ) : Column( - key: key, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 600), - ...List.generate(importMethods.length, (index) { - return RadioListTile( - title: Text(importMethods[index]), - value: index, - groupValue: type, - onChanged: (value) { - setState(() { - type = value as int; - }); - }, - ); - }), - if(type != 3) - ListTile( - title: Text("Add to favorites".tl), - trailing: Select( - current: selectedFolder, - values: folders, - minWidth: 112, - onTap: (v) { + key: key, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 600), + ...List.generate(importMethods.length, (index) { + return RadioListTile( + title: Text(importMethods[index]), + value: index, + groupValue: type, + onChanged: (value) { setState(() { - selectedFolder = folders[v]; + type = value as int; }); }, - ), - ).paddingHorizontal(8), - if(!App.isIOS && !App.isMacOS) - CheckboxListTile( - enabled: true, - title: Text("Copy to app local path".tl), - value: copyToLocalFolder, - onChanged:(v) { - setState(() { - copyToLocalFolder = !copyToLocalFolder; - }); - }).paddingHorizontal(8), - const SizedBox(height: 8), - Text(info).paddingHorizontal(24), - ], - ), + ); + }), + if (type != 3) + ListTile( + title: Text("Add to favorites".tl), + trailing: Select( + current: selectedFolder, + values: folders, + minWidth: 112, + onTap: (v) { + setState(() { + selectedFolder = folders[v]; + }); + }, + ), + ).paddingHorizontal(8), + if (!App.isIOS && !App.isMacOS) + CheckboxListTile( + enabled: true, + title: Text("Copy to app local path".tl), + value: copyToLocalFolder, + onChanged: (v) { + setState(() { + copyToLocalFolder = !copyToLocalFolder; + }); + }).paddingHorizontal(8), + const SizedBox(height: 8), + Text(info).paddingHorizontal(24), + ], + ), actions: [ Button.text( child: Row( @@ -591,7 +594,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { help += "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" .tl; - help +="If you import an EhViewer's database, program will automatically create folders according to the download label in that database.".tl; + help += + "If you import an EhViewer's database, program will automatically create folders according to the download label in that database." + .tl; return ContentDialog( title: "Help".tl, content: Text(help).paddingHorizontal(16), @@ -624,9 +629,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { loading = true; }); var importer = ImportComic( - selectedFolder: selectedFolder, - copyToLocal: copyToLocalFolder); - var result = switch(type) { + selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder); + var result = switch (type) { 0 => await importer.directory(true), 1 => await importer.directory(false), 2 => await importer.cbz(), @@ -634,7 +638,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { 4 => await importer.ehViewer(), int() => true, }; - if(result) { + if (result) { context.pop(); } else { setState(() { @@ -911,3 +915,281 @@ class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon> ); } } + +class ImageFavorites extends StatefulWidget { + const ImageFavorites({super.key}); + + @override + State createState() => _ImageFavoritesState(); +} + +class _ImageFavoritesState extends State { + ImageFavoritesComputed? imageFavoritesCompute; + + int displayType = 0; + + void refreshImageFavorites() async { + try { + imageFavoritesCompute = + await ImageFavoriteManager.computeImageFavorites(); + if (mounted) { + setState(() {}); + } + } catch (e, stackTrace) { + Log.error("Unhandled Exception", e.toString(), stackTrace); + } + } + + @override + void initState() { + refreshImageFavorites(); + ImageFavoriteManager().addListener(refreshImageFavorites); + super.initState(); + } + + @override + void dispose() { + ImageFavoriteManager().removeListener(refreshImageFavorites); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + bool hasData = + imageFavoritesCompute != null && !imageFavoritesCompute!.isEmpty; + return SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, + ), + borderRadius: BorderRadius.circular(8), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + context.to(() => const ImageFavoritesPage()); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 56, + child: Row( + children: [ + Center( + child: Text('Image Favorites'.tl, style: ts.s18), + ), + const Spacer(), + const Icon(Icons.arrow_right), + ], + ), + ).paddingHorizontal(16), + if (hasData) + Row( + children: [ + const Spacer(), + buildTypeButton(0, "Tags".tl), + const Spacer(), + buildTypeButton(1, "Authors".tl), + const Spacer(), + buildTypeButton(2, "Comics".tl), + const Spacer(), + ], + ), + if (hasData) const SizedBox(height: 8), + if (hasData) + buildChart(switch (displayType) { + 0 => imageFavoritesCompute!.tags, + 1 => imageFavoritesCompute!.authors, + 2 => imageFavoritesCompute!.comics, + _ => [], + }) + .paddingHorizontal(16) + .paddingBottom(16), + ], + ), + ), + ), + ); + } + + Widget buildTypeButton(int type, String text) { + const radius = 24.0; + return InkWell( + borderRadius: BorderRadius.circular(radius), + onTap: () async { + setState(() { + displayType = type; + }); + await Future.delayed(const Duration(milliseconds: 20)); + var scrollController = ScrollControllerProvider.of(context); + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + }, + child: AnimatedContainer( + width: 96, + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: + displayType == type ? context.colorScheme.primaryContainer : null, + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, + ), + borderRadius: BorderRadius.circular(radius), + ), + duration: const Duration(milliseconds: 200), + child: Center( + child: Text( + text, + style: ts.s16, + ), + ), + ), + ); + } + + Widget buildChart(List data) { + if (data.isEmpty) { + return const SizedBox(); + } + var maxCount = data.map((e) => e.count).reduce((a, b) => a > b ? a : b); + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: 164, + ), + child: SingleChildScrollView( + child: Column( + key: ValueKey(displayType), + children: data.map((e) { + return _ChartLine( + text: e.text, + count: e.count, + maxCount: maxCount, + enableTranslation: displayType != 2, + onTap: (text) { + context.to(() => ImageFavoritesPage(initialKeyword: text)); + }, + ); + }).toList(), + ), + ), + ); + } +} + +class _ChartLine extends StatefulWidget { + const _ChartLine({ + required this.text, + required this.count, + required this.maxCount, + required this.enableTranslation, + this.onTap, + }); + + final String text; + + final int count; + + final int maxCount; + + final bool enableTranslation; + + final void Function(String text)? onTap; + + @override + State<_ChartLine> createState() => __ChartLineState(); +} + +class __ChartLineState extends State<_ChartLine> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + value: 0, + )..forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var text = widget.text; + var enableTranslation = + App.locale.countryCode == 'CN' && widget.enableTranslation; + if (enableTranslation) { + text = text.translateTagsToCN; + } + if (widget.enableTranslation && text.contains(':')) { + text = text.split(':').last; + } + return Row( + children: [ + InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () { + widget.onTap?.call(widget.text); + }, + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + .paddingHorizontal(4) + .toAlign(Alignment.centerLeft) + .fixWidth(context.width > 600 ? 120 : 80) + .fixHeight(double.infinity), + ), + const SizedBox(width: 8), + Expanded( + child: LayoutBuilder(builder: (context, constrains) { + var width = constrains.maxWidth * widget.count / widget.maxCount; + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Container( + width: width * _controller.value, + height: 18, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + gradient: LinearGradient( + colors: context.isDarkMode + ? [ + Colors.blue.shade800, + Colors.blue.shade500, + ] + : [ + Colors.blue.shade300, + Colors.blue.shade600, + ], + ), + ), + ).toAlign(Alignment.centerLeft); + }, + ); + }), + ), + const SizedBox(width: 8), + Text( + widget.count.toString(), + style: ts.s12, + ).fixWidth(context.width > 600 ? 60 : 30), + ], + ).fixHeight(28); + } +} diff --git a/lib/pages/image_favorites_page/image_favorites_item.dart b/lib/pages/image_favorites_page/image_favorites_item.dart new file mode 100644 index 0000000..120332b --- /dev/null +++ b/lib/pages/image_favorites_page/image_favorites_item.dart @@ -0,0 +1,287 @@ +part of 'image_favorites_page.dart'; + +class _ImageFavoritesItem extends StatefulWidget { + const _ImageFavoritesItem({ + required this.imageFavoritesComic, + required this.selectedImageFavorites, + required this.addSelected, + required this.multiSelectMode, + required this.finalImageFavoritesComicList, + }); + + final ImageFavoritesComic imageFavoritesComic; + final Function(ImageFavorite) addSelected; + final Map selectedImageFavorites; + final List finalImageFavoritesComicList; + final bool multiSelectMode; + + @override + State<_ImageFavoritesItem> createState() => _ImageFavoritesItemState(); +} + +class _ImageFavoritesItemState extends State<_ImageFavoritesItem> { + late final imageFavorites = widget.imageFavoritesComic.images.toList(); + + void goComicInfo(ImageFavoritesComic comic) { + App.mainNavigatorKey?.currentContext?.to(() => ComicPage( + id: comic.id, + sourceKey: comic.sourceKey, + )); + } + + void goReaderPage(ImageFavoritesComic comic, int ep, int page) { + App.rootContext.to( + () => ReaderWithLoading( + id: comic.id, + sourceKey: comic.sourceKey, + initialEp: ep, + initialPage: page, + ), + ); + } + + void goPhotoView(ImageFavorite imageFavorite) { + Navigator.of(App.rootContext).push(MaterialPageRoute( + builder: (context) => ImageFavoritesPhotoView( + comic: widget.imageFavoritesComic, + imageFavorite: imageFavorite, + ))); + } + + void copyTitle() { + Clipboard.setData(ClipboardData(text: widget.imageFavoritesComic.title)); + App.rootContext.showMessage(message: 'Copy the title successfully'.tl); + } + + void onLongPress() { + var renderBox = context.findRenderObject() as RenderBox; + var size = renderBox.size; + var location = renderBox.localToGlobal( + Offset((size.width - 242) / 2, size.height / 2), + ); + showMenu(location, context); + } + + void onSecondaryTap(TapDownDetails details) { + showMenu(details.globalPosition, context); + } + + void showMenu(Offset location, BuildContext context) { + showMenuX( + App.rootContext, + location, + [ + MenuEntry( + icon: Icons.chrome_reader_mode_outlined, + text: 'Details'.tl, + onClick: () { + goComicInfo(widget.imageFavoritesComic); + }, + ), + MenuEntry( + icon: Icons.copy, + text: 'Copy Title'.tl, + onClick: () { + copyTitle(); + }, + ), + MenuEntry( + icon: Icons.select_all, + text: 'Select All'.tl, + onClick: () { + for (var ele in widget.imageFavoritesComic.images) { + widget.addSelected(ele); + } + }, + ), + MenuEntry( + icon: Icons.read_more, + text: 'Photo View'.tl, + onClick: () { + goPhotoView(widget.imageFavoritesComic.images.first); + }, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, + ), + borderRadius: BorderRadius.circular(8), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onSecondaryTapDown: onSecondaryTap, + onLongPress: onLongPress, + onTap: () { + if (widget.multiSelectMode) { + for (var ele in widget.imageFavoritesComic.images) { + widget.addSelected(ele); + } + } else { + // 单击跳转漫画详情 + goComicInfo(widget.imageFavoritesComic); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + buildTop(), + SizedBox( + height: 145, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemBuilder: buildItem, + itemCount: imageFavorites.length, + ), + ).paddingHorizontal(8), + buildBottom(), + ], + ), + ), + ); + } + + Widget buildItem(BuildContext context, int index) { + var image = imageFavorites[index]; + bool isSelected = widget.selectedImageFavorites[image] ?? false; + int curPage = image.page; + String pageText = curPage == firstPage + ? '@a Cover'.tlParams({"a": image.epName}) + : curPage.toString(); + + return InkWell( + onTap: () { + // 单击去阅读页面, 跳转到当前点击的page + if (widget.multiSelectMode) { + widget.addSelected(image); + } else { + goReaderPage(widget.imageFavoritesComic, image.ep, curPage); + } + }, + onLongPress: () { + goPhotoView(image); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + width: 98, + height: 128, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isSelected + ? Theme.of(context).colorScheme.primaryContainer + : null, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + children: [ + Container( + height: 128, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.secondaryContainer, + ), + clipBehavior: Clip.antiAlias, + child: Hero( + tag: "${image.sourceKey}${image.ep}${image.page}", + child: AnimatedImage( + image: ImageFavoritesProvider(image), + width: 96, + height: 128, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ), + ), + ), + Text( + pageText, + style: ts.s10, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + ], + ), + ), + ).paddingHorizontal(4); + } + + Widget buildTop() { + return Row( + children: [ + Expanded( + child: Text( + widget.imageFavoritesComic.title, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 16.0, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + softWrap: true, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + "${imageFavorites.length}/${widget.imageFavoritesComic.maxPageFromEp}", + style: ts.s12), + ), + ], + ).paddingHorizontal(16).paddingVertical(8); + } + + Widget buildBottom() { + var enableTranslate = App.locale.languageCode == 'zh'; + String time = + DateFormat('yyyy-MM-dd').format(widget.imageFavoritesComic.time); + List tags = []; + for (var tag in widget.imageFavoritesComic.tags) { + var text = enableTranslate ? tag.translateTagsToCN : tag; + if (text.contains(':')) { + text = text.split(':').last; + } + tags.add(text); + if (tags.length == 5) { + break; + } + } + var comicSource = ComicSource.find(widget.imageFavoritesComic.sourceKey); + return Row( + children: [ + Text( + "$time | ${comicSource?.name ?? "Unknown"}", + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 12.0, + ), + ).paddingRight(8), + if (tags.isNotEmpty) + Expanded( + child: Text( + tags + .map((e) => enableTranslate ? e.translateTagsToCN : e) + .join(" "), + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 12.0, + overflow: TextOverflow.ellipsis, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ) + ], + ).paddingHorizontal(8).paddingBottom(8); + } +} diff --git a/lib/pages/image_favorites_page/image_favorites_page.dart b/lib/pages/image_favorites_page/image_favorites_page.dart new file mode 100644 index 0000000..ea1b7ac --- /dev/null +++ b/lib/pages/image_favorites_page/image_favorites_page.dart @@ -0,0 +1,539 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:venera/components/components.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/appdata.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; +import 'package:venera/foundation/consts.dart'; +import 'package:venera/foundation/history.dart'; +import 'package:venera/foundation/image_provider/image_favorites_provider.dart'; +import 'package:venera/pages/comic_page.dart'; +import 'package:venera/pages/image_favorites_page/type.dart'; +import 'package:venera/pages/reader/reader.dart'; +import 'package:venera/utils/ext.dart'; +import 'package:venera/utils/file_type.dart'; +import 'package:venera/utils/io.dart'; +import 'package:venera/utils/tags_translation.dart'; +import 'package:venera/utils/translations.dart'; + +part "image_favorites_item.dart"; + +part "image_favorites_photo_view.dart"; + +class ImageFavoritesPage extends StatefulWidget { + const ImageFavoritesPage({super.key, this.initialKeyword}); + + final String? initialKeyword; + + @override + State createState() => _ImageFavoritesPageState(); +} + +class _ImageFavoritesPageState extends State { + late ImageFavoriteSortType sortType; + late TimeRange timeFilterSelect; + late int numFilterSelect; + + // 所有的图片收藏 + List comics = []; + + late var controller = + TextEditingController(text: widget.initialKeyword ?? ""); + + String get keyword => controller.text; + + // 进入关键词搜索模式 + bool searchMode = false; + + bool multiSelectMode = false; + + // 多选的时候选中的图片 + Map selectedImageFavorites = {}; + + void update() { + if (mounted) { + setState(() {}); + } + } + + void updateImageFavorites() async { + comics = searchMode + ? ImageFavoriteManager().search(keyword) + : ImageFavoriteManager().getAll(); + sortImageFavorites(); + update(); + } + + void sortImageFavorites() { + comics = searchMode + ? ImageFavoriteManager().search(keyword) + : ImageFavoriteManager().getAll(); + // 筛选到最终列表 + comics = comics.where((ele) { + bool isFilter = true; + if (timeFilterSelect != TimeRange.all) { + isFilter = timeFilterSelect.contains(ele.time); + } + if (numFilterSelect != numFilterList[0]) { + isFilter = ele.images.length > numFilterSelect; + } + return isFilter; + }).toList(); + // 给列表排序 + switch (sortType) { + case ImageFavoriteSortType.title: + comics.sort((a, b) => a.title.compareTo(b.title)); + case ImageFavoriteSortType.timeAsc: + comics.sort((a, b) => a.time.compareTo(b.time)); + case ImageFavoriteSortType.timeDesc: + comics.sort((a, b) => b.time.compareTo(a.time)); + case ImageFavoriteSortType.maxFavorites: + comics.sort((a, b) => b.images.length + .compareTo(a.images.length)); + case ImageFavoriteSortType.favoritesCompareComicPages: + comics.sort((a, b) { + double tempA = a.images.length / a.maxPageFromEp; + double tempB = b.images.length / b.maxPageFromEp; + return tempB.compareTo(tempA); + }); + } + } + + @override + void initState() { + if (widget.initialKeyword != null) { + searchMode = true; + } + sortType = ImageFavoriteSortType.values.firstWhereOrNull( + (e) => e.value == appdata.implicitData["image_favorites_sort"]) ?? + ImageFavoriteSortType.title; + timeFilterSelect = TimeRange.fromString( + appdata.implicitData["image_favorites_time_filter"]); + numFilterSelect = appdata.implicitData["image_favorites_number_filter"] ?? + numFilterList[0]; + updateImageFavorites(); + ImageFavoriteManager().addListener(updateImageFavorites); + super.initState(); + } + + @override + void dispose() { + ImageFavoriteManager().removeListener(updateImageFavorites); + scrollController.dispose(); + super.dispose(); + } + + Widget buildMultiSelectMenu() { + return MenuButton(entries: [ + MenuEntry( + icon: Icons.delete_outline, + text: "Delete".tl, + onClick: () { + ImageFavoriteManager() + .deleteImageFavorite(selectedImageFavorites.keys); + setState(() { + multiSelectMode = false; + selectedImageFavorites.clear(); + }); + }, + ) + ]); + } + + var scrollController = ScrollController(); + + void selectAll() { + for (var c in comics) { + for (var i in c.images) { + selectedImageFavorites[i] = true; + } + } + update(); + } + + void deSelect() { + setState(() { + selectedImageFavorites.clear(); + }); + } + + void addSelected(ImageFavorite i) { + if (selectedImageFavorites[i] == null) { + selectedImageFavorites[i] = true; + } else { + selectedImageFavorites.remove(i); + } + if (selectedImageFavorites.isEmpty) { + multiSelectMode = false; + } else { + multiSelectMode = true; + } + update(); + } + + @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), + buildMultiSelectMenu(), + ]; + + var scrollWidget = SmoothCustomScrollView( + controller: scrollController, + slivers: [ + if (!searchMode && !multiSelectMode) + SliverAppbar( + title: Text("Image Favorites".tl), + actions: [ + Tooltip( + message: "Search".tl, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + setState(() { + searchMode = true; + }); + }, + ), + ), + Tooltip( + message: "Sort".tl, + child: IconButton( + isSelected: timeFilterSelect != TimeRange.all || + numFilterSelect != numFilterList[0], + icon: const Icon(Icons.sort_rounded), + onPressed: sort, + ), + ), + Tooltip( + message: multiSelectMode + ? "Exit Multi-Select".tl + : "Multi-Select".tl, + child: IconButton( + icon: const Icon(Icons.checklist), + onPressed: () { + setState(() { + multiSelectMode = !multiSelectMode; + }); + }, + ), + ), + ], + ) + else if (multiSelectMode) + SliverAppbar( + leading: Tooltip( + message: "Cancel".tl, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + multiSelectMode = false; + selectedImageFavorites.clear(); + }); + }, + ), + ), + title: Text(selectedImageFavorites.length.toString()), + actions: selectActions, + ) + else if (searchMode) + SliverAppbar( + leading: Tooltip( + message: "Cancel".tl, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + controller.clear(); + setState(() { + searchMode = false; + controller.clear(); + updateImageFavorites(); + }); + }, + ), + ), + title: TextField( + autofocus: true, + controller: controller, + decoration: InputDecoration( + hintText: "Search".tl, + border: InputBorder.none, + ), + onChanged: (v) { + updateImageFavorites(); + }, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + return _ImageFavoritesItem( + imageFavoritesComic: comics[index], + selectedImageFavorites: selectedImageFavorites, + addSelected: addSelected, + multiSelectMode: multiSelectMode, + finalImageFavoritesComicList: comics, + ); + }, + childCount: comics.length, + ), + ), + SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), + ], + ); + Widget body = Scrollbar( + controller: scrollController, + thickness: App.isDesktop ? 8 : 12, + radius: const Radius.circular(8), + interactive: true, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: context.width > changePoint + ? scrollWidget.paddingHorizontal(8) + : scrollWidget, + ), + ); + return PopScope( + canPop: !multiSelectMode && !searchMode, + onPopInvokedWithResult: (didPop, result) { + if (multiSelectMode) { + setState(() { + multiSelectMode = false; + selectedImageFavorites.clear(); + }); + } else if (searchMode) { + controller.clear(); + searchMode = false; + updateImageFavorites(); + } + }, + child: body, + ); + } + + void sort() { + showDialog( + context: context, + builder: (context) { + return _ImageFavoritesDialog( + initSortType: sortType, + initTimeFilterSelect: timeFilterSelect, + initNumFilterSelect: numFilterSelect, + updateConfig: (sortType, timeFilter, numFilter) { + setState(() { + this.sortType = sortType; + timeFilterSelect = timeFilter; + numFilterSelect = numFilter; + }); + sortImageFavorites(); + }, + ); + }, + ); + } +} + +class _ImageFavoritesDialog extends StatefulWidget { + const _ImageFavoritesDialog({ + required this.initSortType, + required this.initTimeFilterSelect, + required this.initNumFilterSelect, + required this.updateConfig, + }); + + final ImageFavoriteSortType initSortType; + final TimeRange initTimeFilterSelect; + final int initNumFilterSelect; + final Function updateConfig; + + @override + State<_ImageFavoritesDialog> createState() => _ImageFavoritesDialogState(); +} + +class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> { + List optionTypes = ['Sort', 'Filter']; + late var sortType = widget.initSortType; + late var numFilter = widget.initNumFilterSelect; + late TimeRangeType timeRangeType; + DateTime? start; + DateTime? end; + + @override + void initState() { + super.initState(); + timeRangeType = switch (widget.initTimeFilterSelect) { + TimeRange.all => TimeRangeType.all, + TimeRange.lastWeek => TimeRangeType.lastWeek, + TimeRange.lastMonth => TimeRangeType.lastMonth, + TimeRange.lastHalfYear => TimeRangeType.lastHalfYear, + TimeRange.lastYear => TimeRangeType.lastYear, + _ => TimeRangeType.custom, + }; + if (timeRangeType == TimeRangeType.custom) { + end = widget.initTimeFilterSelect.end; + start = end!.subtract(widget.initTimeFilterSelect.duration); + } + } + + @override + Widget build(BuildContext context) { + Widget tabBar = Material( + borderRadius: BorderRadius.circular(8), + child: FilledTabBar( + key: PageStorageKey(optionTypes), + tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(), + ), + ).paddingTop(context.padding.top); + return ContentDialog( + content: DefaultTabController( + length: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + tabBar, + TabViewBody(children: [ + Column( + children: ImageFavoriteSortType.values + .map( + (e) => RadioListTile( + title: Text(e.value.tl), + value: e, + groupValue: sortType, + onChanged: (v) { + setState(() { + sortType = v!; + }); + }, + ), + ) + .toList(), + ), + Column( + children: [ + ListTile( + title: Text("Time Filter".tl), + trailing: Select( + current: timeRangeType.value.tl, + values: + TimeRangeType.values.map((e) => e.value.tl).toList(), + minWidth: 64, + onTap: (index) { + setState(() { + timeRangeType = TimeRangeType.values[index]; + }); + }, + ), + ), + if (timeRangeType == TimeRangeType.custom) + Column( + children: [ + ListTile( + title: Text("Start Time".tl), + trailing: TextButton( + onPressed: () async { + final date = await showDatePicker( + context: context, + initialDate: start ?? DateTime.now(), + firstDate: DateTime(2000), + lastDate: end ?? DateTime.now(), + ); + if (date != null) { + setState(() { + start = date; + }); + } + }, + child: Text(start == null + ? "Select Date".tl + : DateFormat("yyyy-MM-dd").format(start!)), + ), + ), + ListTile( + title: Text("End Time".tl), + trailing: TextButton( + onPressed: () async { + final date = await showDatePicker( + context: context, + initialDate: end ?? DateTime.now(), + firstDate: start ?? DateTime(2000), + lastDate: DateTime.now(), + ); + if (date != null) { + setState(() { + end = date; + }); + } + }, + child: Text(end == null + ? "Select Date".tl + : DateFormat("yyyy-MM-dd").format(end!)), + ), + ), + ], + ), + ListTile( + title: Text("Image Favorites Greater Than".tl), + trailing: Select( + current: numFilter.toString(), + values: numFilterList.map((e) => e.toString()).toList(), + minWidth: 64, + onTap: (index) { + setState(() { + numFilter = numFilterList[index]; + }); + }, + ), + ) + ], + ) + ]), + ], + ), + ), + actions: [ + FilledButton( + onPressed: () { + appdata.implicitData["image_favorites_sort"] = sortType.value; + TimeRange timeRange; + if (timeRangeType == TimeRangeType.custom) { + timeRange = TimeRange( + end: end, + duration: end!.difference(start!), + ); + } else { + timeRange = switch (timeRangeType) { + TimeRangeType.all => TimeRange.all, + TimeRangeType.lastWeek => TimeRange.lastWeek, + TimeRangeType.lastMonth => TimeRange.lastMonth, + TimeRangeType.lastHalfYear => TimeRange.lastHalfYear, + TimeRangeType.lastYear => TimeRange.lastYear, + _ => TimeRange.all, + }; + } + appdata.implicitData["image_favorites_time_filter"] = + timeRange.toString(); + appdata.implicitData["image_favorites_number_filter"] = numFilter; + appdata.writeImplicitData(); + if (mounted) { + Navigator.pop(context); + widget.updateConfig(sortType, timeRange, numFilter); + } + }, + child: Text("Confirm".tl), + ), + ], + ); + } +} diff --git a/lib/pages/image_favorites_page/image_favorites_photo_view.dart b/lib/pages/image_favorites_page/image_favorites_photo_view.dart new file mode 100644 index 0000000..e8543b1 --- /dev/null +++ b/lib/pages/image_favorites_page/image_favorites_photo_view.dart @@ -0,0 +1,253 @@ +part of 'image_favorites_page.dart'; + +class ImageFavoritesPhotoView extends StatefulWidget { + const ImageFavoritesPhotoView({ + super.key, + required this.comic, + required this.imageFavorite, + }); + + final ImageFavoritesComic comic; + final ImageFavorite imageFavorite; + + @override + State createState() => + _ImageFavoritesPhotoViewState(); +} + +class _ImageFavoritesPhotoViewState extends State { + late PageController controller; + Map cancelImageFavorites = {}; + + var images = []; + + int currentPage = 0; + + bool isAppBarShow = false; + + @override + void initState() { + var current = 0; + for (var ep in widget.comic.imageFavoritesEp) { + for (var image in ep.imageFavorites) { + images.add(image); + if (image == widget.imageFavorite) { + current = images.length - 1; + } + } + } + currentPage = current; + controller = PageController(initialPage: current); + super.initState(); + } + + void onPop() { + List tempList = cancelImageFavorites.entries + .where((e) => e.value == true) + .map((e) => e.key) + .toList(); + if (tempList.isNotEmpty) { + ImageFavoriteManager().deleteImageFavorite(tempList); + showToast( + message: "Delete @a images".tlParams({'a': tempList.length}), + context: context); + } + } + + PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) { + var image = images[index]; + return PhotoViewGalleryPageOptions( + // 图片加载器 支持本地、网络 + imageProvider: ImageFavoritesProvider(image), + // 初始化大小 全部展示 + minScale: PhotoViewComputedScale.contained * 1.0, + maxScale: PhotoViewComputedScale.covered * 10.0, + onTapUp: (context, details, controllerValue) { + setState(() { + isAppBarShow = !isAppBarShow; + }); + }, + heroAttributes: PhotoViewHeroAttributes( + tag: "${image.sourceKey}${image.ep}${image.page}", + ), + ); + } + + @override + Widget build(BuildContext context) { + return PopScope( + onPopInvokedWithResult: (bool didPop, Object? result) async { + if (didPop) { + onPop(); + } + }, + child: Listener( + onPointerSignal: (event) { + if (HardwareKeyboard.instance.isControlPressed) { + return; + } + if (event is PointerScrollEvent) { + if (event.scrollDelta.dy > 0) { + if (controller.page! >= images.length - 1) { + return; + } + controller.nextPage( + duration: Duration(milliseconds: 180), curve: Curves.ease); + } else { + if (controller.page! <= 0) { + return; + } + controller.previousPage( + duration: Duration(milliseconds: 180), curve: Curves.ease); + } + } + }, + child: Stack(children: [ + Positioned.fill( + child: PhotoViewGallery.builder( + backgroundDecoration: BoxDecoration( + color: context.colorScheme.surface, + ), + builder: _buildItem, + itemCount: images.length, + loadingBuilder: (context, event) => Center( + child: SizedBox( + width: 20.0, + height: 20.0, + child: CircularProgressIndicator( + backgroundColor: context.colorScheme.surfaceContainerHigh, + value: event == null || event.expectedTotalBytes == null + ? null + : event.cumulativeBytesLoaded / + event.expectedTotalBytes!, + ), + ), + ), + pageController: controller, + onPageChanged: (index) { + setState(() { + currentPage = index; + }); + }, + ), + ), + buildPageInfo(), + AnimatedPositioned( + top: isAppBarShow ? 0 : -(context.padding.top + 52), + left: 0, + right: 0, + duration: Duration(milliseconds: 180), + child: buildAppBar(), + ), + ]), + ), + ); + } + + Widget buildPageInfo() { + var text = "${currentPage + 1}/${images.length}"; + return Positioned( + height: 40, + left: 0, + right: 0, + bottom: 0, + child: Center( + child: Stack( + children: [ + Text( + text, + style: TextStyle( + fontSize: 14, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4 + ..color = context.colorScheme.onInverseSurface, + ), + ), + Text(text), + ], + ), + ), + ); + } + + Widget buildAppBar() { + return Material( + color: context.colorScheme.surface.toOpacity(0.72), + child: BlurEffect( + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.5, + ), + ), + ), + height: 52, + child: Row( + children: [ + const SizedBox(width: 8), + IconButton( + icon: Icon(Icons.close), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.comic.title, + style: TextStyle(fontSize: 18), + ), + ), + IconButton( + icon: Icon(Icons.more_vert), + onPressed: showMenu, + ), + const SizedBox(width: 8), + ], + ), + ).paddingTop(context.padding.top), + ), + ); + } + + void showMenu() { + showMenuX( + context, + Offset(context.width, context.padding.top), + [ + MenuEntry( + icon: Icons.image_outlined, + text: "Save Image".tl, + onClick: () async { + var temp = images[currentPage]; + var imageProvider = ImageFavoritesProvider(temp); + var data = await imageProvider.load(null); + var fileType = detectFileType(data); + var fileName = "${currentPage + 1}.${fileType.ext}"; + await saveFile(filename: fileName, data: data); + }, + ), + MenuEntry( + icon: Icons.menu_book_outlined, + text: "Read".tl, + onClick: () async { + var comic = widget.comic; + var ep = images[currentPage].ep; + var page = images[currentPage].page; + App.rootContext.to( + () => ReaderWithLoading( + id: comic.id, + sourceKey: comic.sourceKey, + initialEp: ep, + initialPage: page, + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/pages/image_favorites_page/type.dart b/lib/pages/image_favorites_page/type.dart new file mode 100644 index 0000000..701ab74 --- /dev/null +++ b/lib/pages/image_favorites_page/type.dart @@ -0,0 +1,101 @@ +import 'package:venera/utils/ext.dart'; + +enum ImageFavoriteSortType { + title("Title"), + timeAsc("Time Asc"), + timeDesc("Time Desc"), + maxFavorites("Favorite Num"), // 单本收藏数最多排序 + favoritesCompareComicPages("Favorite Num Compare Comic Pages"); // 单本收藏数比上总页数 + + final String value; + + const ImageFavoriteSortType(this.value); +} + +const numFilterList = [0, 1, 2, 5, 10, 20, 50, 100]; + +class TimeRange { + /// End of the range, null means now + final DateTime? end; + + /// Duration of the range + final Duration duration; + + /// Create a time range + const TimeRange({this.end, required this.duration}); + + static const all = TimeRange(end: null, duration: Duration.zero); + + static const lastWeek = TimeRange(end: null, duration: Duration(days: 7)); + + static const lastMonth = TimeRange(end: null, duration: Duration(days: 30)); + + static const lastHalfYear = + TimeRange(end: null, duration: Duration(days: 180)); + + static const lastYear = TimeRange(end: null, duration: Duration(days: 365)); + + @override + String toString() { + return "${end?.millisecond}:${duration.inMilliseconds}"; + } + + /// Parse a time range from a string, return [TimeRange.all] if failed + factory TimeRange.fromString(String? str) { + if (str == null) { + return TimeRange.all; + } + final parts = str.split(":"); + if (parts.length != 2 || !parts[0].isInt || !parts[1].isInt) { + return TimeRange.all; + } + final end = parts[0] == "null" + ? null + : DateTime.fromMillisecondsSinceEpoch(int.parse(parts[0])); + final duration = Duration(milliseconds: int.parse(parts[1])); + return TimeRange(end: end, duration: duration); + } + + /// Check if a time is in the range + bool contains(DateTime time) { + if (end != null && time.isAfter(end!)) { + return false; + } + if (duration == Duration.zero) { + return true; + } + final start = end == null + ? DateTime.now().subtract(duration) + : end!.subtract(duration); + return time.isAfter(start); + } + + @override + bool operator ==(Object other) { + return other is TimeRange && other.end == end && other.duration == duration; + } + + @override + int get hashCode => end.hashCode ^ duration.hashCode; + + static const List values = [ + all, + lastWeek, + lastMonth, + lastHalfYear, + lastYear, + ]; +} + +enum TimeRangeType { + all("All"), + lastWeek("Last Week"), + lastMonth("Last Month"), + lastHalfYear("Last Half Year"), + lastYear("Last Year"), + custom("Custom"); + + final String value; + + const TimeRangeType(this.value); +} diff --git a/lib/pages/reader/gesture.dart b/lib/pages/reader/gesture.dart index 3e3525b..7c513fb 100644 --- a/lib/pages/reader/gesture.dart +++ b/lib/pages/reader/gesture.dart @@ -20,7 +20,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { static const _kTapToTurnPagePercent = 0.3; - _DragListener? dragListener; + final _dragListeners = <_DragListener>[]; int fingers = 0; @@ -44,19 +44,23 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { _lastTapPointer = event.pointer; _lastTapMoveDistance = Offset.zero; _tapGestureRecognizer.addPointer(event); - if(_dragInProgress) { - dragListener?.onEnd?.call(); + if (_dragInProgress) { + for (var dragListener in _dragListeners) { + dragListener.onStart?.call(event.position); + } _dragInProgress = false; } Future.delayed(_kLongPressMinTime, () { if (_lastTapPointer == event.pointer && fingers == 1) { - if(_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) { + if (_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) { onLongPressedDown(event.position); _longPressInProgress = true; } else { _dragInProgress = true; - dragListener?.onStart?.call(event.position); - dragListener?.onMove?.call(_lastTapMoveDistance!); + for (var dragListener in _dragListeners) { + dragListener.onStart?.call(event.position); + dragListener.onMove?.call(_lastTapMoveDistance!); + } } } }); @@ -65,8 +69,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { if (event.pointer == _lastTapPointer) { _lastTapMoveDistance = event.delta + _lastTapMoveDistance!; } - if(_dragInProgress) { - dragListener?.onMove?.call(event.delta); + if (_dragInProgress) { + for (var dragListener in _dragListeners) { + dragListener.onMove?.call(event.delta); + } } }, onPointerUp: (event) { @@ -74,8 +80,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { if (_longPressInProgress) { onLongPressedUp(event.position); } - if(_dragInProgress) { - dragListener?.onEnd?.call(); + if (_dragInProgress) { + for (var dragListener in _dragListeners) { + dragListener.onEnd?.call(); + } _dragInProgress = false; } _lastTapPointer = null; @@ -86,8 +94,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { if (_longPressInProgress) { onLongPressedUp(event.position); } - if(_dragInProgress) { - dragListener?.onEnd?.call(); + if (_dragInProgress) { + for (var dragListener in _dragListeners) { + dragListener.onEnd?.call(); + } _dragInProgress = false; } _lastTapPointer = null; @@ -261,6 +271,14 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { void onLongPressedDown(Offset location) { context.reader._imageViewController?.handleLongPressDown(location); } + + void addDragListener(_DragListener listener) { + _dragListeners.add(listener); + } + + void removeDragListener(_DragListener listener) { + _dragListeners.remove(listener); + } } class _DragListener { @@ -269,4 +287,4 @@ class _DragListener { void Function()? onEnd; _DragListener({this.onMove, this.onEnd}); -} \ No newline at end of file +} diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index 2d74801..d1a76ee 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -263,6 +263,10 @@ class _GalleryModeState extends State<_GalleryMode> @override void handleDoubleTap(Offset location) { + if (appdata.settings['quickCollectImage'] == 'DoubleTap') { + context.readerScaffold.addImageFavorite(); + return; + } var controller = photoViewControllers[reader.page]!; controller.onDoubleClick?.call(); } @@ -461,7 +465,7 @@ class _ContinuousModeState extends State<_ContinuousMode> widget = Listener( onPointerDown: (event) { fingers++; - if(fingers > 1 && !disableScroll) { + if (fingers > 1 && !disableScroll) { setState(() { disableScroll = true; }); @@ -475,7 +479,7 @@ class _ContinuousModeState extends State<_ContinuousMode> }, onPointerUp: (event) { fingers--; - if(fingers <= 1 && disableScroll) { + if (fingers <= 1 && disableScroll) { setState(() { disableScroll = false; }); @@ -564,6 +568,10 @@ class _ContinuousModeState extends State<_ContinuousMode> @override void handleDoubleTap(Offset location) { + if (appdata.settings['quickCollectImage'] == 'DoubleTap') { + context.readerScaffold.addImageFavorite(); + return; + } double target; if (photoViewController.scale != photoViewController.getInitialScale?.call()) { diff --git a/lib/pages/reader/loading.dart b/lib/pages/reader/loading.dart index c0c7e27..37cdd8e 100644 --- a/lib/pages/reader/loading.dart +++ b/lib/pages/reader/loading.dart @@ -5,12 +5,18 @@ class ReaderWithLoading extends StatefulWidget { super.key, required this.id, required this.sourceKey, + this.initialEp, + this.initialPage, }); final String id; final String sourceKey; + final int? initialEp; + + final int? initialPage; + @override State createState() => _ReaderWithLoadingState(); } @@ -25,8 +31,10 @@ class _ReaderWithLoadingState name: data.name, chapters: data.chapters, history: data.history, - initialChapter: data.history.ep, - initialPage: data.history.page, + initialChapter: widget.initialEp ?? data.history.ep, + initialPage: widget.initialPage ?? data.history.page, + author: data.author, + tags: data.tags, ); } @@ -57,6 +65,8 @@ class _ReaderWithLoadingState ep: 0, page: 0, ), + author: localComic.subtitle, + tags: localComic.tags, ), ); } else { @@ -76,6 +86,8 @@ class _ReaderWithLoadingState ep: 0, page: 0, ), + author: comic.data.findAuthor() ?? "", + tags: comic.data.plainTags, ), ); } @@ -93,11 +105,17 @@ class ReaderProps { final History history; + final String author; + + final List tags; + const ReaderProps({ required this.type, required this.cid, required this.name, required this.chapters, required this.history, + required this.author, + required this.tags, }); } diff --git a/lib/pages/reader/reader.dart b/lib/pages/reader/reader.dart index 35f8b90..37018ac 100644 --- a/lib/pages/reader/reader.dart +++ b/lib/pages/reader/reader.dart @@ -20,6 +20,7 @@ import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; +import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/local.dart'; @@ -27,8 +28,10 @@ import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/data_sync.dart'; +import 'package:venera/utils/ext.dart'; import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/io.dart'; +import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; import 'package:venera/utils/volume.dart'; import 'package:window_manager/window_manager.dart'; @@ -57,10 +60,16 @@ class Reader extends StatefulWidget { required this.history, this.initialPage, this.initialChapter, + required this.author, + required this.tags, }); final ComicType type; + final String author; + + final List tags; + final String cid; final String name; @@ -114,12 +123,14 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { void _checkImagesPerPageChange() { int currentImagesPerPage = imagesPerPage; if (_lastImagesPerPage != currentImagesPerPage) { - _adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage); + _adjustPageForImagesPerPageChange( + _lastImagesPerPage, currentImagesPerPage); _lastImagesPerPage = currentImagesPerPage; } } - void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) { + void _adjustPageForImagesPerPageChange( + int oldImagesPerPage, int newImagesPerPage) { int previousImageIndex = (page - 1) * oldImagesPerPage; int newPage = (previousImageIndex ~/ newImagesPerPage) + 1; page = newPage; @@ -150,7 +161,7 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { updateHistory(); }); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - if(appdata.settings['enableTurnPageByVolumeKey']) { + if (appdata.settings['enableTurnPageByVolumeKey']) { handleVolumeEvent(); } setImageCacheSize(); @@ -170,7 +181,8 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { } else { maxImageCacheSize = 500 << 20; } - Log.info("Reader", "Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize"); + Log.info("Reader", + "Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize"); PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize; } @@ -215,7 +227,7 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { } void updateHistory() { - if(history != null) { + if (history != null) { history!.page = page; history!.ep = chapter; if (maxPage > 1) { @@ -228,11 +240,11 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { } void handleVolumeEvent() { - if(!App.isAndroid) { + if (!App.isAndroid) { // Currently only support Android return; } - if(volumeListener != null) { + if (volumeListener != null) { volumeListener?.cancel(); } volumeListener = VolumeListener( @@ -246,7 +258,7 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { } void stopVolumeEvent() { - if(volumeListener != null) { + if (volumeListener != null) { volumeListener?.cancel(); volumeListener = null; } @@ -306,7 +318,8 @@ abstract mixin class _ReaderLocation { bool toPage(int page) { if (_validatePage(page)) { if (page == this.page) { - if(!(chapter == 1 && page == 1) && !(chapter == maxChapter && page == maxPage)) { + if (!(chapter == 1 && page == 1) && + !(chapter == maxChapter && page == maxPage)) { return false; } } diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 33cf7ef..3249ae3 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -18,8 +18,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { bool get isOpen => _isOpen; - bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft || - context.reader.mode == ReaderMode.continuousRightToLeft; + bool get isReversed => + context.reader.mode == ReaderMode.galleryRightToLeft || + context.reader.mode == ReaderMode.continuousRightToLeft; int showFloatingButtonValue = 0; @@ -29,6 +30,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { _ReaderGestureDetectorState? _gestureDetectorState; + _DragListener? _floatingButtonDragListener; + void setFloatingButton(int value) { lastValue = showFloatingButtonValue; if (value == 0) { @@ -37,12 +40,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { fABValue.value = 0; update(); } - _gestureDetectorState!.dragListener = null; + if (_floatingButtonDragListener != null) { + _gestureDetectorState!.removeDragListener(_floatingButtonDragListener!); + _floatingButtonDragListener = null; + } } var readerMode = context.reader.mode; if (value == 1 && showFloatingButtonValue == 0) { showFloatingButtonValue = 1; - _gestureDetectorState!.dragListener = _DragListener( + _floatingButtonDragListener = _DragListener( onMove: (offset) { if (readerMode == ReaderMode.continuousTopToBottom) { fABValue.value -= offset.dy; @@ -62,10 +68,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { fABValue.value = 0; }, ); + _gestureDetectorState!.addDragListener(_floatingButtonDragListener!); update(); } else if (value == -1 && showFloatingButtonValue == 0) { showFloatingButtonValue = -1; - _gestureDetectorState!.dragListener = _DragListener( + _floatingButtonDragListener = _DragListener( onMove: (offset) { if (readerMode == ReaderMode.continuousTopToBottom) { fABValue.value += offset.dy; @@ -85,10 +92,48 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { fABValue.value = 0; }, ); + _gestureDetectorState!.addDragListener(_floatingButtonDragListener!); update(); } } + _DragListener? _imageFavoriteDragListener; + + void addDragListener() async { + if (!mounted) return; + var readerMode = context.reader.mode; + + // 横向阅读的时候, 如果纵向滑就触发收藏, 纵向阅读的时候, 如果横向滑动就触发收藏 + if (appdata.settings['quickCollectImage'] == 'Swipe') { + if (_imageFavoriteDragListener == null) { + double distance = 0; + _imageFavoriteDragListener = _DragListener( + onMove: (offset) { + switch (readerMode) { + case ReaderMode.continuousTopToBottom: + case ReaderMode.galleryTopToBottom: + distance += offset.dx; + case ReaderMode.continuousLeftToRight: + case ReaderMode.galleryLeftToRight: + case ReaderMode.galleryRightToLeft: + case ReaderMode.continuousRightToLeft: + distance += offset.dy; + } + }, + onEnd: () { + if (distance.abs() > 150) { + addImageFavorite(); + } + distance = 0; + }, + ); + } + _gestureDetectorState!.addDragListener(_imageFavoriteDragListener!); + } else if (_imageFavoriteDragListener != null) { + _gestureDetectorState!.removeDragListener(_imageFavoriteDragListener!); + } + } + @override void initState() { sliderFocus.canRequestFocus = false; @@ -101,6 +146,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { SystemChrome.setPreferredOrientations(DeviceOrientation.values); } super.initState(); + Future.delayed(const Duration(milliseconds: 200), addDragListener); } @override @@ -203,6 +249,123 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ); } + bool isLiked() { + return ImageFavoriteManager().has( + context.reader.cid, + context.reader.type.sourceKey, + context.reader.eid, + context.reader.page, + context.reader.chapter, + ); + } + + void addImageFavorite() { + try { + if (context.reader.images![0].contains('file://')) { + showToast( + message: "Local comic collection is not supported at present".tl, + context: context); + return; + } + String id = context.reader.cid; + int ep = context.reader.chapter; + String eid = context.reader.eid; + String title = context.reader.history!.title; + String subTitle = context.reader.history!.subtitle; + int maxPage = context.reader.images!.length; + int page = context.reader.page; + String sourceKey = context.reader.type.sourceKey; + String imageKey = context.reader.images![page - 1]; + List tags = context.reader.widget.tags; + String author = context.reader.widget.author; + + var epName = context.reader.widget.chapters?.values + .elementAtOrNull(context.reader.chapter - 1) ?? + "E${context.reader.chapter}"; + var translatedTags = tags.map((e) => e.translateTagsToCN).toList(); + + if (isLiked()) { + if (page == firstPage) { + showToast( + message: "The cover cannot be uncollected here".tl, + context: context, + ); + return; + } + ImageFavoriteManager().deleteImageFavorite([ + ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName) + ]); + showToast( + message: "Uncollected the image".tl, + context: context, + seconds: 1, + ); + } else { + var imageFavoritesComic = ImageFavoriteManager().find(id, sourceKey) ?? + ImageFavoritesComic( + id, + [], + title, + sourceKey, + tags, + translatedTags, + DateTime.now(), + author, + {}, + subTitle, + maxPage, + ); + ImageFavorite imageFavorite = + ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName); + ImageFavoritesEp? imageFavoritesEp = + imageFavoritesComic.imageFavoritesEp.firstWhereOrNull((e) { + return e.ep == ep; + }); + if (imageFavoritesEp == null) { + if (page != firstPage) { + var copy = imageFavorite.copyWith( + page: firstPage, + isAutoFavorite: true, + imageKey: context.reader.images![0], + ); + // 不是第一页的话, 自动塞一个封面进去 + imageFavoritesEp = ImageFavoritesEp( + eid, ep, [copy, imageFavorite], epName, maxPage); + } else { + imageFavoritesEp = + ImageFavoritesEp(eid, ep, [imageFavorite], epName, maxPage); + } + imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp); + } else { + if (imageFavoritesEp.eid != eid) { + // 空字符串说明是从pica导入的, 那我们就手动刷一遍保证一致 + if (imageFavoritesEp.eid == "") { + imageFavoritesEp.eid == eid; + } else { + // 避免多章节漫画源的章节顺序发生变化, 如果情况比较多, 做一个以eid为准更新ep的功能 + showToast( + message: + "The chapter order of the comic may have changed, temporarily not supported for collection" + .tl, + context: context, + ); + return; + } + } + imageFavoritesEp.imageFavorites.add(imageFavorite); + } + + ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic); + showToast( + message: "Successfully collected".tl, context: context, seconds: 1); + } + update(); + } catch (e, stackTrace) { + Log.error("Image Favorite", e, stackTrace); + showToast(message: e.toString(), context: context, seconds: 1); + } + } + Widget buildBottom() { var text = "E${context.reader.chapter} : P${context.reader.page}"; if (context.reader.widget.chapters == null) { @@ -233,13 +396,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { child: buildSlider(), ), IconButton.filledTonal( - onPressed: () => !isReversed - ? context.reader.chapter < context.reader.maxChapter - ? context.reader.toNextChapter() - : context.reader.toPage(context.reader.maxPage) - : context.reader.chapter > 1 - ? context.reader.toPrevChapter() - : context.reader.toPage(1), + onPressed: () => !isReversed + ? context.reader.chapter < context.reader.maxChapter + ? context.reader.toNextChapter() + : context.reader.toPage(context.reader.maxPage) + : context.reader.chapter > 1 + ? context.reader.toPrevChapter() + : context.reader.toPage(1), icon: const Icon(Icons.last_page)), const SizedBox( width: 8, @@ -263,6 +426,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ), ), const Spacer(), + Tooltip( + message: "Collect the image".tl, + child: IconButton( + icon: Icon( + isLiked() ? Icons.favorite : Icons.favorite_border), + onPressed: addImageFavorite), + ), if (App.isWindows) Tooltip( message: "${"Full Screen".tl}(F12)", @@ -358,12 +528,14 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { child: Container( decoration: BoxDecoration( color: context.colorScheme.surface.toOpacity(0.82), - border: Border( - top: BorderSide( - color: Colors.grey.toOpacity(0.5), - width: 0.5, - ), - ), + border: isOpen + ? Border( + top: BorderSide( + color: Colors.grey.toOpacity(0.5), + width: 0.5, + ), + ) + : null, ), padding: EdgeInsets.only(bottom: context.padding.bottom), child: child, @@ -559,7 +731,6 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { onChanged: (key) { if (key == "readerMode") { context.reader.mode = ReaderMode.fromKey(appdata.settings[key]); - App.rootContext.pop(); } if (key == "enableTurnPageByVolumeKey") { if (appdata.settings[key]) { @@ -568,6 +739,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { context.reader.stopVolumeEvent(); } } + if (key == "quickCollectImage") { + addDragListener(); + } context.reader.update(); }, ), diff --git a/lib/pages/settings/app.dart b/lib/pages/settings/app.dart index c59e19e..368de45 100644 --- a/lib/pages/settings/app.dart +++ b/lib/pages/settings/app.dart @@ -107,15 +107,16 @@ class _AppSettingsState extends State { actionTitle: 'Export'.tl, ).toSliver(), _CallbackSetting( - title: "Import App Data".tl, + title: "Import App Data (Please restart after success)".tl, callback: () async { var controller = showLoadingDialog(context); var file = await selectFile(ext: ['venera', 'picadata']); if (file != null) { - var cacheFile = File(FilePath.join(App.cachePath, "import_data_temp")); + var cacheFile = + File(FilePath.join(App.cachePath, "import_data_temp")); await file.saveTo(cacheFile.path); try { - if(file.name.endsWith('picadata')) { + if (file.name.endsWith('picadata')) { await importPicaData(cacheFile); } else { await importAppData(cacheFile); @@ -123,8 +124,7 @@ class _AppSettingsState extends State { } catch (e, s) { Log.error("Import data", e.toString(), s); context.showMessage(message: "Failed to import data".tl); - } - finally { + } finally { cacheFile.deleteIgnoreError(); } } diff --git a/lib/pages/settings/local_favorites.dart b/lib/pages/settings/local_favorites.dart index 04511fa..260450d 100644 --- a/lib/pages/settings/local_favorites.dart +++ b/lib/pages/settings/local_favorites.dart @@ -33,7 +33,9 @@ class _LocalFavoritesSettingsState extends State { SelectSetting( title: "Quick Favorite".tl, settingKey: "quickFavorite", - help: "Long press on the favorite button to quickly add to this folder".tl, + help: + "Long press on the favorite button to quickly add to this folder" + .tl, optionTranslation: { for (var e in LocalFavoritesManager().folderNames) e: e }, @@ -44,7 +46,8 @@ class _LocalFavoritesSettingsState extends State { var controller = showLoadingDialog(context); var count = await LocalFavoritesManager().removeInvalid(); controller.close(); - context.showMessage(message: "Deleted @a favorite items".tlParams({'a': count})); + context.showMessage( + message: "Deleted @a favorite items".tlParams({'a': count})); }, actionTitle: 'Delete'.tl, ).toSliver(), diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index 1c6f05d..778a215 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -116,6 +116,21 @@ class _ReaderSettingsState extends State { widget.onChanged?.call("enableClockAndBatteryInfoInReader"); }, ).toSliver(), + SelectSetting( + title: "Quick collect image".tl, + settingKey: "quickCollectImage", + optionTranslation: { + "No": "Not enable".tl, + "DoubleTap": "Double Tap".tl, + "Swipe": "Swipe".tl, + }, + onChanged: () { + widget.onChanged?.call("quickCollectImage"); + }, + help: + "On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode" + .tl, + ).toSliver(), _PopupWindowSetting( title: "Custom Image Processing".tl, builder: () => _CustomImageProcessing(), diff --git a/lib/utils/data.dart b/lib/utils/data.dart index a427af2..08ab2e7 100644 --- a/lib/utils/data.dart +++ b/lib/utils/data.dart @@ -10,6 +10,7 @@ import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/network/cookie_jar.dart'; +import 'package:venera/utils/ext.dart'; import 'package:zip_flutter/zip_flutter.dart'; import 'io.dart'; @@ -128,7 +129,24 @@ Future importPicaData(File file) async { .select("SELECT name FROM sqlite_master WHERE type='table';") .map((e) => e["name"] as String) .toList(); - folderNames.removeWhere((e) => e == "folder_order" || e == "folder_sync"); + folderNames + .removeWhere((e) => e == "folder_order" || e == "folder_sync"); + for (var folderSyncValue in db.select("SELECT * FROM folder_sync;")) { + var folderName = folderSyncValue["folder_name"]; + String sourceKey = folderSyncValue["key"]; + sourceKey = + sourceKey.toLowerCase() == "htmanga" ? "wnacg" : sourceKey; + // 有值就跳过 + if (LocalFavoritesManager().findLinked(folderName).$1 != null) { + continue; + } + try { + LocalFavoritesManager().linkFolderToNetwork(folderName, sourceKey, + jsonDecode(folderSyncValue["sync_data"])["folderId"]); + } catch (e, stack) { + Log.error(e.toString(), stack); + } + } for (var folderName in folderNames) { if (!LocalFavoritesManager().existsFolder(folderName)) { LocalFavoritesManager().createFolder(folderName); @@ -141,7 +159,7 @@ Future importPicaData(File file) async { name: comic['name'], coverPath: comic['cover_path'], author: comic['author'], - type: ComicType(switch(comic['type']) { + type: ComicType(switch (comic['type']) { 0 => 'picacg'.hashCode, 1 => 'ehentai'.hashCode, 2 => 'jm'.hashCode, @@ -155,11 +173,9 @@ Future importPicaData(File file) async { ); } } - } - catch(e) { + } catch (e) { Log.error("Import Data", "Failed to import local favorite: $e"); - } - finally { + } finally { db.dispose(); } } @@ -170,31 +186,80 @@ Future importPicaData(File file) async { for (var comic in db.select("SELECT * FROM history;")) { HistoryManager().addHistory( History.fromMap({ - "type": switch(comic['type']) { + "type": switch (comic['type']) { 0 => 'picacg'.hashCode, 1 => 'ehentai'.hashCode, 2 => 'jm'.hashCode, 3 => 'hitomi'.hashCode, 4 => 'wnacg'.hashCode, - 6 => 'nhentai'.hashCode, + 5 => 'nhentai'.hashCode, _ => comic['type'] }, "id": comic['target'], - "maxPage": comic["max_page"], + "max_page": comic["max_page"], "ep": comic["ep"], "page": comic["page"], "time": comic["time"], "title": comic["title"], "subtitle": comic["subtitle"], "cover": comic["cover"], + "readEpisode": [comic["ep"]], }), ); } - } - catch(e) { - Log.error("Import Data", "Failed to import history: $e"); - } - finally { + List imageFavoritesComicList = + ImageFavoriteManager().comics; + for (var comic in db.select("SELECT * FROM image_favorites;")) { + String sourceKey = comic["id"].split("-")[0]; + // 换名字了, 绅士漫画 + if (sourceKey.toLowerCase() == "htmanga") { + sourceKey = "wnacg"; + } + if (ComicSource.find(sourceKey) == null) { + continue; + } + String id = comic["id"].split("-")[1]; + int page = comic["page"]; + // 章节和page是从1开始的, pica 可能有从 0 开始的, 得转一下 + int ep = comic["ep"] == 0 ? 1 : comic["ep"]; + String title = comic["title"]; + String epName = ""; + ImageFavoritesComic? tempComic = imageFavoritesComicList + .firstWhereOrNull((e) => e.id == id && e.sourceKey == sourceKey); + ImageFavorite curImageFavorite = + ImageFavorite(page, "", null, "", id, ep, sourceKey, epName); + if (tempComic == null) { + tempComic = ImageFavoritesComic(id, [], title, sourceKey, [], [], + DateTime.now(), "", {}, "", 1); + tempComic.imageFavoritesEp = [ + ImageFavoritesEp("", ep, [curImageFavorite], epName, 1) + ]; + imageFavoritesComicList.add(tempComic); + } else { + ImageFavoritesEp? tempEp = + tempComic.imageFavoritesEp.firstWhereOrNull((e) => e.ep == ep); + if (tempEp == null) { + tempComic.imageFavoritesEp + .add(ImageFavoritesEp("", ep, [curImageFavorite], epName, 1)); + } else { + // 如果已经有这个page了, 就不添加了 + if (tempEp.imageFavorites + .firstWhereOrNull((e) => e.page == page) == + null) { + tempEp.imageFavorites.add(curImageFavorite); + } + } + } + } + for (var temp in imageFavoritesComicList) { + ImageFavoriteManager().addOrUpdateOrDelete( + temp, + temp == imageFavoritesComicList.last, + ); + } + } catch (e, stack) { + Log.error("Import Data", "Failed to import history: $e", stack); + } finally { db.dispose(); } } diff --git a/lib/utils/ext.dart b/lib/utils/ext.dart index 172bab3..5a9e534 100644 --- a/lib/utils/ext.dart +++ b/lib/utils/ext.dart @@ -95,6 +95,8 @@ extension StringExt on String{ bool get isURL => _isURL(); bool get isNum => double.tryParse(this) != null; + + bool get isInt => int.tryParse(this) != null; } abstract class ListOrNull{ diff --git a/lib/utils/io.dart b/lib/utils/io.dart index 79020d4..dc6df55 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -376,7 +376,6 @@ class _IOOverrides extends IOOverrides { return super.createFile(path); } } - } T overrideIO(T Function() f) { diff --git a/pubspec.lock b/pubspec.lock index d6e8e6d..89f5900 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1147,4 +1147,4 @@ packages: version: "0.0.6" sdks: dart: ">=3.6.0 <4.0.0" - flutter: ">=3.27.1" + flutter: ">=3.27.1" \ No newline at end of file