Feat: Image favorites (#126)

* feat: 增加图片收藏

* feat: 主体图片收藏页面实现

* feat: 点击打开大图浏览

* feat: 数据结构变更

* feat: 基本完成

* feat: 翻译与bug修复

* feat: 实机测试和问题修复

* feat: jm导入, pica历史记录nhentai有问题, 一键反转

* fix: 大小写不一致, 一个htManga, 一个htmanga

* feat: 拉取收藏优化

* feat: 改成以ep为准

* feat: 兜底一些可能报错场景

* chore: 没有用到

* feat: 尽量保证和网络收藏顺序一致

* feat: 支持显示热点tag

* feat: 支持双击收藏, 不过此时禁止放大图片

* fix: 自动塞封面逻辑完善, 切换快速收藏图片立刻生效

* Refactor

* fix updateValue

* feat: 双击功能提示

* fix: 被确定取消收藏的才删除

* Refactor ImageFavoritesPage

* translate author

* feat: 功能提示改到dialog中

* fix text editing

* fix text editing

* feat: 功能提示放到邮件或长按菜单中

* fix: 修复tag过滤不生效问题

* Improve image loading

* The default value of quickCollectImage should be false.

* Refactor DragListener

* Refactor ImageFavoriteItem & ImageFavoritePhotoView

* Refactor

* Fix `ImageFavoriteManager.has`

* Fix UI

* Improve UI

---------

Co-authored-by: nyne <me@nyne.dev>
This commit is contained in:
luckyray
2025-01-15 16:07:08 +08:00
committed by GitHub
parent 213c225e1e
commit d874920c88
42 changed files with 3054 additions and 226 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ migrate_working_dir/
*.ipr *.ipr
*.iws *.iws
.idea/ .idea/
.vscode/
# The .vscode folder contains launch configuration and tasks you configure in # 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 # VS Code which you may wish to be included in version control, so this line

View File

@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
android.nonFinalResIds=false android.nonFinalResIds=false

View File

@@ -18,7 +18,7 @@
"help": "帮助", "help": "帮助",
"Select": "选择", "Select": "选择",
"Selected @a comics": "已选择 @a 部漫画", "Selected @a comics": "已选择 @a 部漫画",
"Imported @a comics": "已导入 @a 部漫画", "Imported @a comics, loaded @b pages, received @c comics": "已导入 @a 部漫画, 加载 @b 页, 接收到 @c 部漫画",
"Downloading": "下载中", "Downloading": "下载中",
"Back": "后退", "Back": "后退",
"Delete": "删除", "Delete": "删除",
@@ -41,6 +41,7 @@
"Select a folder": "选择一个文件夹", "Select a folder": "选择一个文件夹",
"Folder": "文件夹", "Folder": "文件夹",
"Confirm": "确认", "Confirm": "确认",
"Reversed successfully": "反转成功",
"Remove comic from favorite?": "从收藏中移除漫画?", "Remove comic from favorite?": "从收藏中移除漫画?",
"Move": "移动", "Move": "移动",
"Move to folder": "移动到文件夹", "Move to folder": "移动到文件夹",
@@ -164,7 +165,7 @@
"Date Desc": "日期降序", "Date Desc": "日期降序",
"Start": "开始", "Start": "开始",
"Export App Data": "导出应用数据", "Export App Data": "导出应用数据",
"Import App Data": "导入应用数据", "Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)",
"Export": "导出", "Export": "导出",
"Download Threads": "下载线程数", "Download Threads": "下载线程数",
"Update Time": "更新时间", "Update Time": "更新时间",
@@ -248,6 +249,47 @@
"Export as pdf": "导出为pdf", "Export as pdf": "导出为pdf",
"Export as epub": "导出为epub", "Export as epub": "导出为epub",
"Aggregated Search": "聚合搜索", "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": "未找到搜索结果", "No search results found": "未找到搜索结果",
"Added @c comics to download queue." : "已添加 @c 本漫画到下载队列", "Added @c comics to download queue." : "已添加 @c 本漫画到下载队列",
"Download started": "下载已开始", "Download started": "下载已开始",
@@ -265,7 +307,10 @@
"Aggregated": "聚合", "Aggregated": "聚合",
"Default Search Target": "默认搜索目标", "Default Search Target": "默认搜索目标",
"Auto Language Filters": "自动语言筛选", "Auto Language Filters": "自动语言筛选",
"Check for updates on startup": "启动时检查更新" "Check for updates on startup": "启动时检查更新",
"Start Time": "开始时间",
"End Time": "结束时间",
"Custom": "自定义"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -287,7 +332,7 @@
"help": "幫助", "help": "幫助",
"Select": "選擇", "Select": "選擇",
"Selected @a comics": "已選擇 @a 部漫畫", "Selected @a comics": "已選擇 @a 部漫畫",
"Imported @a comics": "已匯入 @a 部漫畫", "Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 加載 @b 頁, 接收到 @c 部漫畫",
"Downloading": "下載中", "Downloading": "下載中",
"Back": "後退", "Back": "後退",
"Delete": "刪除", "Delete": "刪除",
@@ -431,8 +476,9 @@
"Date": "日期", "Date": "日期",
"Date Desc": "日期降序", "Date Desc": "日期降序",
"Start": "開始", "Start": "開始",
"Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據", "Export App Data": "匯出應用數據",
"Import App Data": "匯入應用數據", "Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)",
"Export": "匯出", "Export": "匯出",
"Download Threads": "下載線程數", "Download Threads": "下載線程數",
"Update Time": "更新時間", "Update Time": "更新時間",
@@ -520,6 +566,47 @@
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載隊列", "Added @c comics to download queue." : "已添加 @c 本漫畫到下載隊列",
"Download started": "下載已開始", "Download started": "下載已開始",
"Click favorite": "點擊收藏", "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": "末尾", "End": "末尾",
"None": "無", "None": "無",
"View Detail": "查看詳情", "View Detail": "查看詳情",
@@ -533,6 +620,9 @@
"Aggregated": "聚合", "Aggregated": "聚合",
"Default Search Target": "默認搜索目標", "Default Search Target": "默認搜索目標",
"Auto Language Filters": "自動語言篩選", "Auto Language Filters": "自動語言篩選",
"Check for updates on startup": "啟動時檢查更新" "Check for updates on startup": "啟動時檢查更新",
"Start Time": "開始時間",
"End Time": "結束時間",
"Custom": "自定義"
} }
} }

View File

