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/init.js b/assets/init.js index 7994a0b..5e7b234 100644 --- a/assets/init.js +++ b/assets/init.js @@ -4,6 +4,13 @@ Venera JavaScript Library This library provides a set of APIs for interacting with the Venera app. */ +function setTimeout(callback, delay) { + sendMessage({ + method: 'delay', + time: delay, + }).then(callback); +} + /// encode, decode, hash, decrypt let Convert = { /** @@ -486,6 +493,37 @@ let Network = { }, }; +/** + * [fetch] function for sending HTTP requests. Same api as the browser fetch. + * @param url {string} + * @param options {{method: string, headers: Object, body: any}} + * @returns {Promise<{ok: boolean, status: number, statusText: string, headers: {}, arrayBuffer: (function(): Promise), text: (function(): Promise), json: (function(): Promise)}>} + * @since 1.2.0 + */ +async function fetch(url, options) { + let method = 'GET'; + let headers = {}; + let data = null; + + if (options) { + method = options.method || method; + headers = options.headers || headers; + data = options.body || data; + } + + let result = await Network.fetchBytes(method, url, headers, data); + + return { + ok: result.status >= 200 && result.status < 300, + status: result.status, + statusText: '', + headers: result.headers, + arrayBuffer: async () => result.body, + text: async () => Convert.decodeUtf8(result.body), + json: async () => JSON.parse(Convert.decodeUtf8(result.body)), + } +} + /** * HtmlDocument class for parsing HTML and querying elements. */ @@ -1166,3 +1204,45 @@ class Image { return new Image(key); } } + +let UI = { + /** + * Show a message + * @param message {string} + */ + showMessage: (message) => { + sendMessage({ + method: 'UI', + function: 'showMessage', + message: message, + }) + }, + + /** + * Show a dialog. Any action will close the dialog. + * @param title {string} + * @param content {string} + * @param actions {{text:string, callback: () => void}[]} + */ + showDialog: (title, content, actions) => { + sendMessage({ + method: 'UI', + function: 'showDialog', + title: title, + content: content, + actions: actions, + }) + }, + + /** + * Open [url] in external browser + * @param url {string} + */ + launchUrl: (url) => { + sendMessage({ + method: 'UI', + function: 'launchUrl', + url: url, + }) + }, +} \ No newline at end of file diff --git a/assets/translation.json b/assets/translation.json index 24a5e6c..eb2d5c4 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": "移动到文件夹", @@ -153,8 +154,8 @@ "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。", "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n", "Export as cbz": "导出为cbz", - "Select a cbz/zip file." : "选择一个cbz/zip文件", - "A cbz file" : "一个cbz文件", + "Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)", + "An archive file" : "一个归档文件", "Fullscreen": "全屏", "Exit": "退出", "View more": "查看更多", @@ -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": "下载已开始", @@ -255,8 +297,8 @@ "End": "末尾", "None": "无", "View Detail": "查看详情", - "Select a directory which contains multiple cbz/zip files." : "选择一个包含多个cbz/zip文件的目录", - "Multiple cbz files" : "多个cbz文件", + "Select a directory which contains multiple archive files." : "选择一个包含多个归档文件的目录", + "Multiple archive files" : "多个归档文件", "No valid comics found" : "未找到有效的漫画", "Enable DNS Overrides": "启用DNS覆写", "DNS Overrides": "DNS覆写", @@ -265,7 +307,18 @@ "Aggregated": "聚合", "Default Search Target": "默认搜索目标", "Auto Language Filters": "自动语言筛选", - "Check for updates on startup": "启动时检查更新" + "Check for updates on startup": "启动时检查更新", + "Start Time": "开始时间", + "End Time": "结束时间", + "Custom": "自定义", + "Reset": "重置", + "Tags": "标签", + "Authors": "作者", + "Comics": "漫画", + "Imported @a comics": "已导入 @a 本漫画", + "New Version": "新版本", + "@c updates": "@c 项更新", + "No updates": "无更新" }, "zh_TW": { "Home": "首頁", @@ -287,7 +340,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": "刪除", @@ -421,8 +474,8 @@ "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。", "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n", "Export as cbz": "匯出為cbz", - "Select a cbz/zip file." : "選擇一個cbz/zip文件", - "A cbz file" : "一個cbz文件", + "Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)", + "An archive file" : "一個歸檔文件", "Fullscreen": "全螢幕", "Exit": "退出", "View more": "查看更多", @@ -431,8 +484,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,11 +574,52 @@ "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": "查看詳情", - "Select a directory which contains multiple cbz/zip files." : "選擇一個包含多個cbz/zip文件的目錄", - "Multiple cbz files" : "多個cbz文件", + "Select a directory which contains multiple archive files." : "選擇一個包含多個歸檔文件的目錄", + "Multiple archive files" : "多個歸檔文件", "No valid comics found" : "未找到有效的漫畫", "Enable DNS Overrides": "啟用DNS覆寫", "DNS Overrides": "DNS覆寫", @@ -533,6 +628,17 @@ "Aggregated": "聚合", "Default Search Target": "默認搜索目標", "Auto Language Filters": "自動語言篩選", - "Check for updates on startup": "啟動時檢查更新" + "Check for updates on startup": "啟動時檢查更新", + "Start Time": "開始時間", + "End Time": "結束時間", + "Custom": "自定義", + "Reset": "重置", + "Tags": "標籤", + "Authors": "作者", + "Comics": "漫畫", + "Imported @a comics": "已匯入 @a 部漫畫", + "New Version": "新版本", + "@c updates": "@c 項更新", + "No updates": "無更新" } } \ 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..9ffe801 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -1,5 +1,27 @@ part of 'components.dart'; +ImageProvider? _findImageProvider(Comic comic) { + ImageProvider image; + if (comic is LocalComic) { + image = LocalComicImageProvider(comic); + } else if (comic is History) { + image = HistoryImageProvider(comic); + } else if (comic.sourceKey == 'local') { + var localComic = LocalManager().find(comic.id, ComicType.local); + if (localComic == null) { + return null; + } + image = FileImage(localComic.coverFile); + } else { + image = CachedImageProvider( + comic.cover, + sourceKey: comic.sourceKey, + cid: comic.id, + ); + } + return image; +} + class ComicTile extends StatelessWidget { const ComicTile( {super.key, @@ -27,8 +49,14 @@ class ComicTile extends StatelessWidget { onTap!(); return; } - App.mainNavigatorKey?.currentContext - ?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); + App.mainNavigatorKey?.currentContext?.to( + () => ComicPage( + id: comic.id, + sourceKey: comic.sourceKey, + cover: comic.cover, + title: comic.title, + ), + ); } void _onLongPressed(context) { @@ -61,8 +89,14 @@ class ComicTile extends StatelessWidget { icon: Icons.chrome_reader_mode_outlined, text: 'Details'.tl, onClick: () { - App.mainNavigatorKey?.currentContext - ?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); + App.mainNavigatorKey?.currentContext?.to( + () => ComicPage( + id: comic.id, + sourceKey: comic.sourceKey, + cover: comic.cover, + title: comic.title, + ), + ); }, ), MenuEntry( @@ -161,23 +195,9 @@ class ComicTile extends StatelessWidget { } Widget buildImage(BuildContext context) { - ImageProvider image; - if (comic is LocalComic) { - image = LocalComicImageProvider(comic as LocalComic); - } else if (comic is History) { - image = HistoryImageProvider(comic as History); - } else if (comic.sourceKey == 'local') { - var localComic = LocalManager().find(comic.id, ComicType.local); - if (localComic == null) { - return const SizedBox(); - } - image = FileImage(localComic.coverFile); - } else { - image = CachedImageProvider( - comic.cover, - sourceKey: comic.sourceKey, - cid: comic.id, - ); + var image = _findImageProvider(comic); + if (image == null) { + return const SizedBox(); } return AnimatedImage( image: image, @@ -199,15 +219,25 @@ class ComicTile extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), child: Row( children: [ - Container( - width: height * 0.68, - height: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), + Hero( + tag: "cover${comic.id}${comic.sourceKey}", + child: Container( + width: height * 0.68, + height: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: context.colorScheme.outlineVariant, + blurRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: buildImage(context), ), - clipBehavior: Clip.antiAlias, - child: buildImage(context), ), SizedBox.fromSize( size: const Size(16, 5), @@ -248,20 +278,23 @@ class ComicTile extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.toOpacity(0.2), - blurRadius: 2, - offset: const Offset(0, 2), - ), - ], + child: Hero( + tag: "cover${comic.id}${comic.sourceKey}", + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.toOpacity(0.2), + blurRadius: 2, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: buildImage(context), ), - clipBehavior: Clip.antiAlias, - child: buildImage(context), ), ), Align( @@ -726,9 +759,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(); @@ -1393,7 +1433,7 @@ class _RatingWidgetState extends State { } if (full < widget.count) { children.add(ClipRect( - clipper: SMClipper(rating: star() * widget.size), + clipper: _SMClipper(rating: star() * widget.size), child: Icon( Icons.star, size: widget.size, @@ -1442,10 +1482,10 @@ class _RatingWidgetState extends State { } } -class SMClipper extends CustomClipper { +class _SMClipper extends CustomClipper { final double rating; - SMClipper({required this.rating}); + _SMClipper({required this.rating}); @override Rect getClip(Size size) { @@ -1453,7 +1493,52 @@ class SMClipper extends CustomClipper { } @override - bool shouldReclip(SMClipper oldClipper) { + bool shouldReclip(_SMClipper oldClipper) { return rating != oldClipper.rating; } } + +class SimpleComicTile extends StatelessWidget { + const SimpleComicTile({super.key, required this.comic, this.onTap}); + + final Comic comic; + + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + var image = _findImageProvider(comic); + + var child = image == null + ? const SizedBox() + : AnimatedImage( + image: image, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ); + + return AnimatedTapRegion( + borderRadius: 8, + onTap: onTap ?? () { + context.to( + () => ComicPage( + id: comic.id, + sourceKey: comic.sourceKey, + ), + ); + }, + child: Container( + width: 92, + height: 114, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.secondaryContainer, + ), + clipBehavior: Clip.antiAlias, + child: child, + ), + ); + } +} diff --git a/lib/components/gesture.dart b/lib/components/gesture.dart index 10bc71e..44fd07c 100644 --- a/lib/components/gesture.dart +++ b/lib/components/gesture.dart @@ -41,39 +41,44 @@ class AnimatedTapRegion extends StatefulWidget { } class _AnimatedTapRegionState extends State { - bool isScaled = false; - bool isHovered = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) { - isHovered = true; - if (!isScaled) { - Future.delayed(const Duration(milliseconds: 100), () { - if (isHovered) { - setState(() => isScaled = true); - } - }); - } + setState(() { + isHovered = true; + }); }, onExit: (_) { - isHovered = false; - if(isScaled) { - setState(() => isScaled = false); - } + setState(() { + isHovered = false; + }); }, child: GestureDetector( onTap: widget.onTap, - child: ClipRRect( - borderRadius: BorderRadius.circular(widget.borderRadius), - clipBehavior: Clip.antiAlias, - child: AnimatedScale( - duration: _fastAnimationDuration, - scale: isScaled ? 1.1 : 1, - child: widget.child, + child: AnimatedContainer( + duration: _fastAnimationDuration, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(widget.borderRadius), + boxShadow: isHovered + ? [ + BoxShadow( + color: context.colorScheme.outline, + blurRadius: 2, + offset: const Offset(0, 2), + ), + ] + : [ + BoxShadow( + color: context.colorScheme.outlineVariant, + blurRadius: 1, + offset: const Offset(0, 1), + ), + ], ), + child: widget.child, ), ), ); 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/navigation_bar.dart b/lib/components/navigation_bar.dart index 09dc06e..791436f 100644 --- a/lib/components/navigation_bar.dart +++ b/lib/components/navigation_bar.dart @@ -200,15 +200,17 @@ class NaviPaneState extends State } Widget buildMainView() { - return Navigator( - observers: [widget.observer], - key: widget.navigatorKey, - onGenerateRoute: (settings) => AppPageRoute( - preventRebuild: false, - isRootRoute: true, - builder: (context) { - return _NaviMainView(state: this); - }, + return HeroControllerScope( + controller: MaterialApp.createMaterialHeroController(), + child: Navigator( + observers: [widget.observer], + key: widget.navigatorKey, + onGenerateRoute: (settings) => AppPageRoute( + preventRebuild: false, + builder: (context) { + return _NaviMainView(state: this); + }, + ), ), ); } @@ -362,16 +364,14 @@ class _SideNaviWidget extends StatelessWidget { color: enabled ? colorScheme.primaryContainer : null, borderRadius: BorderRadius.circular(12), ), - child: showTitle ? Row( - children: [ - icon, - const SizedBox(width: 12), - Text(entry.label) - ], - ) : Align( - alignment: Alignment.centerLeft, - child: icon, - ), + child: showTitle + ? Row( + children: [icon, const SizedBox(width: 12), Text(entry.label)], + ) + : Align( + alignment: Alignment.centerLeft, + child: icon, + ), ), ).paddingVertical(4); } @@ -395,16 +395,14 @@ class _PaneActionWidget extends StatelessWidget { duration: const Duration(milliseconds: 180), padding: const EdgeInsets.symmetric(horizontal: 12), height: 38, - child: showTitle ? Row( - children: [ - icon, - const SizedBox(width: 12), - Text(entry.label) - ], - ) : Align( - alignment: Alignment.centerLeft, - child: icon, - ), + child: showTitle + ? Row( + children: [icon, const SizedBox(width: 12), Text(entry.label)], + ) + : Align( + alignment: Alignment.centerLeft, + child: icon, + ), ), ).paddingVertical(4); } 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/app.dart b/lib/foundation/app.dart index 91b7f81..bb909cc 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -10,7 +10,7 @@ export "widget_utils.dart"; export "context.dart"; class _App { - final version = "1.1.4"; + final version = "1.2.0"; bool get isAndroid => Platform.isAndroid; diff --git a/lib/foundation/app_page_route.dart b/lib/foundation/app_page_route.dart index 39d3bd6..e273a03 100644 --- a/lib/foundation/app_page_route.dart +++ b/lib/foundation/app_page_route.dart @@ -19,7 +19,6 @@ class AppPageRoute extends PageRoute with _AppRouteTransitionMixin{ super.barrierDismissible = false, this.enableIOSGesture = true, this.preventRebuild = true, - this.isRootRoute = false, }) { assert(opaque); } @@ -50,9 +49,6 @@ class AppPageRoute extends PageRoute with _AppRouteTransitionMixin{ @override final bool preventRebuild; - - @override - final bool isRootRoute; } mixin _AppRouteTransitionMixin on PageRoute { @@ -79,8 +75,6 @@ mixin _AppRouteTransitionMixin on PageRoute { bool get preventRebuild; - bool get isRootRoute; - Widget? _child; @override @@ -121,22 +115,6 @@ mixin _AppRouteTransitionMixin on PageRoute { @override Widget buildTransitions(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { - if(isRootRoute) { - return FadeTransition( - opacity: Tween(begin: 0, end: 1.0).animate(CurvedAnimation( - parent: animation, - curve: Curves.ease - )), - child: FadeTransition( - opacity: Tween(begin: 1.0, end: 0).animate(CurvedAnimation( - parent: secondaryAnimation, - curve: Curves.ease - )), - child: child, - ), - ); - } - return SlidePageTransitionBuilder().buildTransitions( this, context, diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index b193f81..320dc10 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -85,6 +85,7 @@ class _Appdata { "proxy", "authorizationRequired", "customImageProcessing", + "webdav", ]; /// Sync data from another device @@ -143,12 +144,13 @@ class _Settings with ChangeNotifier { 'quickFavorite': null, 'enableTurnPageByVolumeKey': true, 'enableClockAndBatteryInfoInReader': true, + 'quickCollectImage': 'No', // No, DoubleTap, Swipe 'authorizationRequired': false, 'onClickFavorite': 'viewDetail', // viewDetail, read 'enableDnsOverrides': false, 'dnsOverrides': {}, 'enableCustomImageProcessing': false, - 'customImageProcessing': _defaultCustomImageProcessing, + 'customImageProcessing': defaultCustomImageProcessing, 'sni': true, 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese }; @@ -168,15 +170,20 @@ class _Settings with ChangeNotifier { } } -const _defaultCustomImageProcessing = ''' +const defaultCustomImageProcessing = ''' /** * Process an image - * @param image {ArayBuffer} - The image to process + * @param image {ArrayBuffer} - The image to process * @param cid {string} - The comic ID * @param eid {string} - The episode ID - * @returns {Promise} - The processed image + * @param page {number} - The page number + * @param sourceKey {string} - The source key + * @returns {Promise | {image: Promise, onCancel: () => void}} - The processed image */ -async function processImage(image, cid, eid) { +function processImage(image, cid, eid, page, sourceKey) { + let image = new Promise((resolve, reject) => { + resolve(image); + }); return image; } -'''; \ No newline at end of file +'''; diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index ec5386e..7eed7e4 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/widgets.dart'; +import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/history.dart'; @@ -136,6 +137,8 @@ class ComicSource { notifyListeners(); } + static final availableUpdates = {}; + static bool get isEmpty => _sources.isEmpty; /// Name of this source. @@ -201,7 +204,7 @@ class ComicSource { final LikeCommentFunc? likeCommentFunc; - final Map? settings; + final Map>? settings; final Map>? translations; 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..4266193 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -1,5 +1,6 @@ part of 'comic_source.dart'; +/// return true if ver1 > ver2 bool compareSemVer(String ver1, String ver2) { ver1 = ver1.replaceFirst("-", "."); ver2 = ver2.replaceFirst("-", "."); @@ -193,7 +194,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 +503,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 +619,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 +772,7 @@ class ComicSourceParser { addFolder: addFolder, deleteFolder: deleteFolder, addOrDelFavorite: addOrDelFavFunc, + isOldToNewSort: isOldToNewSort, ); } @@ -920,8 +923,30 @@ class ComicSourceParser { }; } - Map _parseSettings() { - return _getValue("settings") ?? {}; + Map> _parseSettings() { + var value = _getValue("settings"); + if (value is Map) { + var newMap = >{}; + for (var e in value.entries) { + if (e.key is! String) { + continue; + } + var v = {}; + for (var e2 in e.value.entries) { + if (e2.key is! String) { + continue; + } + var v2 = e2.value; + if (v2 is JSInvokable) { + v2 = JSAutoFreeFunction(v2); + } + v[e2.key] = v2; + } + newMap[e.key] = v; + } + return newMap; + } + return {}; } RegExp? _parseIdMatch() { 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/favorites.dart b/lib/foundation/favorites.dart index 6671aba..48c76d8 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -594,7 +594,10 @@ class LocalFavoritesManager with ChangeNotifier { notifyListeners(); } - void onReadEnd(String id, ComicType type) async { + void onRead(String id, ComicType type) async { + if (appdata.settings['moveFavoriteAfterRead'] == "none") { + return; + } _modifiedAfterLastCache = true; for (final folder in folderNames) { var rows = _db.select(""" diff --git a/lib/foundation/history.dart b/lib/foundation/history.dart index 1867264..062055f 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)) { @@ -319,6 +337,7 @@ class HistoryManager with ChangeNotifier { } void close() { + isInitialized = false; _db.dispose(); } } 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..6fb1d29 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 { @@ -77,7 +78,13 @@ abstract class BaseImageProvider> while (data == null && !stop) { try { - data = await load(chunkEvents); + data = await load(chunkEvents, () { + if (stop) { + throw const _ImageLoadingStopException(); + } + }); + } on _ImageLoadingStopException { + rethrow; } catch (e) { if (e.toString().contains("Invalid Status Code: 404")) { rethrow; @@ -99,7 +106,7 @@ abstract class BaseImageProvider> } if (stop) { - throw Exception("Image loading is stopped"); + throw const _ImageLoadingStopException(); } if (data!.isEmpty) { @@ -126,17 +133,23 @@ abstract class BaseImageProvider> } rethrow; } - } catch (e) { + } on _ImageLoadingStopException { + rethrow; + } catch (e, s) { scheduleMicrotask(() { PaintingBinding.instance.imageCache.evict(key); }); + Log.error("Image Loading", e, s); rethrow; } finally { chunkEvents.close(); } } - Future load(StreamController chunkEvents); + Future load( + StreamController chunkEvents, + void Function() checkStop, + ); String get key; @@ -157,3 +170,7 @@ abstract class BaseImageProvider> } typedef FileDecoderCallback = Future Function(Uint8List); + +class _ImageLoadingStopException implements Exception { + const _ImageLoadingStopException(); +} diff --git a/lib/foundation/image_provider/cached_image.dart b/lib/foundation/image_provider/cached_image.dart index be5cd4a..6a4fd54 100644 --- a/lib/foundation/image_provider/cached_image.dart +++ b/lib/foundation/image_provider/cached_image.dart @@ -1,4 +1,4 @@ -import 'dart:async' show Future, StreamController; +import 'dart:async' show Future; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/network/images.dart'; @@ -26,9 +26,10 @@ class CachedImageProvider static const _kMaxLoadingCount = 8; @override - Future load(StreamController chunkEvents) async { + Future load(chunkEvents, checkStop) async { while(loadingCount > _kMaxLoadingCount) { await Future.delayed(const Duration(milliseconds: 100)); + checkStop(); } loadingCount++; try { @@ -37,6 +38,7 @@ class CachedImageProvider return file.readAsBytes(); } await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { + checkStop(); chunkEvents.add(ImageChunkEvent( cumulativeBytesLoaded: progress.currentBytes, expectedTotalBytes: progress.totalBytes, diff --git a/lib/foundation/image_provider/history_image_provider.dart b/lib/foundation/image_provider/history_image_provider.dart index 4e2475a..d188b34 100644 --- a/lib/foundation/image_provider/history_image_provider.dart +++ b/lib/foundation/image_provider/history_image_provider.dart @@ -1,4 +1,4 @@ -import 'dart:async' show Future, StreamController; +import 'dart:async' show Future; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/foundation/local.dart'; @@ -17,7 +17,7 @@ class HistoryImageProvider final History history; @override - Future load(StreamController chunkEvents) async { + Future load(chunkEvents, checkStop) async { var url = history.cover; if (!url.contains('/')) { var localComic = LocalManager().find(history.id, history.type); @@ -27,6 +27,7 @@ class HistoryImageProvider var comicSource = history.type.comicSource ?? (throw "Comic source not found."); var comic = await comicSource.loadComicInfo!(history.id); + checkStop(); url = comic.data.cover; history.cover = url; HistoryManager().addHistory(history); @@ -36,6 +37,7 @@ class HistoryImageProvider history.type.sourceKey, history.id, )) { + checkStop(); chunkEvents.add(ImageChunkEvent( cumulativeBytesLoaded: progress.currentBytes, expectedTotalBytes: progress.totalBytes, 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..0ccea9d --- /dev/null +++ b/lib/foundation/image_provider/image_favorites_provider.dart @@ -0,0 +1,155 @@ +import 'dart:async' show Future, StreamController; +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, + void Function()? checkStop, + ) async { + var imageKey = imageFavorite.imageKey; + var localImage = await getImageFromLocal(); + checkStop?.call(); + if (localImage != null) { + return localImage; + } + var cacheImage = await readFromCache(); + checkStop?.call(); + if (cacheImage != null) { + return cacheImage; + } + var gotImageKey = false; + if (imageKey == "") { + imageKey = await getImageKey(); + checkStop?.call(); + gotImageKey = true; + } + Uint8List image; + try { + image = await getImageFromNetwork(imageKey, chunkEvents, checkStop); + } catch (e) { + if (gotImageKey) { + rethrow; + } else { + imageKey = await getImageKey(); + image = await getImageFromNetwork(imageKey, chunkEvents, checkStop); + } + } + 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, + void Function()? checkStop, + ) async { + await for (var progress + in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) { + checkStop?.call(); + 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_comic_image.dart b/lib/foundation/image_provider/local_comic_image.dart index 591b840..860894a 100644 --- a/lib/foundation/image_provider/local_comic_image.dart +++ b/lib/foundation/image_provider/local_comic_image.dart @@ -1,4 +1,4 @@ -import 'dart:async' show Future, StreamController; +import 'dart:async' show Future; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/foundation/local.dart'; @@ -16,7 +16,7 @@ class LocalComicImageProvider final LocalComic comic; @override - Future load(StreamController chunkEvents) async { + Future load(chunkEvents, checkStop) async { File? file = comic.coverFile; if(! await file.exists()) { file = null; @@ -49,6 +49,7 @@ class LocalComicImageProvider if(file == null) { throw "Error: Cover not found."; } + checkStop(); var data = await file.readAsBytes(); if(data.isEmpty) { throw "Exception: Empty file(${file.path})."; diff --git a/lib/foundation/image_provider/local_favorite_image.dart b/lib/foundation/image_provider/local_favorite_image.dart index ade4a7b..11fbb2e 100644 --- a/lib/foundation/image_provider/local_favorite_image.dart +++ b/lib/foundation/image_provider/local_favorite_image.dart @@ -1,4 +1,4 @@ -import 'dart:async' show Future, StreamController; +import 'dart:async' show Future; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/foundation/app.dart'; @@ -22,13 +22,13 @@ 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(); } } @override - Future load(StreamController chunkEvents) async { + Future load(chunkEvents, checkStop) async { var sourceKey = ComicSource.fromIntKey(intKey)?.key; var fileName = key.hashCode.toString(); var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName)); @@ -37,12 +37,14 @@ class LocalFavoriteImageProvider } else { await file.create(recursive: true); } + checkStop(); await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) { + checkStop(); chunkEvents.add(ImageChunkEvent( 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 +54,8 @@ class LocalFavoriteImageProvider } @override - Future obtainKey(ImageConfiguration configuration) { + Future obtainKey( + ImageConfiguration configuration) { return SynchronousFuture(this); } diff --git a/lib/foundation/image_provider/reader_image.dart b/lib/foundation/image_provider/reader_image.dart index 0afc72a..9c93a95 100644 --- a/lib/foundation/image_provider/reader_image.dart +++ b/lib/foundation/image_provider/reader_image.dart @@ -1,4 +1,4 @@ -import 'dart:async' show Future, StreamController; +import 'dart:async' show Future; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_qjs/flutter_qjs.dart'; @@ -12,7 +12,7 @@ import 'package:venera/foundation/appdata.dart'; class ReaderImageProvider extends BaseImageProvider { /// Image provider for normal image. - const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid); + const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid, this.page); final String imageKey; @@ -22,8 +22,10 @@ class ReaderImageProvider final String eid; + final int page; + @override - Future load(StreamController chunkEvents) async { + Future load(chunkEvents, checkStop) async { Uint8List? imageBytes; if (imageKey.startsWith('file://')) { var file = File(imageKey); @@ -35,6 +37,7 @@ class ReaderImageProvider } else { await for (var event in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) { + checkStop(); chunkEvents.add(ImageChunkEvent( cumulativeBytesLoaded: event.currentBytes, expectedTotalBytes: event.totalBytes, @@ -60,14 +63,57 @@ class ReaderImageProvider })() '''); if (func is JSInvokable) { - var result = await func.invoke([imageBytes, cid, eid]); - func.free(); + var result = func.invoke([imageBytes, cid, eid, page, sourceKey]); if (result is Uint8List) { - return result; + imageBytes = result; + } else if (result is Future) { + var futureResult = await result; + if (futureResult is Uint8List) { + imageBytes = futureResult; + } + } else if (result is Map) { + var image = result['image']; + if (image is Uint8List) { + imageBytes = image; + } else if (image is Future) { + JSInvokable? onCancel; + if (result['onCancel'] is JSInvokable) { + onCancel = result['onCancel']; + } + if (onCancel == null) { + var futureImage = await image; + if (futureImage is Uint8List) { + imageBytes = futureImage; + } + } else { + dynamic futureImage; + image.then((value) { + futureImage = value; + futureImage ??= Uint8List(0); + }); + while (futureImage == null) { + try { + checkStop(); + } + catch(e) { + onCancel.invoke([]); + onCancel.free(); + func.free(); + rethrow; + } + await Future.delayed(Duration(milliseconds: 50)); + } + if (futureImage is Uint8List) { + imageBytes = futureImage; + } + } + onCancel?.free(); + } } + func.free(); } } - return imageBytes; + return imageBytes!; } @override diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index f7e935a..9accea8 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:crypto/crypto.dart'; import 'package:dio/io.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html; import 'package:html/dom.dart' as dom; @@ -19,7 +20,9 @@ import 'package:pointycastle/block/modes/cbc.dart'; import 'package:pointycastle/block/modes/cfb.dart'; import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ofb.dart'; +import 'package:url_launcher/url_launcher_string.dart'; import 'package:uuid/uuid.dart'; +import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/network/app_dio.dart'; import 'package:venera/network/cookie_jar.dart'; @@ -39,7 +42,7 @@ class JavaScriptRuntimeException implements Exception { } } -class JsEngine with _JSEngineApi { +class JsEngine with _JSEngineApi, _JsUiApi { factory JsEngine() => _cache ?? (_cache = JsEngine._create()); static JsEngine? _cache; @@ -93,85 +96,67 @@ class JsEngine with _JSEngineApi { String method = message["method"] as String; switch (method) { case "log": - { - String level = message["level"]; - Log.addLog( - switch (level) { - "error" => LogLevel.error, - "warning" => LogLevel.warning, - "info" => LogLevel.info, - _ => LogLevel.warning - }, - message["title"], - message["content"].toString()); - } + String level = message["level"]; + Log.addLog( + switch (level) { + "error" => LogLevel.error, + "warning" => LogLevel.warning, + "info" => LogLevel.info, + _ => LogLevel.warning + }, + message["title"], + message["content"].toString()); case 'load_data': - { - String key = message["key"]; - String dataKey = message["data_key"]; - return ComicSource.find(key)?.data[dataKey]; - } + String key = message["key"]; + String dataKey = message["data_key"]; + return ComicSource.find(key)?.data[dataKey]; case 'save_data': - { - String key = message["key"]; - String dataKey = message["data_key"]; - if (dataKey == 'setting') { - throw "setting is not allowed to be saved"; - } - var data = message["data"]; - var source = ComicSource.find(key)!; - source.data[dataKey] = data; - source.saveData(); + String key = message["key"]; + String dataKey = message["data_key"]; + if (dataKey == 'setting') { + throw "setting is not allowed to be saved"; } + var data = message["data"]; + var source = ComicSource.find(key)!; + source.data[dataKey] = data; + source.saveData(); case 'delete_data': - { - String key = message["key"]; - String dataKey = message["data_key"]; - var source = ComicSource.find(key); - source?.data.remove(dataKey); - source?.saveData(); - } + String key = message["key"]; + String dataKey = message["data_key"]; + var source = ComicSource.find(key); + source?.data.remove(dataKey); + source?.saveData(); case 'http': - { - return _http(Map.from(message)); - } + return _http(Map.from(message)); case 'html': - { - return handleHtmlCallback(Map.from(message)); - } + return handleHtmlCallback(Map.from(message)); case 'convert': - { - return _convert(Map.from(message)); - } + return _convert(Map.from(message)); case "random": - { - return _random( - message["min"] ?? 0, - message["max"] ?? 1, - message["type"], - ); - } + return _random( + message["min"] ?? 0, + message["max"] ?? 1, + message["type"], + ); case "cookie": - { - return handleCookieCallback(Map.from(message)); - } + return handleCookieCallback(Map.from(message)); case "uuid": - { - return const Uuid().v1(); - } + return const Uuid().v1(); case "load_setting": - { - String key = message["key"]; - String settingKey = message["setting_key"]; - var source = ComicSource.find(key)!; - return source.data["settings"]?[settingKey] ?? - source.settings?[settingKey]['default'] ?? - (throw "Setting not found: $settingKey"); - } + String key = message["key"]; + String settingKey = message["setting_key"]; + var source = ComicSource.find(key)!; + return source.data["settings"]?[settingKey] ?? + source.settings?[settingKey]!['default'] ?? + (throw "Setting not found: $settingKey"); case "isLogged": - { - return ComicSource.find(message["key"])!.isLogged; - } + return ComicSource.find(message["key"])!.isLogged; + // temporary solution for [setTimeout] function + // TODO: implement [setTimeout] in quickjs project + case "delay": + return Future.delayed(Duration(milliseconds: message["time"])); + case "UI": + handleUIMessage(Map.from(message)); } } return null; @@ -688,3 +673,62 @@ class DocumentWrapper { return elements.length - 1; } } + +class JSAutoFreeFunction { + final JSInvokable func; + + /// Automatically free the function when it's not used anymore + JSAutoFreeFunction(this.func) { + finalizer.attach(this, func); + } + + dynamic call(List args) { + return func(args); + } + + static final finalizer = Finalizer((func) { + func.free(); + }); +} + +mixin class _JsUiApi { + void handleUIMessage(Map message) { + switch (message['function']) { + case 'showMessage': + var m = message['message']; + if (m.toString().isNotEmpty) { + App.rootContext.showMessage(message: m.toString()); + } + case 'showDialog': + _showDialog(message); + case 'launchUrl': + var url = message['url']; + if (url.toString().isNotEmpty) { + launchUrlString(url.toString()); + } + } + } + + void _showDialog(Map message) { + var title = message['title']; + var content = message['content']; + var actions = {}; + for (var action in message['actions']) { + actions[action['text']] = JSAutoFreeFunction(action['callback']); + } + showDialog(context: App.rootContext, builder: (context) { + return ContentDialog( + title: title, + content: Text(content).paddingHorizontal(16), + actions: actions.entries.map((entry) { + return TextButton( + onPressed: () { + entry.value.call([]); + }, + child: Text(entry.key), + ); + }).toList(), + ); + }); + } +} 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/network/cache.dart b/lib/network/cache.dart index 9d9df06..a2b6465 100644 --- a/lib/network/cache.dart +++ b/lib/network/cache.dart @@ -42,6 +42,9 @@ class NetworkCacheManager implements Interceptor { static const _maxCacheSize = 10 * 1024 * 1024; void setCache(NetworkCache cache) { + if (_cache.containsKey(cache.uri)) { + size -= _cache[cache.uri]!.size; + } while (size > _maxCacheSize) { size -= _cache.values.first.size; _cache.remove(_cache.keys.first); @@ -94,7 +97,7 @@ class NetworkCacheManager implements Interceptor { var time = DateTime.now(); var diff = time.difference(cache.time); if (options.headers['cache-time'] == 'long' && - diff < const Duration(hours: 2)) { + diff < const Duration(hours: 6)) { return handler.resolve(Response( requestOptions: options, data: cache.data, @@ -110,7 +113,7 @@ class NetworkCacheManager implements Interceptor { ..set('venera-cache', 'true'), statusCode: 200, )); - } else if (diff < const Duration(hours: 1)) { + } else if (diff < const Duration(hours: 2)) { var o = options.copyWith( method: "HEAD", ); @@ -132,15 +135,42 @@ class NetworkCacheManager implements Interceptor { } static bool compareHeaders(Map a, Map b) { - a.remove('cache-time'); - a.remove('prevent-parallel'); - b.remove('cache-time'); - b.remove('prevent-parallel'); + const shouldIgnore = [ + 'cache-time', + 'prevent-parallel', + 'date', + 'x-varnish', + 'cf-ray', + 'connection', + 'vary', + 'content-encoding', + 'report-to', + 'server-timing', + 'token', + 'set-cookie', + 'cf-cache-status', + 'cf-request-id', + 'cf-ray', + 'authorization', + ]; + for (var key in shouldIgnore) { + a.remove(key); + b.remove(key); + } if (a.length != b.length) { return false; } for (var key in a.keys) { - if (a[key] != b[key]) { + if (a[key] is List && b[key] is List) { + if (a[key].length != b[key].length) { + return false; + } + for (var i = 0; i < a[key].length; i++) { + if (a[key][i] != b[key][i]) { + return false; + } + } + } else if (a[key] != b[key]) { return false; } } @@ -161,7 +191,7 @@ class NetworkCacheManager implements Interceptor { var cache = NetworkCache( uri: response.requestOptions.uri, requestHeaders: response.requestOptions.headers, - responseHeaders: response.headers.map, + responseHeaders: Map.from(response.headers.map), data: response.data, time: DateTime.now(), size: size, diff --git a/lib/pages/aggregated_search_page.dart b/lib/pages/aggregated_search_page.dart index 16007cd..06093a4 100644 --- a/lib/pages/aggregated_search_page.dart +++ b/lib/pages/aggregated_search_page.dart @@ -1,14 +1,11 @@ import "package:flutter/material.dart"; -import "package:shimmer/shimmer.dart"; +import 'package:shimmer_animation/shimmer_animation.dart'; import "package:venera/components/components.dart"; import "package:venera/foundation/app.dart"; import "package:venera/foundation/comic_source/comic_source.dart"; -import "package:venera/foundation/image_provider/cached_image.dart"; import "package:venera/pages/search_result_page.dart"; import "package:venera/utils/translations.dart"; -import "comic_page.dart"; - class AggregatedSearchPage extends StatefulWidget { const AggregatedSearchPage({super.key, required this.keyword}); @@ -73,9 +70,9 @@ class _SliverSearchResultState extends State<_SliverSearchResult> with AutomaticKeepAliveClientMixin { bool isLoading = true; - static const _kComicHeight = 144.0; + static const _kComicHeight = 132.0; - get _comicWidth => _kComicHeight * 0.72; + get _comicWidth => _kComicHeight * 0.7; static const _kLeftPadding = 16.0; @@ -123,28 +120,9 @@ class _SliverSearchResultState extends State<_SliverSearchResult> } Widget buildComic(Comic c) { - return AnimatedTapRegion( - borderRadius: 8, - onTap: () { - context.to(() => ComicPage( - id: c.id, - sourceKey: c.sourceKey, - )); - }, - child: Container( - height: _kComicHeight, - width: _comicWidth, - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainerLow, - ), - child: AnimatedImage( - width: _comicWidth, - height: _kComicHeight, - fit: BoxFit.cover, - image: CachedImageProvider(c.cover), - ), - ), - ).paddingLeft(_kLeftPadding); + return SimpleComicTile(comic: c) + .paddingLeft(_kLeftPadding) + .paddingBottom(2); } @override @@ -169,10 +147,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult> SizedBox( height: _kComicHeight, width: double.infinity, - child: Shimmer.fromColors( - baseColor: context.colorScheme.surfaceContainerLow, - highlightColor: context.colorScheme.surfaceContainer, - direction: ShimmerDirection.ltr, + child: Shimmer( child: LayoutBuilder(builder: (context, constrains) { var itemWidth = _comicWidth + _kLeftPadding; var items = (constrains.maxWidth / itemWidth).ceil(); diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index d78c893..6fdc3e6 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shimmer_animation/shimmer_animation.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:venera/components/components.dart'; @@ -26,12 +27,22 @@ import 'dart:math' as math; import 'comments_page.dart'; class ComicPage extends StatefulWidget { - const ComicPage({super.key, required this.id, required this.sourceKey}); + const ComicPage({ + super.key, + required this.id, + required this.sourceKey, + this.cover, + this.title, + }); final String id; final String sourceKey; + final String? cover; + + final String? title; + @override State createState() => _ComicPageState(); } @@ -55,13 +66,11 @@ class _ComicPageState extends LoadingState @override Widget buildLoading() { - return Column( - children: [ - const Appbar(title: Text("")), - Expanded( - child: super.buildLoading(), - ), - ], + return _ComicPageLoadingPlaceHolder( + cover: widget.cover, + title: widget.title, + sourceKey: widget.sourceKey, + cid: widget.id, ); } @@ -145,6 +154,8 @@ class _ComicPageState extends LoadingState ep: 0, page: 0, ), + author: localComic.subTitle ?? '', + tags: localComic.tags, ); }); App.mainNavigatorKey!.currentContext!.pop(); @@ -199,21 +210,32 @@ class _ComicPageState extends LoadingState crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(width: 16), - Container( - decoration: BoxDecoration( - color: context.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - height: 144, - width: 144 * 0.72, - clipBehavior: Clip.antiAlias, - child: AnimatedImage( - image: CachedImageProvider( - comic.cover, - sourceKey: comic.sourceKey, + Hero( + tag: "cover${comic.id}${comic.sourceKey}", + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: context.colorScheme.outlineVariant, + blurRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + height: 144, + width: 144 * 0.72, + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + image: CachedImageProvider( + widget.cover ?? comic.cover, + sourceKey: comic.sourceKey, + cid: comic.id, + ), + width: double.infinity, + height: double.infinity, ), - width: double.infinity, - height: double.infinity, ), ), const SizedBox(width: 16), @@ -663,6 +685,8 @@ abstract mixin class _ComicPageActions { initialChapter: ep, initialPage: page, history: History.fromModel(model: comic, ep: 0, page: 0), + author: comic.findAuthor() ?? '', + tags: comic.plainTags, ), ); } @@ -1217,9 +1241,11 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> { } else { error = res.errorMessage; } - setState(() { - isLoading = false; - }); + if (mounted) { + setState(() { + isLoading = false; + }); + } } @override @@ -1942,3 +1968,124 @@ class _CommentWidget extends StatelessWidget { ); } } + +class _ComicPageLoadingPlaceHolder extends StatelessWidget { + const _ComicPageLoadingPlaceHolder({ + this.cover, + this.title, + required this.sourceKey, + required this.cid, + }); + + final String? cover; + + final String? title; + + final String sourceKey; + + final String cid; + + @override + Widget build(BuildContext context) { + Widget buildContainer(double? width, double? height, + {Color? color, double? radius}) { + return Container( + height: height, + width: width, + decoration: BoxDecoration( + color: color ?? context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(radius ?? 4), + ), + ); + } + + return Shimmer( + child: Column( + children: [ + Appbar(title: Text(""), backgroundColor: context.colorScheme.surface), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 16), + buildImage(context), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Text(title ?? "", style: ts.s18) + else + buildContainer(200, 25), + const SizedBox(height: 8), + buildContainer(80, 20), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + if (context.width < changePoint) + Row( + children: [ + Expanded( + child: buildContainer(null, 36, radius: 18), + ), + const SizedBox(width: 16), + Expanded( + child: buildContainer(null, 36, radius: 18), + ), + ], + ).paddingHorizontal(16), + const Divider(), + const SizedBox(height: 8), + Center( + child: CircularProgressIndicator( + strokeWidth: 2.4, + ).fixHeight(24).fixWidth(24), + ) + ], + ), + ); + } + + Widget buildImage(BuildContext context) { + Widget child; + if (cover != null) { + child = AnimatedImage( + image: CachedImageProvider( + cover!, + sourceKey: sourceKey, + cid: cid, + ), + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); + } else { + child = const SizedBox(); + } + + return Hero( + tag: "cover$cid$sourceKey", + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: context.colorScheme.outlineVariant, + blurRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + height: 144, + width: 144 * 0.72, + clipBehavior: Clip.antiAlias, + child: child, + ), + ); + } +} diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index 9e7c3f6..dd43ef1 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -14,17 +14,15 @@ import 'package:venera/utils/translations.dart'; class ComicSourcePage extends StatefulWidget { const ComicSourcePage({super.key}); - static Future checkComicSourceUpdate([bool implicit = false]) async { + static Future checkComicSourceUpdate() async { if (ComicSource.all().isEmpty) { - return; + return 0; } - var controller = implicit ? null : showLoadingDialog(App.rootContext); var dio = AppDio(); var res = await dio.get( "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json"); if (res.statusCode != 200) { - App.rootContext.showMessage(message: "Network error".tl); - return; + return -1; } var list = jsonDecode(res.data!) as List; var versions = {}; @@ -34,34 +32,17 @@ class ComicSourcePage extends StatefulWidget { var shouldUpdate = []; for (var source in ComicSource.all()) { if (versions.containsKey(source.key) && - versions[source.key] != source.version) { + compareSemVer(versions[source.key]!, source.version)) { shouldUpdate.add(source.key); } } - controller?.close(); - if (shouldUpdate.isEmpty) { - if (!implicit) { - App.rootContext.showMessage(message: "No Update Available".tl); + if (shouldUpdate.isNotEmpty) { + for (var key in shouldUpdate) { + ComicSource.availableUpdates[key] = versions[key]!; } - return; + ComicSource.notifyListeners(); } - var msg = ""; - for (var key in shouldUpdate) { - msg += "${ComicSource.find(key)?.name}: v${versions[key]}\n"; - } - msg = msg.trim(); - await showConfirmDialog( - context: App.rootContext, - title: "Updates Available".tl, - content: msg, - confirmText: "Update", - onConfirm: () async { - for (var key in shouldUpdate) { - var source = ComicSource.find(key); - await _BodyState.update(source!); - } - }, - ); + return shouldUpdate.length; } @override @@ -72,9 +53,6 @@ class _ComicSourcePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: Appbar( - title: Text('Comic Source'.tl), - ), body: const _Body(), ); } @@ -90,10 +68,30 @@ class _Body extends StatefulWidget { class _BodyState extends State<_Body> { var url = ""; + void updateUI() { + setState(() {}); + } + + @override + void initState() { + super.initState(); + ComicSource.addListener(updateUI); + } + + @override + void dispose() { + super.dispose(); + ComicSource.removeListener(updateUI); + } + @override Widget build(BuildContext context) { return SmoothCustomScrollView( slivers: [ + SliverAppbar( + title: Text('Comic Source'.tl), + style: AppbarStyle.shadow, + ), buildCard(context), for (var source in ComicSource.all()) buildSource(context, source), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), @@ -102,12 +100,36 @@ class _BodyState extends State<_Body> { } Widget buildSource(BuildContext context, ComicSource source) { + var newVersion = ComicSource.availableUpdates[source.key]; + bool hasUpdate = + newVersion != null && compareSemVer(newVersion, source.version); return SliverToBoxAdapter( child: Column( children: [ const Divider(), ListTile( - title: Text(source.name), + title: Row( + children: [ + Text(source.name), + const SizedBox(width: 6), + if (hasUpdate) + Tooltip( + message: newVersion, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + "New Version".tl, + style: const TextStyle(fontSize: 13), + ), + ), + ) + ], + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -223,6 +245,8 @@ class _BodyState extends State<_Body> { }, ), ); + } else if (type == "callback") { + yield _CallbackSetting(setting: item); } } catch (e, s) { Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s"); @@ -299,6 +323,9 @@ class _BodyState extends State<_Body> { controller.close(); await ComicSourceParser().parse(res.data!, source.filePath); await File(source.filePath).writeAsString(res.data!); + if (ComicSource.availableUpdates.containsKey(source.key)) { + ComicSource.availableUpdates.remove(source.key); + } } catch (e) { if (cancel) return; App.rootContext.showMessage(message: e.toString()); @@ -368,10 +395,7 @@ class _BodyState extends State<_Body> { ), ListTile( title: Text("Check updates".tl), - trailing: buildButton( - onPressed: () => ComicSourcePage.checkComicSourceUpdate(false), - child: Text("Check".tl), - ), + trailing: _CheckUpdatesButton(), ), const SizedBox(height: 8), ], @@ -619,3 +643,88 @@ class __EditFilePageState extends State<_EditFilePage> { ); } } + +class _CheckUpdatesButton extends StatefulWidget { + const _CheckUpdatesButton(); + + @override + State<_CheckUpdatesButton> createState() => _CheckUpdatesButtonState(); +} + +class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> { + bool isLoading = false; + + void check() async { + setState(() { + isLoading = true; + }); + var count = await ComicSourcePage.checkComicSourceUpdate(); + if (count == -1) { + context.showMessage(message: "Network error".tl); + } else if (count == 0) { + context.showMessage(message: "No updates".tl); + } else { + context.showMessage(message: "@c updates".tlParams({"c": count})); + } + setState(() { + isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Button.normal( + onPressed: check, + isLoading: isLoading, + child: Text("Check".tl), + ).fixHeight(32); + } +} + +class _CallbackSetting extends StatefulWidget { + const _CallbackSetting({required this.setting}); + + final MapEntry> setting; + + @override + State<_CallbackSetting> createState() => _CallbackSettingState(); +} + +class _CallbackSettingState extends State<_CallbackSetting> { + String get key => widget.setting.key; + + String get buttonText => widget.setting.value['buttonText'] ?? "Click"; + + String get title => widget.setting.value['title'] ?? key; + + bool isLoading = false; + + Future onClick() async { + var func = widget.setting.value['callback']; + var result = func([]); + if (result is Future) { + setState(() { + isLoading = true; + }); + try { + await result; + } finally { + setState(() { + isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(title.ts(key)), + trailing: Button.normal( + onPressed: onClick, + isLoading: isLoading, + child: Text(buttonText.ts(key)), + ).fixHeight(32), + ); + } +} 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..7be3e27 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -50,9 +50,16 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { var (a, b) = LocalFavoritesManager().findLinked(widget.folder); networkSource = a; networkFolder = b; + LocalFavoritesManager().addListener(updateComics); super.initState(); } + @override + void dispose() { + super.dispose(); + LocalFavoritesManager().removeListener(updateComics); + } + void selectAll() { setState(() { selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); @@ -136,17 +143,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 +161,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { context.pop(); importNetworkFolder( networkSource!, + selectUpdatePageNumKey + .currentState!.updatePageNum, widget.folder, networkFolder!, ).then( @@ -380,6 +389,35 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { selections: selectedComics, menuBuilder: (c) { return [ + MenuEntry( + icon: Icons.delete, + text: "Delete".tl, + onClick: () { + LocalFavoritesManager().deleteComicWithId( + widget.folder, + c.id, + (c as FavoriteItem).type, + ); + }, + ), + MenuEntry( + icon: Icons.check, + text: "Select".tl, + onClick: () { + setState(() { + if (!multiSelectMode) { + multiSelectMode = true; + } + if (selectedComics.containsKey(c as FavoriteItem)) { + selectedComics.remove(c); + _checkExitSelectMode(); + } else { + selectedComics[c] = true; + } + lastSelectedIndex = comics.indexOf(c); + }); + }, + ), MenuEntry( icon: Icons.download, text: "Download".tl, @@ -655,7 +693,6 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { (c as FavoriteItem).type, ); } - updateComics(); _cancel(); } } @@ -741,6 +778,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 +824,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..e9579c3 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -6,17 +6,18 @@ import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart'; 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 +36,7 @@ class HomePage extends StatelessWidget { const _Local(), const _ComicSourceWidget(), const _AccountsWidget(), + const ImageFavorites(), SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), ], ); @@ -83,7 +85,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 +96,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs } void update() { - if(mounted) { + if (mounted) { setState(() {}); } } @@ -110,8 +113,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 +124,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 +162,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(); + }), ], ), ), @@ -264,8 +265,8 @@ class _HistoryState extends State<_History> { scrollDirection: Axis.horizontal, itemCount: history.length, itemBuilder: (context, index) { - return AnimatedTapRegion( - borderRadius: 8, + return SimpleComicTile( + comic: history[index], onTap: () { context.to( () => ComicPage( @@ -274,25 +275,7 @@ class _HistoryState extends State<_History> { ), ); }, - child: Container( - width: 92, - height: 114, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context) - .colorScheme - .secondaryContainer, - ), - clipBehavior: Clip.antiAlias, - child: AnimatedImage( - image: HistoryImageProvider(history[index]), - width: 96, - height: 128, - fit: BoxFit.cover, - filterQuality: FilterQuality.medium, - ), - ), - ).paddingHorizontal(8); + ).paddingHorizontal(8).paddingVertical(2); }, ), ).paddingHorizontal(8).paddingBottom(16), @@ -385,32 +368,8 @@ class _LocalState extends State<_Local> { scrollDirection: Axis.horizontal, itemCount: local.length, itemBuilder: (context, index) { - return AnimatedTapRegion( - onTap: () { - local[index].read(); - }, - borderRadius: 8, - child: Container( - width: 92, - height: 114, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Theme.of(context) - .colorScheme - .secondaryContainer, - ), - clipBehavior: Clip.antiAlias, - child: AnimatedImage( - image: LocalComicImageProvider( - local[index], - ), - width: 96, - height: 128, - fit: BoxFit.cover, - filterQuality: FilterQuality.medium, - ), - ), - ).paddingHorizontal(8); + return SimpleComicTile(comic: local[index]) + .paddingHorizontal(8); }, ), ).paddingHorizontal(8), @@ -494,15 +453,15 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { String info = [ "Select a directory which contains the comic files.".tl, "Select a directory which contains the comic directories.".tl, - "Select a cbz/zip file.".tl, - "Select a directory which contains multiple cbz/zip files.".tl, + "Select an archive file (cbz, zip, 7z, cb7)".tl, + "Select a directory which contains multiple archive files.".tl, "Select an EhViewer database and a download folder.".tl ][type]; List importMethods = [ "Single Comic".tl, "Multiple Comics".tl, - "A cbz file".tl, - "Multiple cbz files".tl, + "An archive file".tl, + "Multiple archive files".tl, "EhViewer downloads".tl ]; @@ -518,50 +477,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 != 4) + 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 && type != 2 && type != 3) + 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 +550,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 +585,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 +594,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { 4 => await importer.ehViewer(), int() => true, }; - if(result) { + if (result) { context.pop(); } else { setState(() { @@ -736,6 +696,30 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> { }).toList(), ).paddingHorizontal(16).paddingBottom(16), ), + if (ComicSource.availableUpdates.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.update, color: context.colorScheme.primary, size: 20,), + const SizedBox(width: 8), + Text("@c updates".tlParams({ + 'c': ComicSource.availableUpdates.length, + }), style: ts.withColor(context.colorScheme.primary),), + ], + ), + ).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8), ], ), ), @@ -911,3 +895,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..51768e6 --- /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, 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/main_page.dart b/lib/pages/main_page.dart index fdd4e7b..aa95c35 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -37,9 +37,6 @@ class _MainPageState extends State { } void checkUpdates() async { - if (!appdata.settings['checkUpdateOnStart']) { - return; - } var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; var now = DateTime.now().millisecondsSinceEpoch; if (now - lastCheck < 24 * 60 * 60 * 1000) { @@ -47,9 +44,11 @@ class _MainPageState extends State { } appdata.implicitData['lastCheckUpdate'] = now; appdata.writeImplicitData(); - await Future.delayed(const Duration(milliseconds: 300)); - await checkUpdateUi(false); - await ComicSourcePage.checkComicSourceUpdate(true); + ComicSourcePage.checkComicSourceUpdate(); + if (appdata.settings['checkUpdateOnStart']) { + await Future.delayed(const Duration(milliseconds: 300)); + await checkUpdateUi(false); + } } @override 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..11ef91a 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()) { @@ -665,6 +673,7 @@ ImageProvider _createImageProviderFromKey( reader.type.comicSource?.key, reader.cid, reader.eid, + reader.page, ); } 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 d3221cc..c897f70 100644 --- a/lib/pages/reader/reader.dart +++ b/lib/pages/reader/reader.dart @@ -20,6 +20,8 @@ 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/favorites.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/local.dart'; @@ -27,8 +29,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 +61,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 +124,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; @@ -138,16 +150,25 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { void initState() { page = widget.initialPage ?? 1; chapter = widget.initialChapter ?? 1; + if (page < 1) { + page = 1; + } + if (chapter < 1) { + chapter = 1; + } mode = ReaderMode.fromKey(appdata.settings['readerMode']); history = widget.history; Future.microtask(() { updateHistory(); }); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); - if(appdata.settings['enableTurnPageByVolumeKey']) { + if (appdata.settings['enableTurnPageByVolumeKey']) { handleVolumeEvent(); } setImageCacheSize(); + Future.delayed(const Duration(milliseconds: 200), () { + LocalFavoritesManager().onRead(cid, type); + }); super.initState(); } @@ -164,7 +185,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; } @@ -209,7 +231,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) { @@ -222,11 +244,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( @@ -240,7 +262,7 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { } void stopVolumeEvent() { - if(volumeListener != null) { + if (volumeListener != null) { volumeListener?.cancel(); volumeListener = null; } @@ -300,7 +322,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..00453d3 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, @@ -478,6 +650,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { reader.type.comicSource!.key, reader.cid, reader.eid, + reader.page, ); } return InkWell( @@ -559,7 +732,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 +740,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { context.reader.stopVolumeEvent(); } } + if (key == "quickCollectImage") { + addDragListener(); + } context.reader.update(); }, ), diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index bcaeb5b..6d59223 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; @@ -184,7 +185,7 @@ class _SearchPageState extends State { duration: const Duration(milliseconds: 200), child: buildSearchOptions(), ); - yield buildSearchHistory(); + yield _SearchHistory(search); } } @@ -228,6 +229,11 @@ class _SearchPageState extends State { onChanged: (value) { setState(() { aggregatedSearch = value ?? false; + if (!aggregatedSearch && + appdata.settings['defaultSearchTarget'] == + "_aggregated_") { + searchTarget = sources.first.key; + } }); }, ), @@ -286,78 +292,6 @@ class _SearchPageState extends State { ); } - Widget buildSearchHistory() { - return SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index == 0) { - return const SizedBox( - height: 16, - ); - } - if (index == 1) { - return ListTile( - leading: const Icon(Icons.history), - contentPadding: EdgeInsets.zero, - title: Text("Search History".tl), - trailing: Flyout( - flyoutBuilder: (context) { - return FlyoutContent( - title: "Clear Search History".tl, - actions: [ - FilledButton( - child: Text("Clear".tl), - onPressed: () { - appdata.clearSearchHistory(); - context.pop(); - update(); - }, - ) - ], - ); - }, - child: Builder( - builder: (context) { - return Tooltip( - message: "Clear".tl, - child: IconButton( - icon: const Icon(Icons.clear_all), - onPressed: () { - context - .findAncestorStateOfType()! - .show(); - }, - ), - ); - }, - ), - ), - ); - } - return InkWell( - onTap: () { - search(appdata.searchHistory[index - 2]); - }, - child: Container( - decoration: BoxDecoration( - // color: context.colorScheme.surfaceContainer, - border: Border( - left: BorderSide( - color: context.colorScheme.outlineVariant, - width: 2, - ), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Text(appdata.searchHistory[index - 2], style: ts.s14), - ), - ).paddingBottom(8).paddingHorizontal(4); - }, - childCount: 2 + appdata.searchHistory.length, - ), - ).sliverPaddingHorizontal(16); - } - Widget buildSuggestions(BuildContext context) { bool check(String text, String key, String value) { if (text.removeAllBlank == "") { @@ -577,3 +511,130 @@ class SearchOptionWidget extends StatelessWidget { ); } } + +class _SearchHistory extends StatefulWidget { + const _SearchHistory(this.search); + + final void Function(String) search; + + @override + State<_SearchHistory> createState() => _SearchHistoryState(); +} + +class _SearchHistoryState extends State<_SearchHistory> { + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + return const SizedBox( + height: 16, + ); + } + if (index == 1) { + return ListTile( + leading: const Icon(Icons.history), + contentPadding: EdgeInsets.zero, + title: Text("Search History".tl), + trailing: Flyout( + flyoutBuilder: (context) { + return FlyoutContent( + title: "Clear Search History".tl, + actions: [ + FilledButton( + child: Text("Clear".tl), + onPressed: () { + appdata.clearSearchHistory(); + context.pop(); + setState(() {}); + }, + ) + ], + ); + }, + child: Builder( + builder: (context) { + return Tooltip( + message: "Clear".tl, + child: IconButton( + icon: const Icon(Icons.clear_all), + onPressed: () { + context + .findAncestorStateOfType()! + .show(); + }, + ), + ); + }, + ), + ), + ); + } + return buildItem(index - 2); + }, + childCount: 2 + appdata.searchHistory.length, + ), + ).sliverPaddingHorizontal(16); + } + + Widget buildItem(int index) { + void showMenu(Offset offset) { + showMenuX( + context, + offset, + [ + MenuEntry( + icon: Icons.copy, + text: 'Copy'.tl, + onClick: () { + Clipboard.setData( + ClipboardData(text: appdata.searchHistory[index])); + }, + ), + MenuEntry( + icon: Icons.delete, + text: 'Delete'.tl, + onClick: () { + appdata.removeSearchHistory(appdata.searchHistory[index]); + appdata.saveData(); + setState(() {}); + }, + ), + ], + ); + } + + return Builder(builder: (context) { + return InkWell( + onTap: () { + widget.search(appdata.searchHistory[index]); + }, + onLongPress: () { + var renderBox = context.findRenderObject() as RenderBox; + var offset = renderBox.localToGlobal(Offset.zero); + showMenu(Offset( + offset.dx + renderBox.size.width / 2 - 121, + offset.dy + renderBox.size.height - 8, + )); + }, + onSecondaryTapUp: (details) { + showMenu(details.globalPosition); + }, + child: Container( + decoration: BoxDecoration( + // color: context.colorScheme.surfaceContainer, + border: Border( + left: BorderSide( + color: context.colorScheme.outlineVariant, + width: 2, + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text(appdata.searchHistory[index], style: ts.s14), + ), + ).paddingBottom(8).paddingHorizontal(4); + }); + } +} 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/network.dart b/lib/pages/settings/network.dart index ea86985..9f80afd 100644 --- a/lib/pages/settings/network.dart +++ b/lib/pages/settings/network.dart @@ -250,13 +250,16 @@ class _DNSOverrides extends StatefulWidget { } class __DNSOverridesState extends State<_DNSOverrides> { - var overrides = <(String, String)>[]; + var overrides = <(TextEditingController, TextEditingController)>[]; @override void initState() { for (var entry in (appdata.settings['dnsOverrides'] as Map).entries) { if (entry.key is String && entry.value is String) { - overrides.add((entry.key, entry.value)); + overrides.add(( + TextEditingController(text: entry.key), + TextEditingController(text: entry.value) + )); } } super.initState(); @@ -266,7 +269,7 @@ class __DNSOverridesState extends State<_DNSOverrides> { void dispose() { var map = {}; for (var entry in overrides) { - map[entry.$1] = entry.$2; + map[entry.$1.text] = entry.$2.text; } appdata.settings['dnsOverrides'] = map; appdata.saveData(); @@ -300,7 +303,8 @@ class __DNSOverridesState extends State<_DNSOverrides> { TextButton.icon( onPressed: () { setState(() { - overrides.add(('', '')); + overrides + .add((TextEditingController(), TextEditingController())); }); }, icon: const Icon(Icons.add), @@ -315,6 +319,7 @@ class __DNSOverridesState extends State<_DNSOverrides> { Widget buildOverride(int index) { var entry = overrides[index]; return Container( + key: ValueKey(index), height: 48, margin: EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( @@ -338,10 +343,7 @@ class __DNSOverridesState extends State<_DNSOverrides> { border: InputBorder.none, hintText: "Domain".tl, ), - controller: TextEditingController(text: entry.$1), - onChanged: (v) { - overrides[index] = (v, entry.$2); - }, + controller: entry.$1, ).paddingHorizontal(8), ), Container( @@ -354,10 +356,7 @@ class __DNSOverridesState extends State<_DNSOverrides> { border: InputBorder.none, hintText: "IP".tl, ), - controller: TextEditingController(text: entry.$2), - onChanged: (v) { - overrides[index] = (entry.$1, v); - }, + controller: entry.$2, ).paddingHorizontal(8), ), Container( diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index 1c6f05d..79f3fa8 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -116,9 +116,25 @@ class _ReaderSettingsState extends State { widget.onChanged?.call("enableClockAndBatteryInfoInReader"); }, ).toSliver(), - _PopupWindowSetting( + 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(), + _CallbackSetting( title: "Custom Image Processing".tl, - builder: () => _CustomImageProcessing(), + callback: () => context.to(() => _CustomImageProcessing()), + actionTitle: "Edit".tl, ).toSliver(), ], ); @@ -148,10 +164,25 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> { super.dispose(); } + int resetKey = 0; + @override Widget build(BuildContext context) { - return PopUpWidgetScaffold( - title: "Custom Image Processing".tl, + return Scaffold( + appBar: Appbar( + title: Text("Custom Image Processing".tl), + actions: [ + TextButton( + onPressed: () { + current = defaultCustomImageProcessing; + appdata.settings['customImageProcessing'] = current; + resetKey++; + setState(() {}); + }, + child: Text("Reset".tl), + ) + ], + ), body: Column( children: [ _SwitchSetting( @@ -167,6 +198,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> { ), child: SizedBox.expand( child: CodeEditor( + key: ValueKey(resetKey), initialValue: appdata.settings['customImageProcessing'], onChanged: (value) { current = value; diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart index 7001da9..16eee6a 100644 --- a/lib/utils/cbz.dart +++ b/lib/utils/cbz.dart @@ -1,9 +1,10 @@ import 'dart:convert'; - +import 'package:flutter_7zip/flutter_7zip.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/utils/ext.dart'; +import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/io.dart'; import 'package:zip_flutter/zip_flutter.dart'; @@ -57,12 +58,33 @@ class ComicChapter { ComicChapter({required this.title, required this.start, required this.end}); } +/// Comic Book Archive. Currently supports CBZ, ZIP and 7Z formats. abstract class CBZ { + static Future checkType(File file) async { + var header = []; + await for (var bytes in file.openRead()) { + header.addAll(bytes); + if (header.length >= 32) break; + } + return detectFileType(header); + } + + static Future extractArchive(File file, Directory out) async { + var fileType = await checkType(file); + if (fileType.mime == 'application/zip') { + await ZipFile.openAndExtractAsync(file.path, out.path, 4); + } else if (fileType.mime == "application/x-7z-compressed") { + await SZArchive.extractIsolates(file.path, out.path, 4); + } else { + throw Exception('Unsupported archive type'); + } + } + static Future import(File file) async { var cache = Directory(FilePath.join(App.cachePath, 'cbz_import')); if (cache.existsSync()) cache.deleteSync(recursive: true); cache.createSync(); - await ZipFile.openAndExtractAsync(file.path, cache.path, 4); + await extractArchive(file, cache); var metaDataFile = File(FilePath.join(cache.path, 'metadata.json')); ComicMetaData? metaData; if (metaDataFile.existsSync()) { @@ -72,7 +94,7 @@ abstract class CBZ { } catch (_) {} } metaData ??= ComicMetaData( - title: file.name.replaceLast('.cbz', ''), + title: file.name.substring(0, file.name.lastIndexOf('.')), author: "", tags: [], ); @@ -86,6 +108,7 @@ abstract class CBZ { return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); }); if(files.isEmpty) { + cache.deleteSync(recursive: true); throw Exception('No images found in the archive'); } files.sort((a, b) => a.path.compareTo(b.path)); 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/file_type.dart b/lib/utils/file_type.dart index 86538e1..9356be0 100644 --- a/lib/utils/file_type.dart +++ b/lib/utils/file_type.dart @@ -21,8 +21,17 @@ class FileType { } } +final _resolver = MimeTypeResolver() + // zip + ..addMagicNumber([0x50, 0x4B], 'application/zip') + // 7z + ..addMagicNumber([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed') + // rar + ..addMagicNumber([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar') +; + FileType detectFileType(List data) { - var mime = lookupMimeType('no-file', headerBytes: data); + var mime = _resolver.lookup('no-file', headerBytes: data); var ext = mime == null ? '' : extensionFromMime(mime); if(ext == 'jpe') { ext = 'jpg'; diff --git a/lib/utils/import_comic.dart b/lib/utils/import_comic.dart index 6904fcc..7473c06 100644 --- a/lib/utils/import_comic.dart +++ b/lib/utils/import_comic.dart @@ -20,7 +20,7 @@ class ImportComic { const ImportComic({this.selectedFolder, this.copyToLocal = true}); Future cbz() async { - var file = await selectFile(ext: ['cbz', 'zip']); + var file = await selectFile(ext: ['cbz', 'zip', '7z', 'cb7']); Map> imported = {}; if (file == null) { return false; @@ -42,7 +42,8 @@ class ImportComic { var dir = await picker.pickDirectory(directAccess: true); if (dir != null) { var files = (await dir.list().toList()).whereType().toList(); - files.removeWhere((e) => e.extension != 'cbz' && e.extension != 'zip'); + const supportedExtensions = ['cbz', 'zip', '7z', 'cb7']; + files.removeWhere((e) => !supportedExtensions.contains(e.extension)); Map> imported = {}; var controller = showLoadingDialog(App.rootContext, allowCancel: false); var comics = []; 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..8270f48 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -303,6 +303,15 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_7zip: + dependency: "direct main" + description: + path: "." + ref: b33344797f1d2469339e0e1b75f5f954f1da224c + resolved-ref: b33344797f1d2469339e0e1b75f5f954f1da224c + url: "https://github.com/wgh136/flutter_7zip" + source: git + version: "0.0.1" flutter_file_dialog: dependency: "direct main" description: @@ -867,14 +876,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" - shimmer: + shimmer_animation: dependency: "direct main" description: - name: shimmer - sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + name: shimmer_animation + sha256: "9357080b7dd892aae837d569e1fbbcbe7f9a02ca994e558561d90e35e92f1101" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "2.2.2" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 3c67428..8c25efe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: venera description: "A comic app." publish_to: 'none' -version: 1.1.4+114 +version: 1.2.0+120 environment: sdk: '>=3.6.0 <4.0.0' @@ -69,10 +69,14 @@ dependencies: ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e pdf: ^3.11.1 dynamic_color: ^1.7.0 - shimmer: ^3.0.0 + shimmer_animation: ^2.1.0 flutter_memory_info: ^0.0.1 syntax_highlight: ^0.4.0 text_scroll: ^0.2.0 + flutter_7zip: + git: + url: https://github.com/wgh136/flutter_7zip + ref: b33344797f1d2469339e0e1b75f5f954f1da224c dev_dependencies: flutter_test: diff --git a/windows/build.iss b/windows/build.iss index db9577d..15cc4d6 100644 --- a/windows/build.iss +++ b/windows/build.iss @@ -59,6 +59,7 @@ Source: "{#RootPath}\build\windows\x64\runner\Release\zip_flutter.dll"; DestDir: Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\dynamic_color_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#RootPath}\build\windows\x64\runner\Release\flutter_7zip.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files