@@ -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<Widget> children;
final TabController? controller;
@override
State<TabViewBody> createState() => _TabViewBodyState();
}
class _TabViewBodyState extends State<TabViewBody> {
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 { class SearchBarController {
_SearchBarMixin? _state; _SearchBarMixin? _state;

View File

@@ -726,9 +726,16 @@ class _SliverGridComicsState extends State<SliverGridComics> {
comics.add(comic); comics.add(comic);
} }
} }
HistoryManager().addListener(update);
super.initState(); super.initState();
} }
@override
void dispose() {
HistoryManager().removeListener(update);
super.dispose();
}
void update() { void update() {
setState(() { setState(() {
comics.clear(); comics.clear();

View File

@@ -22,6 +22,7 @@ class AnimatedImage extends StatefulWidget {
this.filterQuality = FilterQuality.medium, this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false, this.isAntiAlias = false,
this.part, this.part,
this.onError,
Map<String, String>? headers, Map<String, String>? headers,
int? cacheWidth, int? cacheWidth,
int? cacheHeight, int? cacheHeight,
@@ -63,6 +64,8 @@ class AnimatedImage extends StatefulWidget {
final ImagePart? part; final ImagePart? part;
final Function? onError;
static void clear() => _AnimatedImageState.clear(); static void clear() => _AnimatedImageState.clear();
@override @override
@@ -169,6 +172,8 @@ class _AnimatedImageState extends State<AnimatedImage>
_handleImageFrame, _handleImageFrame,
onChunk: _handleImageChunk, onChunk: _handleImageChunk,
onError: (Object error, StackTrace? stackTrace) { onError: (Object error, StackTrace? stackTrace) {
// 图片加错错误回调
widget.onError?.call(error, stackTrace);
setState(() { setState(() {
_lastException = error; _lastException = error;
}); });
@@ -271,7 +276,7 @@ class _AnimatedImageState extends State<AnimatedImage>
Widget result; Widget result;
if (_imageInfo != null) { if (_imageInfo != null) {
if(widget.part != null) { if (widget.part != null) {
return CustomPaint( return CustomPaint(
painter: ImagePainter( painter: ImagePainter(
image: _imageInfo!.image, image: _imageInfo!.image,

View File

@@ -5,6 +5,7 @@ void showToast({
required BuildContext context, required BuildContext context,
Widget? icon, Widget? icon,
Widget? trailing, Widget? trailing,
int? seconds,
}) { }) {
var newEntry = OverlayEntry( var newEntry = OverlayEntry(
builder: (context) => _ToastOverlay( builder: (context) => _ToastOverlay(
@@ -17,7 +18,7 @@ void showToast({
state?.addOverlay(newEntry); state?.addOverlay(newEntry);
Timer(const Duration(seconds: 2), () => state?.remove(newEntry)); Timer(Duration(seconds: seconds ?? 2), () => state?.remove(newEntry));
} }
class _ToastOverlay extends StatelessWidget { class _ToastOverlay extends StatelessWidget {
@@ -48,7 +49,8 @@ class _ToastOverlay extends StatelessWidget {
color: Theme.of(context).colorScheme.onInverseSurface), color: Theme.of(context).colorScheme.onInverseSurface),
child: IntrinsicWidth( child: IntrinsicWidth(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: context.width - 32, maxWidth: context.width - 32,
), ),
@@ -241,13 +243,13 @@ LoadingDialogController showLoadingDialog(BuildContext context,
class ContentDialog extends StatelessWidget { class ContentDialog extends StatelessWidget {
const ContentDialog({ const ContentDialog({
super.key, super.key,
required this.title, this.title, // 如果不传 title 将不会展示
required this.content, required this.content,
this.dismissible = true, this.dismissible = true,
this.actions = const [], this.actions = const [],
}); });
final String title; final String? title;
final Widget content; final Widget content;
@@ -261,14 +263,16 @@ class ContentDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Appbar( title != null
leading: IconButton( ? Appbar(
icon: const Icon(Icons.close), leading: IconButton(
onPressed: dismissible ? context.pop : null, icon: const Icon(Icons.close),
), onPressed: dismissible ? context.pop : null,
title: Text(title), ),
backgroundColor: Colors.transparent, title: Text(title!),
), backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content, this.content,
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -360,7 +364,7 @@ Future<void> showInputDialog({
} else { } else {
result = futureOr; result = futureOr;
} }
if(result == null) { if (result == null) {
context.pop(); context.pop();
} else { } else {
setState(() => error = result.toString()); setState(() => error = result.toString());

View File

@@ -102,13 +102,36 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
duration: _fastAnimationDuration, curve: Curves.linear); duration: _fastAnimationDuration, curve: Curves.linear);
} }
}, },
child: widget.builder( child: ScrollControllerProvider._(
context, controller: _controller,
_controller, child: widget.builder(
_isMouseScroll context,
? const NeverScrollableScrollPhysics() _controller,
: const BouncingScrollPhysics(), _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<ScrollControllerProvider>();
return provider!.controller;
}
@override
bool updateShouldNotify(ScrollControllerProvider oldWidget) {
return oldWidget.controller != controller;
}
}

View File

@@ -143,6 +143,7 @@ class _Settings with ChangeNotifier {
'quickFavorite': null, 'quickFavorite': null,
'enableTurnPageByVolumeKey': true, 'enableTurnPageByVolumeKey': true,
'enableClockAndBatteryInfoInReader': true, 'enableClockAndBatteryInfoInReader': true,
'quickCollectImage': 'No', // No, DoubleTap, Swipe
'authorizationRequired': false, 'authorizationRequired': false,
'onClickFavorite': 'viewDetail', // viewDetail, read 'onClickFavorite': 'viewDetail', // viewDetail, read
'enableDnsOverrides': false, 'enableDnsOverrides': false,
@@ -179,4 +180,4 @@ const _defaultCustomImageProcessing = '''
async function processImage(image, cid, eid) { async function processImage(image, cid, eid) {
return image; return image;
} }
'''; ''';

View File

@@ -10,6 +10,10 @@ class FavoriteData {
final bool multiFolder; final bool multiFolder;
// 这个收藏时间新旧顺序, 是为了最小成本同步远端的收藏, 只拉取远程最新收藏的漫画, 就不需要全拉取一遍了
// 如果为 null, 当做从新到旧
final bool? isOldToNewSort;
final Future<Res<List<Comic>>> Function(int page, [String? folder])? final Future<Res<List<Comic>>> Function(int page, [String? folder])?
loadComic; loadComic;
@@ -44,6 +48,7 @@ class FavoriteData {
this.addFolder, this.addFolder,
this.allFavoritesId, this.allFavoritesId,
this.addOrDelFavorite, this.addOrDelFavorite,
this.isOldToNewSort,
}); });
} }

View File

@@ -73,7 +73,8 @@ class Comic {
this.sourceKey, this.sourceKey,
this.maxPage, this.maxPage,
this.language, this.language,
): favoriteId = null, stars = null; ) : favoriteId = null,
stars = null;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
@@ -231,6 +232,34 @@ class ComicDetails with HistoryMixin {
String get id => comicId; String get id => comicId;
ComicType get comicType => ComicType(sourceKey.hashCode); ComicType get comicType => ComicType(sourceKey.hashCode);
/// Convert tags map to plain list
List<String> get plainTags {
var res = <String>[];
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 { class ArchiveInfo {
@@ -242,4 +271,4 @@ class ArchiveInfo {
: title = json["title"], : title = json["title"],
description = json["description"], description = json["description"],
id = json["id"]; id = json["id"];
} }

View File

@@ -193,7 +193,7 @@ class ComicSourceParser {
login = (account, pwd) async { login = (account, pwd) async {
try { try {
await JsEngine().runCode(""" await JsEngine().runCode("""
ComicSource.sources.$_key.account.login(${jsonEncode(account)}, ComicSource.sources.$_key.account.login(${jsonEncode(account)},
${jsonEncode(pwd)}) ${jsonEncode(pwd)})
"""); """);
var source = ComicSource.find(_key!)!; var source = ComicSource.find(_key!)!;
@@ -502,9 +502,9 @@ class ComicSourceParser {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load( ComicSource.sources.$_key.categoryComics.load(
${jsonEncode(category)}, ${jsonEncode(category)},
${jsonEncode(param)}, ${jsonEncode(param)},
${jsonEncode(options)}, ${jsonEncode(options)},
${jsonEncode(page)} ${jsonEncode(page)}
) )
"""); """);
@@ -618,6 +618,7 @@ class ComicSourceParser {
if (!_checkExists("favorites")) return null; if (!_checkExists("favorites")) return null;
final bool multiFolder = _getValue("favorites.multiFolder"); final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async { Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) { if (!ComicSource.find(_key!)!.isLogged) {
@@ -770,6 +771,7 @@ class ComicSourceParser {
addFolder: addFolder, addFolder: addFolder,
deleteFolder: deleteFolder, deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc, addOrDelFavorite: addOrDelFavFunc,
isOldToNewSort: isOldToNewSort,
); );
} }

View File

@@ -1,6 +1,17 @@
/// If window width is less than this value, it is considered as mobile.
const changePoint = 600; 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; const changePoint2 = 1300;
/// Default user agent for http requests.
const webUA = 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"; "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;

View File

@@ -36,6 +36,8 @@ extension Navigation on BuildContext {
Brightness get brightness => Theme.of(this).brightness; Brightness get brightness => Theme.of(this).brightness;
bool get isDarkMode => brightness == Brightness.dark;
void showMessage({required String message}) { void showMessage({required String message}) {
showToast(message: message, context: this); showToast(message: message, context: this);
} }

View File

@@ -1,12 +1,23 @@
import 'dart:async'; 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:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.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 'package:venera/utils/translations.dart';
import 'app.dart'; import 'app.dart';
import 'consts.dart';
part "image_favorites.dart";
typedef HistoryType = ComicType; typedef HistoryType = ComicType;
@@ -37,7 +48,7 @@ class History implements Comic {
@override @override
String cover; String cover;
int ep; int ep;
int page; int page;
@@ -201,7 +212,12 @@ class HistoryManager with ChangeNotifier {
Map<String, bool>? _cachedHistory; Map<String, bool>? _cachedHistory;
bool isInitialized = false;
Future<void> init() async { Future<void> init() async {
if (isInitialized) {
return;
}
_db = sqlite3.open("${App.dataPath}/history.db"); _db = sqlite3.open("${App.dataPath}/history.db");
_db.execute(""" _db.execute("""
@@ -220,6 +236,8 @@ class HistoryManager with ChangeNotifier {
"""); """);
notifyListeners(); notifyListeners();
ImageFavoriteManager().init();
isInitialized = true;
} }
/// add history. if exists, update time. /// add history. if exists, update time.
@@ -275,7 +293,7 @@ class HistoryManager with ChangeNotifier {
} }
History? findSync(String id, ComicType type) { History? findSync(String id, ComicType type) {
if(_cachedHistory == null) { if (_cachedHistory == null) {
updateCache(); updateCache();
} }
if (!_cachedHistory!.containsKey(id)) { if (!_cachedHistory!.containsKey(id)) {

View File

@@ -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<String, dynamic> toJson() {
return {
'page': page,
'imageKey': imageKey,
'isAutoFavorite': isAutoFavorite,
'eid': eid,
'id': id,
'ep': ep,
'sourceKey': sourceKey,
'epName': epName,
};
}
ImageFavorite.fromJson(Map<String, dynamic> 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<ImageFavorite> 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<String, dynamic> 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<String> tags;
List<String> translatedTags;
final DateTime time;
List<ImageFavoritesEp> imageFavoritesEp;
final Map<String, dynamic> 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<ImageFavorite> 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<ImageFavoritesEp> finalImageFavoritesEp = [];
tempImageFavoritesEp.forEach((i) {
List<ImageFavorite> 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<ImageFavoritesComic> 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<ImageFavoritesEp> 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<dynamic> finalImageFavoritesEp =
jsonDecode(jsonEncode(tempImageFavoritesEp));
for (var e in tempImageFavoritesEp) {
List<Map> 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<ImageFavoritesComic> 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<ImageFavorite> imageFavoriteList) {
if (imageFavoriteList.isEmpty) {
return;
}
for (var i in imageFavoriteList) {
ImageFavoritesProvider.deleteFromCache(i);
}
var comics = <ImageFavoritesComic>{};
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<ImageFavoritesComic> search(String keyword) {
if (keyword == "") {
return [];
}
return getAll(keyword);
}
static Future<ImageFavoritesComputed> 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<String> exceptTags = [
'連載中',
'',
'translated',
'chinese',
'sole male',
'sole female',
'original',
'doujinshi',
'manga',
'multi-work series',
'mosaic censorship',
'dilf',
'bbm',
'uncensored',
'full censorship'
];
Map<String, int> tagCount = {};
Map<String, int> authorCount = {};
Map<ImageFavoritesComic, int> comicImageCount = {};
Map<ImageFavoritesComic, int> 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<String> sortedTags = tagCount.keys.toList()
..sort((a, b) => tagCount[b]!.compareTo(tagCount[a]!));
// 按数量排序作者
List<String> sortedAuthors = authorCount.keys.toList()
..sort((a, b) => authorCount[b]!.compareTo(authorCount[a]!));
// 按收藏数量排序漫画
List<MapEntry<ImageFavoritesComic, int>> 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<TextWithCount> tags;
/// 基于收藏的作者数排序
final List<TextWithCount> authors;
/// 基于喜欢的图片数排序
final List<TextWithCount> comics;
/// 计算后的图片收藏数据
const ImageFavoritesComputed(
this.tags,
this.authors,
this.comics,
);
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;
}

View File

@@ -6,6 +6,7 @@ import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/log.dart';
abstract class BaseImageProvider<T extends BaseImageProvider<T>> abstract class BaseImageProvider<T extends BaseImageProvider<T>>
extends ImageProvider<T> { extends ImageProvider<T> {
@@ -126,10 +127,11 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
rethrow; rethrow;
} }
} catch (e) { } catch (e, s) {
scheduleMicrotask(() { scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key); PaintingBinding.instance.imageCache.evict(key);
}); });
Log.error("Image Loading", e, s);
rethrow; rethrow;
} finally { } finally {
chunkEvents.close(); chunkEvents.close();

View File

@@ -0,0 +1,146 @@
import 'dart:async' show Future, StreamController;
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import '../history.dart';
import 'base_image_provider.dart';
import 'image_favorites_provider.dart' as image_provider;
class ImageFavoritesProvider
extends BaseImageProvider<image_provider.ImageFavoritesProvider> {
/// 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<Uint8List> load(StreamController<ImageChunkEvent>? chunkEvents) async {
var imageKey = imageFavorite.imageKey;
var localImage = await getImageFromLocal();
if (localImage != null) {
return localImage;
}
var cacheImage = await readFromCache();
if (cacheImage != null) {
return cacheImage;
}
var gotImageKey = false;
if (imageKey == "") {
imageKey = await getImageKey();
gotImageKey = true;
}
Uint8List image;
try {
image = await getImageFromNetwork(imageKey, chunkEvents);
} catch (e) {
if (gotImageKey) {
rethrow;
} else {
imageKey = await getImageKey();
image = await getImageFromNetwork(imageKey, chunkEvents);
}
}
await writeToCache(image);
return image;
}
Future<void> 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<Uint8List?> 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<void> 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<Uint8List?> 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<Uint8List> getImageFromNetwork(
String imageKey, StreamController<ImageChunkEvent>? chunkEvents) async {
await for (var progress
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
if (chunkEvents != null) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,
));
}
if (progress.imageBytes != null) {
return progress.imageBytes!;
}
}
throw "Error: Empty response body.";
}
Future<String> 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<ImageFavoritesProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
String get key =>
"ImageFavorites ${imageFavorite.imageKey}@${imageFavorite.sourceKey}@${imageFavorite.id}@${imageFavorite.eid}";
}

View File

@@ -22,7 +22,7 @@ class LocalFavoriteImageProvider
static void delete(String id, int intKey) { static void delete(String id, int intKey) {
var fileName = (id + intKey.toString()).hashCode.toString(); var fileName = (id + intKey.toString()).hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName)); var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
if(file.existsSync()) { if (file.existsSync()) {
file.delete(); file.delete();
} }
} }
@@ -42,7 +42,7 @@ class LocalFavoriteImageProvider
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes, expectedTotalBytes: progress.totalBytes,
)); ));
if(progress.imageBytes != null) { if (progress.imageBytes != null) {
var data = progress.imageBytes!; var data = progress.imageBytes!;
await file.writeAsBytes(data); await file.writeAsBytes(data);
return data; return data;
@@ -52,7 +52,8 @@ class LocalFavoriteImageProvider
} }
@override @override
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFavoriteImageProvider> obtainKey(
ImageConfiguration configuration) {
return SynchronousFuture(this); return SynchronousFuture(this);
} }

View File

@@ -36,6 +36,8 @@ class LocalComic with HistoryMixin implements Comic {
/// chapter id is the name of the directory in `LocalManager.path/$directory` /// chapter id is the name of the directory in `LocalManager.path/$directory`
final Map<String, String>? chapters; final Map<String, String>? chapters;
bool get hasChapters => chapters != null;
/// relative path to the cover image /// relative path to the cover image
@override @override
final String cover; final String cover;
@@ -119,6 +121,8 @@ class LocalComic with HistoryMixin implements Comic {
ep: 0, ep: 0,
page: 0, page: 0,
), ),
author: subtitle,
tags: tags,
), ),
); );
} }
@@ -266,7 +270,7 @@ class LocalManager with ChangeNotifier {
String findValidId(ComicType type) { String findValidId(ComicType type) {
final res = _db.select( 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 ORDER BY CAST(id AS INTEGER) DESC
LIMIT 1; LIMIT 1;
''', ''',
@@ -318,8 +322,8 @@ class LocalManager with ChangeNotifier {
List<LocalComic> getComics(LocalSortType sortType) { List<LocalComic> getComics(LocalSortType sortType) {
var res = _db.select(''' var res = _db.select('''
SELECT * FROM comics SELECT * FROM comics
ORDER BY ORDER BY
${sortType.value == 'name' ? 'title' : 'created_at'} ${sortType.value == 'name' ? 'title' : 'created_at'}
${sortType.value == 'time_asc' ? 'ASC' : 'DESC'} ${sortType.value == 'time_asc' ? 'ASC' : 'DESC'}
; ;
'''); ''');
@@ -361,7 +365,7 @@ class LocalManager with ChangeNotifier {
LocalComic? findByName(String name) { LocalComic? findByName(String name) {
final res = _db.select(''' final res = _db.select('''
SELECT * FROM comics SELECT * FROM comics
WHERE title = ? OR directory = ?; WHERE title = ? OR directory = ?;
''', [name, name]); ''', [name, name]);
if (res.isEmpty) { if (res.isEmpty) {
@@ -385,7 +389,7 @@ class LocalManager with ChangeNotifier {
} }
var comic = find(id, type) ?? (throw "Comic Not Found"); var comic = find(id, type) ?? (throw "Comic Not Found");
var directory = Directory(comic.baseDir); var directory = Directory(comic.baseDir);
if (comic.chapters != null) { if (comic.hasChapters) {
var cid = var cid =
ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String); ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
directory = Directory(FilePath.join(directory.path, cid)); directory = Directory(FilePath.join(directory.path, cid));

View File

@@ -145,6 +145,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ep: 0, ep: 0,
page: 0, page: 0,
), ),
author: localComic.subTitle ?? '',
tags: localComic.tags,
); );
}); });
App.mainNavigatorKey!.currentContext!.pop(); App.mainNavigatorKey!.currentContext!.pop();
@@ -663,6 +665,8 @@ abstract mixin class _ComicPageActions {
initialChapter: ep, initialChapter: ep,
initialPage: page, initialPage: page,
history: History.fromModel(model: comic, ep: 0, page: 0), history: History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '',
tags: comic.plainTags,
), ),
); );
} }

View File

@@ -147,13 +147,13 @@ Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var newInfo = (await comicSource.loadComicInfo!(c.id)).data; var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[]; var newTags = <String>[];
for(var entry in newInfo.tags.entries) { for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time']; const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key; var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) { if (shouldIgnore.contains(namespace.toLowerCase())) {
continue; continue;
} }
for(var tag in entry.value) { for (var tag in entry.value) {
newTags.add("$namespace:$tag"); newTags.add("$namespace:$tag");
} }
} }
@@ -305,6 +305,7 @@ Future<void> sortFolders() async {
Future<void> importNetworkFolder( Future<void> importNetworkFolder(
String source, String source,
int updatePageNum,
String? folder, String? folder,
String? folderID, String? folderID,
) async { ) async {
@@ -312,7 +313,7 @@ Future<void> importNetworkFolder(
if (comicSource == null) { if (comicSource == null) {
return; return;
} }
if(folder != null && folder.isEmpty) { if (folder != null && folder.isEmpty) {
folder = null; folder = null;
} }
var resultName = folder ?? comicSource.name; var resultName = folder ?? comicSource.name;
@@ -324,7 +325,7 @@ Future<void> importNetworkFolder(
return; return;
} }
} }
if(!exists) { if (!exists) {
LocalFavoritesManager().createFolder(resultName); LocalFavoritesManager().createFolder(resultName);
LocalFavoritesManager().linkFolderToNetwork( LocalFavoritesManager().linkFolderToNetwork(
resultName, resultName,
@@ -332,37 +333,46 @@ Future<void> importNetworkFolder(
folderID ?? "", folderID ?? "",
); );
} }
bool isOldToNewSort = comicSource.favoriteData?.isOldToNewSort ?? false;
var current = 0; var current = 0;
int receivedComics = 0;
int requestCount = 0;
var isFinished = false; var isFinished = false;
int maxPage = 1;
List<FavoriteItem> comics = [];
String? next; String? next;
// 如果是从旧到新, 先取一下maxPage
if (isOldToNewSort) {
var res = await comicSource.favoriteData?.loadComic!(1, folderID);
maxPage = res?.subData ?? 1;
}
Future<void> fetchNext() async { Future<void> fetchNext() async {
var retry = 3; var retry = 3;
while (updatePageNum > requestCount && !isFinished) {
while (true) {
try { try {
if (comicSource.favoriteData?.loadComic != null) { 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 page = int.parse(next!);
var res = await comicSource.favoriteData!.loadComic!(page, folderID); var res = await comicSource.favoriteData!.loadComic!(page, folderID);
var count = 0; var count = 0;
receivedComics += res.data.length;
for (var c in res.data) { for (var c in res.data) {
var result = LocalFavoritesManager().addComic( if (!LocalFavoritesManager()
resultName, .comicExists(resultName, c.id, ComicType(source.hashCode))) {
FavoriteItem( count++;
comics.add(FavoriteItem(
id: c.id, id: c.id,
name: c.title, name: c.title,
coverPath: c.cover, coverPath: c.cover,
type: ComicType(source.hashCode), type: ComicType(source.hashCode),
author: c.subtitle ?? '', author: c.subtitle ?? '',
tags: c.tags ?? [], tags: c.tags ?? [],
), ));
);
if (result) {
count++;
} }
} }
requestCount++;
current += count; current += count;
if (res.data.isEmpty || res.subData == page) { if (res.data.isEmpty || res.subData == page) {
isFinished = true; isFinished = true;
@@ -373,22 +383,22 @@ Future<void> importNetworkFolder(
} else if (comicSource.favoriteData?.loadNext != null) { } else if (comicSource.favoriteData?.loadNext != null) {
var res = await comicSource.favoriteData!.loadNext!(next, folderID); var res = await comicSource.favoriteData!.loadNext!(next, folderID);
var count = 0; var count = 0;
receivedComics += res.data.length;
for (var c in res.data) { for (var c in res.data) {
var result = LocalFavoritesManager().addComic( if (!LocalFavoritesManager()
resultName, .comicExists(resultName, c.id, ComicType(source.hashCode))) {
FavoriteItem( count++;
comics.add(FavoriteItem(
id: c.id, id: c.id,
name: c.title, name: c.title,
coverPath: c.cover, coverPath: c.cover,
type: ComicType(source.hashCode), type: ComicType(source.hashCode),
author: c.subtitle ?? '', author: c.subtitle ?? '',
tags: c.tags ?? [], tags: c.tags ?? [],
), ));
);
if (result) {
count++;
} }
} }
requestCount++;
current += count; current += count;
if (res.data.isEmpty || res.subData == null) { if (res.data.isEmpty || res.subData == null) {
isFinished = true; isFinished = true;
@@ -408,6 +418,8 @@ Future<void> importNetworkFolder(
continue; continue;
} }
} }
// 跳出循环, 表示已经完成, 强制为 true, 避免死循环
isFinished = true;
} }
bool isCanceled = false; bool isCanceled = false;
@@ -415,6 +427,7 @@ Future<void> importNetworkFolder(
bool isErrored() => errorMsg != null; bool isErrored() => errorMsg != null;
void Function()? updateDialog; void Function()? updateDialog;
void Function()? closeDialog;
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
@@ -422,6 +435,7 @@ Future<void> importNetworkFolder(
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
updateDialog = () => setState(() {}); updateDialog = () => setState(() {});
closeDialog = () => Navigator.pop(context);
return ContentDialog( return ContentDialog(
title: isFinished title: isFinished
? "Finished".tl ? "Finished".tl
@@ -437,8 +451,11 @@ Future<void> importNetworkFolder(
value: isFinished ? 1 : null, value: isFinished ? 1 : null,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text("Imported @c comics".tlParams({ Text("Imported @a comics, loaded @b pages, received @c comics"
"c": current, .tlParams({
"a": current,
"b": requestCount,
"c": receivedComics,
})), })),
const SizedBox(height: 4), const SizedBox(height: 4),
if (isErrored()) Text("Error: $errorMsg"), if (isErrored()) Text("Error: $errorMsg"),
@@ -476,4 +493,18 @@ Future<void> importNetworkFolder(
break; 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);
}
} }

View File

@@ -11,6 +11,7 @@ import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
@@ -35,7 +36,7 @@ class FavoritesPage extends StatefulWidget {
State<FavoritesPage> createState() => _FavoritesPageState(); State<FavoritesPage> createState() => _FavoritesPageState();
} }
class _FavoritesPageState extends State<FavoritesPage> { class _FavoritesPageState extends State<FavoritesPage> {
String? folder; String? folder;
bool isNetwork = false; bool isNetwork = false;
@@ -58,7 +59,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
@override @override
void initState() { void initState() {
var data = appdata.implicitData['favoriteFolder']; var data = appdata.implicitData['favoriteFolder'];
if(data != null){ if (data != null) {
folder = data['name']; folder = data['name'];
isNetwork = data['isNetwork'] ?? false; isNetwork = data['isNetwork'] ?? false;
} }
@@ -101,7 +102,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Material( child: Material(
child: SizedBox( child: SizedBox(
width: min(300, context.width-16), width: min(300, context.width - 16),
child: _LeftBar( child: _LeftBar(
withAppbar: true, withAppbar: true,
favPage: this, favPage: this,
@@ -153,14 +154,16 @@ class _FavoritesPageState extends State<FavoritesPage> {
); );
} }
if (!isNetwork) { if (!isNetwork) {
return _LocalFavoritesPage(folder: folder!, key: PageStorageKey("local_$folder")); return _LocalFavoritesPage(
folder: folder!, key: PageStorageKey("local_$folder"));
} else { } else {
var favoriteData = getFavoriteDataOrNull(folder!); var favoriteData = getFavoriteDataOrNull(folder!);
if (favoriteData == null) { if (favoriteData == null) {
folder = null; folder = null;
return buildBody(); return buildBody();
} else { } else {
return NetworkFavoritePage(favoriteData, key: PageStorageKey("network_$folder")); return NetworkFavoritePage(favoriteData,
key: PageStorageKey("network_$folder"));
} }
} }
} }

View File

@@ -136,17 +136,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
message: "Sync".tl, message: "Sync".tl,
child: Flyout( child: Flyout(
flyoutBuilder: (context) { flyoutBuilder: (context) {
var sourceName = ComicSource.find(networkSource!)?.name ?? final GlobalKey<_SelectUpdatePageNumState>
networkSource!; selectUpdatePageNumKey =
var text = "The folder is Linked to @source".tlParams({ GlobalKey<_SelectUpdatePageNumState>();
"source": sourceName, var updatePageWidget = _SelectUpdatePageNum(
}); networkSource: networkSource!,
if (networkFolder != null && networkFolder!.isNotEmpty) { networkFolder: networkFolder,
text += "\n${"Source Folder".tl}: $networkFolder"; key: selectUpdatePageNumKey,
} );
return FlyoutContent( return FlyoutContent(
title: "Sync".tl, title: "Sync".tl,
content: Text(text), content: updatePageWidget,
actions: [ actions: [
Button.filled( Button.filled(
child: Text("Update".tl), child: Text("Update".tl),
@@ -154,6 +154,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
context.pop(); context.pop();
importNetworkFolder( importNetworkFolder(
networkSource!, networkSource!,
selectUpdatePageNumKey
.currentState!.updatePageNum,
widget.folder, widget.folder,
networkFolder!, networkFolder!,
).then( ).then(
@@ -741,6 +743,17 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
); );
}, },
), ),
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
});
},
),
], ],
), ),
body: ReorderableBuilder<FavoriteItem>( body: ReorderableBuilder<FavoriteItem>(
@@ -776,3 +789,76 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
); );
} }
} }
class _SelectUpdatePageNum extends StatefulWidget {
const _SelectUpdatePageNum({
required this.networkSource,
this.networkFolder,
super.key,
});
final String? networkFolder;
final String networkSource;
@override
State<_SelectUpdatePageNum> createState() => _SelectUpdatePageNumState();
}
class _SelectUpdatePageNumState extends State<_SelectUpdatePageNum> {
int updatePageNum = 9999999;
String get _allPageText => 'All'.tl;
List<String> 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();
});
},
)
],
),
],
);
}
}

View File

@@ -20,8 +20,7 @@ Future<bool> _deleteComic(
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: "Remove".tl, title: "Remove".tl,
content: Text("Remove comic from favorite?".tl) content: Text("Remove comic from favorite?".tl).paddingHorizontal(16),
.paddingHorizontal(16),
actions: [ actions: [
Button.filled( Button.filled(
isLoading: loading, isLoading: loading,
@@ -94,9 +93,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
return ComicList( return ComicList(
key: comicListKey, key: comicListKey,
leadingSliver: SliverAppbar( leadingSliver: SliverAppbar(
style: context.width < changePoint style:
? AppbarStyle.shadow context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
: AppbarStyle.blur,
leading: Tooltip( leading: Tooltip(
message: "Folders".tl, message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth child: context.width <= _kTwoPanelChangeWidth
@@ -117,7 +115,7 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
icon: Icons.sync, icon: Icons.sync,
text: "Convert to local".tl, text: "Convert to local".tl,
onClick: () { onClick: () {
importNetworkFolder(widget.data.key, null, null); importNetworkFolder(widget.data.key, 9999999, null, null);
}, },
) )
]), ]),
@@ -215,9 +213,8 @@ class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var sliverAppBar = SliverAppbar( var sliverAppBar = SliverAppbar(
style: context.width < changePoint style:
? AppbarStyle.shadow context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
: AppbarStyle.blur,
leading: Tooltip( leading: Tooltip(
message: "Folders".tl, message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth child: context.width <= _kTwoPanelChangeWidth
@@ -431,8 +428,7 @@ class _FolderTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: "Delete".tl, title: "Delete".tl,
content: Text("Delete folder?".tl) content: Text("Delete folder?".tl).paddingHorizontal(16),
.paddingHorizontal(16),
actions: [ actions: [
Button.filled( Button.filled(
isLoading: loading, isLoading: loading,
@@ -558,7 +554,7 @@ class _FavoriteFolder extends StatelessWidget {
icon: Icons.sync, icon: Icons.sync,
text: "Convert to local".tl, text: "Convert to local".tl,
onClick: () { onClick: () {
importNetworkFolder(data.key, title, folderID); importNetworkFolder(data.key, 9999999, title, folderID);
}, },
) )
]), ]),

View File

@@ -9,14 +9,17 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart'; import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart'; import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_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/pages/search_page.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/import_comic.dart'; import 'package:venera/utils/import_comic.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'local_comics_page.dart'; import 'local_comics_page.dart';
@@ -35,6 +38,7 @@ class HomePage extends StatelessWidget {
const _Local(), const _Local(),
const _ComicSourceWidget(), const _ComicSourceWidget(),
const _AccountsWidget(), const _AccountsWidget(),
const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
], ],
); );
@@ -83,7 +87,8 @@ class _SyncDataWidget extends StatefulWidget {
State<_SyncDataWidget> createState() => _SyncDataWidgetState(); State<_SyncDataWidget> createState() => _SyncDataWidgetState();
} }
class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver { class _SyncDataWidgetState extends State<_SyncDataWidget>
with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -93,7 +98,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
} }
void update() { void update() {
if(mounted) { if (mounted) {
setState(() {}); setState(() {});
} }
} }
@@ -110,8 +115,8 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
if(state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
if(DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) { if (DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) {
lastCheck = DateTime.now(); lastCheck = DateTime.now();
DataSync().downloadData(); DataSync().downloadData();
} }
@@ -121,7 +126,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child; Widget child;
if(!DataSync().isEnabled) { if (!DataSync().isEnabled) {
child = const SliverPadding(padding: EdgeInsets.zero); child = const SliverPadding(padding: EdgeInsets.zero);
} else if (DataSync().isUploading || DataSync().isDownloading) { } else if (DataSync().isUploading || DataSync().isDownloading) {
child = SliverToBoxAdapter( child = SliverToBoxAdapter(
@@ -159,17 +164,15 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.cloud_upload_outlined), icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async { onPressed: () async {
DataSync().uploadData(); DataSync().uploadData();
} }),
),
IconButton( IconButton(
icon: const Icon(Icons.cloud_download_outlined), icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async { onPressed: () async {
DataSync().downloadData(); DataSync().downloadData();
} }),
),
], ],
), ),
), ),
@@ -518,50 +521,50 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
), ),
) )
: Column( : Column(
key: key, key: key,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 600), const SizedBox(width: 600),
...List.generate(importMethods.length, (index) { ...List.generate(importMethods.length, (index) {
return RadioListTile( return RadioListTile(
title: Text(importMethods[index]), title: Text(importMethods[index]),
value: index, value: index,
groupValue: type, groupValue: type,
onChanged: (value) { 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) {
setState(() { setState(() {
selectedFolder = folders[v]; type = value as int;
}); });
}, },
), );
).paddingHorizontal(8), }),
if(!App.isIOS && !App.isMacOS) if (type != 3)
CheckboxListTile( ListTile(
enabled: true, title: Text("Add to favorites".tl),
title: Text("Copy to app local path".tl), trailing: Select(
value: copyToLocalFolder, current: selectedFolder,
onChanged:(v) { values: folders,
setState(() { minWidth: 112,
copyToLocalFolder = !copyToLocalFolder; onTap: (v) {
}); setState(() {
}).paddingHorizontal(8), selectedFolder = folders[v];
const SizedBox(height: 8), });
Text(info).paddingHorizontal(24), },
], ),
), ).paddingHorizontal(8),
if (!App.isIOS && !App.isMacOS)
CheckboxListTile(
enabled: true,
title: Text("Copy to app local path".tl),
value: copyToLocalFolder,
onChanged: (v) {
setState(() {
copyToLocalFolder = !copyToLocalFolder;
});
}).paddingHorizontal(8),
const SizedBox(height: 8),
Text(info).paddingHorizontal(24),
],
),
actions: [ actions: [
Button.text( Button.text(
child: Row( child: Row(
@@ -591,7 +594,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
help += 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" "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; .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( return ContentDialog(
title: "Help".tl, title: "Help".tl,
content: Text(help).paddingHorizontal(16), content: Text(help).paddingHorizontal(16),
@@ -624,9 +629,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
loading = true; loading = true;
}); });
var importer = ImportComic( var importer = ImportComic(
selectedFolder: selectedFolder, selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder);
copyToLocal: copyToLocalFolder); var result = switch (type) {
var result = switch(type) {
0 => await importer.directory(true), 0 => await importer.directory(true),
1 => await importer.directory(false), 1 => await importer.directory(false),
2 => await importer.cbz(), 2 => await importer.cbz(),
@@ -634,7 +638,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
4 => await importer.ehViewer(), 4 => await importer.ehViewer(),
int() => true, int() => true,
}; };
if(result) { if (result) {
context.pop(); context.pop();
} else { } else {
setState(() { setState(() {
@@ -911,3 +915,281 @@ class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon>
); );
} }
} }
class ImageFavorites extends StatefulWidget {
const ImageFavorites({super.key});
@override
State<ImageFavorites> createState() => _ImageFavoritesState();
}
class _ImageFavoritesState extends State<ImageFavorites> {
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<TextWithCount> 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);
}
}

View File

@@ -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<ImageFavorite, bool> selectedImageFavorites;
final List<ImageFavoritesComic> 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<String> 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);
}
}

View File

@@ -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<ImageFavoritesPage> createState() => _ImageFavoritesPageState();
}
class _ImageFavoritesPageState extends State<ImageFavoritesPage> {
late ImageFavoriteSortType sortType;
late TimeRange timeFilterSelect;
late int numFilterSelect;
// 所有的图片收藏
List<ImageFavoritesComic> comics = [];
late var controller =
TextEditingController(text: widget.initialKeyword ?? "");
String get keyword => controller.text;
// 进入关键词搜索模式
bool searchMode = false;
bool multiSelectMode = false;
// 多选的时候选中的图片
Map<ImageFavorite, bool> 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<Widget> 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<String> 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<ImageFavoriteSortType>(
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),
),
],
);
}
}

View File

@@ -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<ImageFavoritesPhotoView> createState() =>
_ImageFavoritesPhotoViewState();
}
class _ImageFavoritesPhotoViewState extends State<ImageFavoritesPhotoView> {
late PageController controller;
Map<ImageFavorite, bool> cancelImageFavorites = {};
var images = <ImageFavorite>[];
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<ImageFavorite> tempList = cancelImageFavorites.entries
.where((e) => e.value == true)
.map((e) => e.key)
.toList();
if (tempList.isNotEmpty) {
ImageFavoriteManager().deleteImageFavorite(tempList);
showToast(
message: "Delete @a images".tlParams({'a': tempList.length}),
context: context);
}
}
PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) {
var image = images[index];
return PhotoViewGalleryPageOptions(
// 图片加载器 支持本地、网络
imageProvider: ImageFavoritesProvider(image),
// 初始化大小 全部展示
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
onTapUp: (context, details, controllerValue) {
setState(() {
isAppBarShow = !isAppBarShow;
});
},
heroAttributes: PhotoViewHeroAttributes(
tag: "${image.sourceKey}${image.ep}${image.page}",
),
);
}
@override
Widget build(BuildContext context) {
return PopScope(
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) {
onPop();
}
},
child: Listener(
onPointerSignal: (event) {
if (HardwareKeyboard.instance.isControlPressed) {
return;
}
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) {
if (controller.page! >= images.length - 1) {
return;
}
controller.nextPage(
duration: Duration(milliseconds: 180), curve: Curves.ease);
} else {
if (controller.page! <= 0) {
return;
}
controller.previousPage(
duration: Duration(milliseconds: 180), curve: Curves.ease);
}
}
},
child: Stack(children: [
Positioned.fill(
child: PhotoViewGallery.builder(
backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface,
),
builder: _buildItem,
itemCount: images.length,
loadingBuilder: (context, event) => Center(
child: SizedBox(
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
backgroundColor: context.colorScheme.surfaceContainerHigh,
value: event == null || event.expectedTotalBytes == null
? null
: event.cumulativeBytesLoaded /
event.expectedTotalBytes!,
),
),
),
pageController: controller,
onPageChanged: (index) {
setState(() {
currentPage = index;
});
},
),
),
buildPageInfo(),
AnimatedPositioned(
top: isAppBarShow ? 0 : -(context.padding.top + 52),
left: 0,
right: 0,
duration: Duration(milliseconds: 180),
child: buildAppBar(),
),
]),
),
);
}
Widget buildPageInfo() {
var text = "${currentPage + 1}/${images.length}";
return Positioned(
height: 40,
left: 0,
right: 0,
bottom: 0,
child: Center(
child: Stack(
children: [
Text(
text,
style: TextStyle(
fontSize: 14,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.4
..color = context.colorScheme.onInverseSurface,
),
),
Text(text),
],
),
),
);
}
Widget buildAppBar() {
return Material(
color: context.colorScheme.surface.toOpacity(0.72),
child: BlurEffect(
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.5,
),
),
),
height: 52,
child: Row(
children: [
const SizedBox(width: 8),
IconButton(
icon: Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
},
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.comic.title,
style: TextStyle(fontSize: 18),
),
),
IconButton(
icon: Icon(Icons.more_vert),
onPressed: showMenu,
),
const SizedBox(width: 8),
],
),
).paddingTop(context.padding.top),
),
);
}
void showMenu() {
showMenuX(
context,
Offset(context.width, context.padding.top),
[
MenuEntry(
icon: Icons.image_outlined,
text: "Save Image".tl,
onClick: () async {
var temp = images[currentPage];
var imageProvider = ImageFavoritesProvider(temp);
var data = await imageProvider.load(null);
var fileType = detectFileType(data);
var fileName = "${currentPage + 1}.${fileType.ext}";
await saveFile(filename: fileName, data: data);
},
),
MenuEntry(
icon: Icons.menu_book_outlined,
text: "Read".tl,
onClick: () async {
var comic = widget.comic;
var ep = images[currentPage].ep;
var page = images[currentPage].page;
App.rootContext.to(
() => ReaderWithLoading(
id: comic.id,
sourceKey: comic.sourceKey,
initialEp: ep,
initialPage: page,
),
);
},
),
],
);
}
}

View File

@@ -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<TimeRange> 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);
}

View File

@@ -20,7 +20,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
static const _kTapToTurnPagePercent = 0.3; static const _kTapToTurnPagePercent = 0.3;
_DragListener? dragListener; final _dragListeners = <_DragListener>[];
int fingers = 0; int fingers = 0;
@@ -44,19 +44,23 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_lastTapPointer = event.pointer; _lastTapPointer = event.pointer;
_lastTapMoveDistance = Offset.zero; _lastTapMoveDistance = Offset.zero;
_tapGestureRecognizer.addPointer(event); _tapGestureRecognizer.addPointer(event);
if(_dragInProgress) { if (_dragInProgress) {
dragListener?.onEnd?.call(); for (var dragListener in _dragListeners) {
dragListener.onStart?.call(event.position);
}
_dragInProgress = false; _dragInProgress = false;
} }
Future.delayed(_kLongPressMinTime, () { Future.delayed(_kLongPressMinTime, () {
if (_lastTapPointer == event.pointer && fingers == 1) { if (_lastTapPointer == event.pointer && fingers == 1) {
if(_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) { if (_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) {
onLongPressedDown(event.position); onLongPressedDown(event.position);
_longPressInProgress = true; _longPressInProgress = true;
} else { } else {
_dragInProgress = true; _dragInProgress = true;
dragListener?.onStart?.call(event.position); for (var dragListener in _dragListeners) {
dragListener?.onMove?.call(_lastTapMoveDistance!); dragListener.onStart?.call(event.position);
dragListener.onMove?.call(_lastTapMoveDistance!);
}
} }
} }
}); });
@@ -65,8 +69,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
if (event.pointer == _lastTapPointer) { if (event.pointer == _lastTapPointer) {
_lastTapMoveDistance = event.delta + _lastTapMoveDistance!; _lastTapMoveDistance = event.delta + _lastTapMoveDistance!;
} }
if(_dragInProgress) { if (_dragInProgress) {
dragListener?.onMove?.call(event.delta); for (var dragListener in _dragListeners) {
dragListener.onMove?.call(event.delta);
}
} }
}, },
onPointerUp: (event) { onPointerUp: (event) {
@@ -74,8 +80,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
if (_longPressInProgress) { if (_longPressInProgress) {
onLongPressedUp(event.position); onLongPressedUp(event.position);
} }
if(_dragInProgress) { if (_dragInProgress) {
dragListener?.onEnd?.call(); for (var dragListener in _dragListeners) {
dragListener.onEnd?.call();
}
_dragInProgress = false; _dragInProgress = false;
} }
_lastTapPointer = null; _lastTapPointer = null;
@@ -86,8 +94,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
if (_longPressInProgress) { if (_longPressInProgress) {
onLongPressedUp(event.position); onLongPressedUp(event.position);
} }
if(_dragInProgress) { if (_dragInProgress) {
dragListener?.onEnd?.call(); for (var dragListener in _dragListeners) {
dragListener.onEnd?.call();
}
_dragInProgress = false; _dragInProgress = false;
} }
_lastTapPointer = null; _lastTapPointer = null;
@@ -261,6 +271,14 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
void onLongPressedDown(Offset location) { void onLongPressedDown(Offset location) {
context.reader._imageViewController?.handleLongPressDown(location); context.reader._imageViewController?.handleLongPressDown(location);
} }
void addDragListener(_DragListener listener) {
_dragListeners.add(listener);
}
void removeDragListener(_DragListener listener) {
_dragListeners.remove(listener);
}
} }
class _DragListener { class _DragListener {
@@ -269,4 +287,4 @@ class _DragListener {
void Function()? onEnd; void Function()? onEnd;
_DragListener({this.onMove, this.onEnd}); _DragListener({this.onMove, this.onEnd});
} }

View File

@@ -263,6 +263,10 @@ class _GalleryModeState extends State<_GalleryMode>
@override @override
void handleDoubleTap(Offset location) { void handleDoubleTap(Offset location) {
if (appdata.settings['quickCollectImage'] == 'DoubleTap') {
context.readerScaffold.addImageFavorite();
return;
}
var controller = photoViewControllers[reader.page]!; var controller = photoViewControllers[reader.page]!;
controller.onDoubleClick?.call(); controller.onDoubleClick?.call();
} }
@@ -461,7 +465,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
widget = Listener( widget = Listener(
onPointerDown: (event) { onPointerDown: (event) {
fingers++; fingers++;
if(fingers > 1 && !disableScroll) { if (fingers > 1 && !disableScroll) {
setState(() { setState(() {
disableScroll = true; disableScroll = true;
}); });
@@ -475,7 +479,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
}, },
onPointerUp: (event) { onPointerUp: (event) {
fingers--; fingers--;
if(fingers <= 1 && disableScroll) { if (fingers <= 1 && disableScroll) {
setState(() { setState(() {
disableScroll = false; disableScroll = false;
}); });
@@ -564,6 +568,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override @override
void handleDoubleTap(Offset location) { void handleDoubleTap(Offset location) {
if (appdata.settings['quickCollectImage'] == 'DoubleTap') {
context.readerScaffold.addImageFavorite();
return;
}
double target; double target;
if (photoViewController.scale != if (photoViewController.scale !=
photoViewController.getInitialScale?.call()) { photoViewController.getInitialScale?.call()) {

View File

@@ -5,12 +5,18 @@ class ReaderWithLoading extends StatefulWidget {
super.key, super.key,
required this.id, required this.id,
required this.sourceKey, required this.sourceKey,
this.initialEp,
this.initialPage,
}); });
final String id; final String id;
final String sourceKey; final String sourceKey;
final int? initialEp;
final int? initialPage;
@override @override
State<ReaderWithLoading> createState() => _ReaderWithLoadingState(); State<ReaderWithLoading> createState() => _ReaderWithLoadingState();
} }
@@ -25,8 +31,10 @@ class _ReaderWithLoadingState
name: data.name, name: data.name,
chapters: data.chapters, chapters: data.chapters,
history: data.history, history: data.history,
initialChapter: data.history.ep, initialChapter: widget.initialEp ?? data.history.ep,
initialPage: data.history.page, initialPage: widget.initialPage ?? data.history.page,
author: data.author,
tags: data.tags,
); );
} }
@@ -57,6 +65,8 @@ class _ReaderWithLoadingState
ep: 0, ep: 0,
page: 0, page: 0,
), ),
author: localComic.subtitle,
tags: localComic.tags,
), ),
); );
} else { } else {
@@ -76,6 +86,8 @@ class _ReaderWithLoadingState
ep: 0, ep: 0,
page: 0, page: 0,
), ),
author: comic.data.findAuthor() ?? "",
tags: comic.data.plainTags,
), ),
); );
} }
@@ -93,11 +105,17 @@ class ReaderProps {
final History history; final History history;
final String author;
final List<String> tags;
const ReaderProps({ const ReaderProps({
required this.type, required this.type,
required this.cid, required this.cid,
required this.name, required this.name,
required this.chapters, required this.chapters,
required this.history, required this.history,
required this.author,
required this.tags,
}); });
} }

View File

@@ -20,6 +20,7 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
@@ -27,8 +28,10 @@ import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/data_sync.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/file_type.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:venera/utils/volume.dart'; import 'package:venera/utils/volume.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
@@ -57,10 +60,16 @@ class Reader extends StatefulWidget {
required this.history, required this.history,
this.initialPage, this.initialPage,
this.initialChapter, this.initialChapter,
required this.author,
required this.tags,
}); });
final ComicType type; final ComicType type;
final String author;
final List<String> tags;
final String cid; final String cid;
final String name; final String name;
@@ -114,12 +123,14 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
void _checkImagesPerPageChange() { void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage; int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) { if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage); _adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage; _lastImagesPerPage = currentImagesPerPage;
} }
} }
void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) { void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage; int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1; int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage; page = newPage;
@@ -150,7 +161,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
updateHistory(); updateHistory();
}); });
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if(appdata.settings['enableTurnPageByVolumeKey']) { if (appdata.settings['enableTurnPageByVolumeKey']) {
handleVolumeEvent(); handleVolumeEvent();
} }
setImageCacheSize(); setImageCacheSize();
@@ -170,7 +181,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} else { } else {
maxImageCacheSize = 500 << 20; 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; PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
} }
@@ -215,7 +227,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} }
void updateHistory() { void updateHistory() {
if(history != null) { if (history != null) {
history!.page = page; history!.page = page;
history!.ep = chapter; history!.ep = chapter;
if (maxPage > 1) { if (maxPage > 1) {
@@ -228,11 +240,11 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} }
void handleVolumeEvent() { void handleVolumeEvent() {
if(!App.isAndroid) { if (!App.isAndroid) {
// Currently only support Android // Currently only support Android
return; return;
} }
if(volumeListener != null) { if (volumeListener != null) {
volumeListener?.cancel(); volumeListener?.cancel();
} }
volumeListener = VolumeListener( volumeListener = VolumeListener(
@@ -246,7 +258,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} }
void stopVolumeEvent() { void stopVolumeEvent() {
if(volumeListener != null) { if (volumeListener != null) {
volumeListener?.cancel(); volumeListener?.cancel();
volumeListener = null; volumeListener = null;
} }
@@ -306,7 +318,8 @@ abstract mixin class _ReaderLocation {
bool toPage(int page) { bool toPage(int page) {
if (_validatePage(page)) { if (_validatePage(page)) {
if (page == this.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; return false;
} }
} }

View File

@@ -18,8 +18,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
bool get isOpen => _isOpen; bool get isOpen => _isOpen;
bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft || bool get isReversed =>
context.reader.mode == ReaderMode.continuousRightToLeft; context.reader.mode == ReaderMode.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft;
int showFloatingButtonValue = 0; int showFloatingButtonValue = 0;
@@ -29,6 +30,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
_ReaderGestureDetectorState? _gestureDetectorState; _ReaderGestureDetectorState? _gestureDetectorState;
_DragListener? _floatingButtonDragListener;
void setFloatingButton(int value) { void setFloatingButton(int value) {
lastValue = showFloatingButtonValue; lastValue = showFloatingButtonValue;
if (value == 0) { if (value == 0) {
@@ -37,12 +40,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0; fABValue.value = 0;
update(); update();
} }
_gestureDetectorState!.dragListener = null; if (_floatingButtonDragListener != null) {
_gestureDetectorState!.removeDragListener(_floatingButtonDragListener!);
_floatingButtonDragListener = null;
}
} }
var readerMode = context.reader.mode; var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) { if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1; showFloatingButtonValue = 1;
_gestureDetectorState!.dragListener = _DragListener( _floatingButtonDragListener = _DragListener(
onMove: (offset) { onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) { if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy; fABValue.value -= offset.dy;
@@ -62,10 +68,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0; fABValue.value = 0;
}, },
); );
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update(); update();
} else if (value == -1 && showFloatingButtonValue == 0) { } else if (value == -1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = -1; showFloatingButtonValue = -1;
_gestureDetectorState!.dragListener = _DragListener( _floatingButtonDragListener = _DragListener(
onMove: (offset) { onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) { if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value += offset.dy; fABValue.value += offset.dy;
@@ -85,10 +92,48 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0; fABValue.value = 0;
}, },
); );
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update(); 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 @override
void initState() { void initState() {
sliderFocus.canRequestFocus = false; sliderFocus.canRequestFocus = false;
@@ -101,6 +146,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
SystemChrome.setPreferredOrientations(DeviceOrientation.values); SystemChrome.setPreferredOrientations(DeviceOrientation.values);
} }
super.initState(); super.initState();
Future.delayed(const Duration(milliseconds: 200), addDragListener);
} }
@override @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<String> 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() { Widget buildBottom() {
var text = "E${context.reader.chapter} : P${context.reader.page}"; var text = "E${context.reader.chapter} : P${context.reader.page}";
if (context.reader.widget.chapters == null) { if (context.reader.widget.chapters == null) {
@@ -233,13 +396,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: buildSlider(), child: buildSlider(),
), ),
IconButton.filledTonal( IconButton.filledTonal(
onPressed: () => !isReversed onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter ? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter() ? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage) : context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1 : context.reader.chapter > 1
? context.reader.toPrevChapter() ? context.reader.toPrevChapter()
: context.reader.toPage(1), : context.reader.toPage(1),
icon: const Icon(Icons.last_page)), icon: const Icon(Icons.last_page)),
const SizedBox( const SizedBox(
width: 8, width: 8,
@@ -263,6 +426,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
), ),
), ),
const Spacer(), const Spacer(),
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(
isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite),
),
if (App.isWindows) if (App.isWindows)
Tooltip( Tooltip(
message: "${"Full Screen".tl}(F12)", message: "${"Full Screen".tl}(F12)",
@@ -358,12 +528,14 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.82), color: context.colorScheme.surface.toOpacity(0.82),
border: Border( border: isOpen
top: BorderSide( ? Border(
color: Colors.grey.toOpacity(0.5), top: BorderSide(
width: 0.5, color: Colors.grey.toOpacity(0.5),
), width: 0.5,
), ),
)
: null,
), ),
padding: EdgeInsets.only(bottom: context.padding.bottom), padding: EdgeInsets.only(bottom: context.padding.bottom),
child: child, child: child,
@@ -559,7 +731,6 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
onChanged: (key) { onChanged: (key) {
if (key == "readerMode") { if (key == "readerMode") {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]); context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
App.rootContext.pop();
} }
if (key == "enableTurnPageByVolumeKey") { if (key == "enableTurnPageByVolumeKey") {
if (appdata.settings[key]) { if (appdata.settings[key]) {
@@ -568,6 +739,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context.reader.stopVolumeEvent(); context.reader.stopVolumeEvent();
} }
} }
if (key == "quickCollectImage") {
addDragListener();
}
context.reader.update(); context.reader.update();
}, },
), ),

View File

@@ -107,15 +107,16 @@ class _AppSettingsState extends State<AppSettings> {
actionTitle: 'Export'.tl, actionTitle: 'Export'.tl,
).toSliver(), ).toSliver(),
_CallbackSetting( _CallbackSetting(
title: "Import App Data".tl, title: "Import App Data (Please restart after success)".tl,
callback: () async { callback: () async {
var controller = showLoadingDialog(context); var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera', 'picadata']); var file = await selectFile(ext: ['venera', 'picadata']);
if (file != null) { 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); await file.saveTo(cacheFile.path);
try { try {
if(file.name.endsWith('picadata')) { if (file.name.endsWith('picadata')) {
await importPicaData(cacheFile); await importPicaData(cacheFile);
} else { } else {
await importAppData(cacheFile); await importAppData(cacheFile);
@@ -123,8 +124,7 @@ class _AppSettingsState extends State<AppSettings> {
} catch (e, s) { } catch (e, s) {
Log.error("Import data", e.toString(), s); Log.error("Import data", e.toString(), s);
context.showMessage(message: "Failed to import data".tl); context.showMessage(message: "Failed to import data".tl);
} } finally {
finally {
cacheFile.deleteIgnoreError(); cacheFile.deleteIgnoreError();
} }
} }

View File

@@ -33,7 +33,9 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
SelectSetting( SelectSetting(
title: "Quick Favorite".tl, title: "Quick Favorite".tl,
settingKey: "quickFavorite", 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: { optionTranslation: {
for (var e in LocalFavoritesManager().folderNames) e: e for (var e in LocalFavoritesManager().folderNames) e: e
}, },
@@ -44,7 +46,8 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
var controller = showLoadingDialog(context); var controller = showLoadingDialog(context);
var count = await LocalFavoritesManager().removeInvalid(); var count = await LocalFavoritesManager().removeInvalid();
controller.close(); 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, actionTitle: 'Delete'.tl,
).toSliver(), ).toSliver(),

View File

@@ -116,6 +116,21 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call("enableClockAndBatteryInfoInReader"); widget.onChanged?.call("enableClockAndBatteryInfoInReader");
}, },
).toSliver(), ).toSliver(),
SelectSetting(
title: "Quick collect image".tl,
settingKey: "quickCollectImage",
optionTranslation: {
"No": "Not enable".tl,
"DoubleTap": "Double Tap".tl,
"Swipe": "Swipe".tl,
},
onChanged: () {
widget.onChanged?.call("quickCollectImage");
},
help:
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
.tl,
).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Custom Image Processing".tl, title: "Custom Image Processing".tl,
builder: () => _CustomImageProcessing(), builder: () => _CustomImageProcessing(),

View File

@@ -10,6 +10,7 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/ext.dart';
import 'package:zip_flutter/zip_flutter.dart'; import 'package:zip_flutter/zip_flutter.dart';
import 'io.dart'; import 'io.dart';
@@ -128,7 +129,24 @@ Future<void> importPicaData(File file) async {
.select("SELECT name FROM sqlite_master WHERE type='table';") .select("SELECT name FROM sqlite_master WHERE type='table';")
.map((e) => e["name"] as String) .map((e) => e["name"] as String)
.toList(); .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) { for (var folderName in folderNames) {
if (!LocalFavoritesManager().existsFolder(folderName)) { if (!LocalFavoritesManager().existsFolder(folderName)) {
LocalFavoritesManager().createFolder(folderName); LocalFavoritesManager().createFolder(folderName);
@@ -141,7 +159,7 @@ Future<void> importPicaData(File file) async {
name: comic['name'], name: comic['name'],
coverPath: comic['cover_path'], coverPath: comic['cover_path'],
author: comic['author'], author: comic['author'],
type: ComicType(switch(comic['type']) { type: ComicType(switch (comic['type']) {
0 => 'picacg'.hashCode, 0 => 'picacg'.hashCode,
1 => 'ehentai'.hashCode, 1 => 'ehentai'.hashCode,
2 => 'jm'.hashCode, 2 => 'jm'.hashCode,
@@ -155,11 +173,9 @@ Future<void> importPicaData(File file) async {
); );
} }
} }
} } catch (e) {
catch(e) {
Log.error("Import Data", "Failed to import local favorite: $e"); Log.error("Import Data", "Failed to import local favorite: $e");
} } finally {
finally {
db.dispose(); db.dispose();
} }
} }
@@ -170,31 +186,80 @@ Future<void> importPicaData(File file) async {
for (var comic in db.select("SELECT * FROM history;")) { for (var comic in db.select("SELECT * FROM history;")) {
HistoryManager().addHistory( HistoryManager().addHistory(
History.fromMap({ History.fromMap({
"type": switch(comic['type']) { "type": switch (comic['type']) {
0 => 'picacg'.hashCode, 0 => 'picacg'.hashCode,
1 => 'ehentai'.hashCode, 1 => 'ehentai'.hashCode,
2 => 'jm'.hashCode, 2 => 'jm'.hashCode,
3 => 'hitomi'.hashCode, 3 => 'hitomi'.hashCode,
4 => 'wnacg'.hashCode, 4 => 'wnacg'.hashCode,
6 => 'nhentai'.hashCode, 5 => 'nhentai'.hashCode,
_ => comic['type'] _ => comic['type']
}, },
"id": comic['target'], "id": comic['target'],
"maxPage": comic["max_page"], "max_page": comic["max_page"],
"ep": comic["ep"], "ep": comic["ep"],
"page": comic["page"], "page": comic["page"],
"time": comic["time"], "time": comic["time"],
"title": comic["title"], "title": comic["title"],
"subtitle": comic["subtitle"], "subtitle": comic["subtitle"],
"cover": comic["cover"], "cover": comic["cover"],
"readEpisode": [comic["ep"]],
}), }),
); );
} }
} List<ImageFavoritesComic> imageFavoritesComicList =
catch(e) { ImageFavoriteManager().comics;
Log.error("Import Data", "Failed to import history: $e"); for (var comic in db.select("SELECT * FROM image_favorites;")) {
} String sourceKey = comic["id"].split("-")[0];
finally { // 换名字了, 绅士漫画
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(); db.dispose();
} }
} }

View File

@@ -95,6 +95,8 @@ extension StringExt on String{
bool get isURL => _isURL(); bool get isURL => _isURL();
bool get isNum => double.tryParse(this) != null; bool get isNum => double.tryParse(this) != null;
bool get isInt => int.tryParse(this) != null;
} }
abstract class ListOrNull{ abstract class ListOrNull{

View File

@@ -376,7 +376,6 @@ class _IOOverrides extends IOOverrides {
return super.createFile(path); return super.createFile(path);
} }
} }
} }
T overrideIO<T>(T Function() f) { T overrideIO<T>(T Function() f) {

View File

@@ -1147,4 +1147,4 @@ packages:
version: "0.0.6" version: "0.0.6"
sdks: sdks:
dart: ">=3.6.0 <4.0.0" dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.1" flutter: ">=3.27.1"