24 Commits

Author SHA1 Message Date
nyne
b6e5035509 v1.2.0
v1.2.0
2025-01-18 18:27:08 +08:00
52410bac03 update windows build script 2025-01-18 17:23:22 +08:00
0a187cca2e fix #144 2025-01-18 17:13:20 +08:00
dda8d98e85 Add UI api 2025-01-18 16:53:05 +08:00
1abf9c151e Fix setTimeout 2025-01-18 16:24:46 +08:00
d9084272e5 Add callback setting 2025-01-18 16:07:16 +08:00
16512f2711 Improve config updates check 2025-01-18 15:43:22 +08:00
481bb97301 fix #143 2025-01-18 12:26:20 +08:00
950690df48 update flutter_7zip 2025-01-18 11:57:12 +08:00
825ef39605 fix #142 2025-01-18 11:43:49 +08:00
5f36ef6ea3 support 7z;
fix #137
2025-01-17 22:30:25 +08:00
bfd115046d fix #140 2025-01-16 19:17:18 +08:00
4c6e4373e9 Improve cache 2025-01-16 18:28:49 +08:00
6467a46e5c Add fetch 2025-01-16 17:58:47 +08:00
0011738820 Update version code 2025-01-16 17:52:30 +08:00
c640e6bfbf Improve image loading 2025-01-16 17:51:43 +08:00
5d1d62e157 fix history database 2025-01-15 18:31:15 +08:00
399b9abaee Improve UI 2025-01-15 18:24:38 +08:00
luckyray
d874920c88 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>
2025-01-15 16:07:08 +08:00
Pacalini
213c225e1e fix #131 (#136) 2025-01-13 13:59:42 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
d55c0aa325 Add Get it on F-Droid badge (#101) 2025-01-11 19:28:41 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
2d6e76a5a6 clean up full_description.txt (#133) 2025-01-11 17:52:42 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
2968f1fa29 Phone screenshots (#132)
* clean up full_description.txt

* ratio up to 2.3

* add 4 screenshots

* add 3 missing screenshots
2025-01-11 13:55:10 +08:00
72228515f6 validate params 2025-01-08 16:32:18 +08:00
75 changed files with 4265 additions and 739 deletions

View File

@@ -13,4 +13,4 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Validate Fastlane Supply Metadata - name: Validate Fastlane Supply Metadata
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.0.0 uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0

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

@@ -8,6 +8,10 @@
A comic reader that support reading local and network comics. A comic reader that support reading local and network comics.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
## Features ## Features
- Read local comics - Read local comics

View File

@@ -4,6 +4,13 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app. This library provides a set of APIs for interacting with the Venera app.
*/ */
function setTimeout(callback, delay) {
sendMessage({
method: 'delay',
time: delay,
}).then(callback);
}
/// encode, decode, hash, decrypt /// encode, decode, hash, decrypt
let Convert = { let Convert = {
/** /**
@@ -486,6 +493,37 @@ let Network = {
}, },
}; };
/**
* [fetch] function for sending HTTP requests. Same api as the browser fetch.
* @param url {string}
* @param options {{method: string, headers: Object, body: any}}
* @returns {Promise<{ok: boolean, status: number, statusText: string, headers: {}, arrayBuffer: (function(): Promise<ArrayBuffer>), text: (function(): Promise<string>), json: (function(): Promise<any>)}>}
* @since 1.2.0
*/
async function fetch(url, options) {
let method = 'GET';
let headers = {};
let data = null;
if (options) {
method = options.method || method;
headers = options.headers || headers;
data = options.body || data;
}
let result = await Network.fetchBytes(method, url, headers, data);
return {
ok: result.status >= 200 && result.status < 300,
status: result.status,
statusText: '',
headers: result.headers,
arrayBuffer: async () => result.body,
text: async () => Convert.decodeUtf8(result.body),
json: async () => JSON.parse(Convert.decodeUtf8(result.body)),
}
}
/** /**
* HtmlDocument class for parsing HTML and querying elements. * HtmlDocument class for parsing HTML and querying elements.
*/ */
@@ -1166,3 +1204,45 @@ class Image {
return new Image(key); return new Image(key);
} }
} }
let UI = {
/**
* Show a message
* @param message {string}
*/
showMessage: (message) => {
sendMessage({
method: 'UI',
function: 'showMessage',
message: message,
})
},
/**
* Show a dialog. Any action will close the dialog.
* @param title {string}
* @param content {string}
* @param actions {{text:string, callback: () => void}[]}
*/
showDialog: (title, content, actions) => {
sendMessage({
method: 'UI',
function: 'showDialog',
title: title,
content: content,
actions: actions,
})
},
/**
* Open [url] in external browser
* @param url {string}
*/
launchUrl: (url) => {
sendMessage({
method: 'UI',
function: 'launchUrl',
url: url,
})
},
}

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": "移动到文件夹",
@@ -153,8 +154,8 @@
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。", "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n", "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n",
"Export as cbz": "导出为cbz", "Export as cbz": "导出为cbz",
"Select a cbz/zip file." : "选择一个cbz/zip文件", "Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
"A cbz file" : "一个cbz文件", "An archive file" : "一个归档文件",
"Fullscreen": "全屏", "Fullscreen": "全屏",
"Exit": "退出", "Exit": "退出",
"View more": "查看更多", "View more": "查看更多",
@@ -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": "下载已开始",
@@ -255,8 +297,8 @@
"End": "末尾", "End": "末尾",
"None": "无", "None": "无",
"View Detail": "查看详情", "View Detail": "查看详情",
"Select a directory which contains multiple cbz/zip files." : "选择一个包含多个cbz/zip文件的目录", "Select a directory which contains multiple archive files." : "选择一个包含多个归档文件的目录",
"Multiple cbz files" : "多个cbz文件", "Multiple archive files" : "多个归档文件",
"No valid comics found" : "未找到有效的漫画", "No valid comics found" : "未找到有效的漫画",
"Enable DNS Overrides": "启用DNS覆写", "Enable DNS Overrides": "启用DNS覆写",
"DNS Overrides": "DNS覆写", "DNS Overrides": "DNS覆写",
@@ -265,7 +307,18 @@
"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": "自定义",
"Reset": "重置",
"Tags": "标签",
"Authors": "作者",
"Comics": "漫画",
"Imported @a comics": "已导入 @a 本漫画",
"New Version": "新版本",
"@c updates": "@c 项更新",
"No updates": "无更新"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -287,7 +340,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": "刪除",
@@ -421,8 +474,8 @@
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。", "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n", "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n",
"Export as cbz": "匯出為cbz", "Export as cbz": "匯出為cbz",
"Select a cbz/zip file." : "選擇一個cbz/zip文件", "Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"A cbz file" : "一個cbz文件", "An archive file" : "一個歸檔文件",
"Fullscreen": "全螢幕", "Fullscreen": "全螢幕",
"Exit": "退出", "Exit": "退出",
"View more": "查看更多", "View more": "查看更多",
@@ -431,8 +484,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,11 +574,52 @@
"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": "查看詳情",
"Select a directory which contains multiple cbz/zip files." : "選擇一個包含多個cbz/zip文件的目錄", "Select a directory which contains multiple archive files." : "選擇一個包含多個歸檔文件的目錄",
"Multiple cbz files" : "多個cbz文件", "Multiple archive files" : "多個歸檔文件",
"No valid comics found" : "未找到有效的漫畫", "No valid comics found" : "未找到有效的漫畫",
"Enable DNS Overrides": "啟用DNS覆寫", "Enable DNS Overrides": "啟用DNS覆寫",
"DNS Overrides": "DNS覆寫", "DNS Overrides": "DNS覆寫",
@@ -533,6 +628,17 @@
"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": "自定義",
"Reset": "重置",
"Tags": "標籤",
"Authors": "作者",
"Comics": "漫畫",
"Imported @a comics": "已匯入 @a 部漫畫",
"New Version": "新版本",
"@c updates": "@c 項更新",
"No updates": "無更新"
} }
} }

View File

@@ -1,13 +1,5 @@
<p><a href="https://flutter.dev/"><img src="https://img.shields.io/badge/flutter-3.24.4-blue" alt="flutter"></a>
<a href="https://github.com/venera-app/venera/blob/master/LICENSE"><img src="https://img.shields.io/github/license/venera-app/venera" alt="License"></a>
<a href="https://github.com/venera-app/venera/releases"><img src="https://img.shields.io/github/v/release/venera-app/venera" alt="Download"></a>
<a href="https://github.com/venera-app/venera/stargazers"><img src="https://img.shields.io/github/stars/venera-app/venera" alt="stars"></a>
<a href="https://t.me/+Ws-IpmUutzkxMjhl"><img src="https://img.shields.io/badge/Telegram-2CA5E0?style=flat&amp;logo=telegram&amp;logoColor=white" alt="Telegram"></a></p>
<p>A comic reader that support reading local and network comics.</p> <p>A comic reader that support reading local and network comics.</p>
<h3>Features</h3>
<h2>Features</h2>
<ul> <ul>
<li>Read local comics</li> <li>Read local comics</li>
<li>Use javascript to create comic sources</li> <li>Use javascript to create comic sources</li>
@@ -17,24 +9,7 @@
<li>View comments, tags, and other information of comics if the source supports</li> <li>View comments, tags, and other information of comics if the source supports</li>
<li>Login to comment, rate, and other operations if the source supports</li> <li>Login to comment, rate, and other operations if the source supports</li>
</ul> </ul>
<h3>Thanks</h3>
<h2>Build from source</h2> <h4>Tags Translation</h4>
<li><a href="https://github.com/EhTagTranslation/Database">github.com/EhTagTranslation/Database</a></li>
<ol>
<li>Clone the repository</li>
<li>Install flutter, see <a href="https://flutter.dev/docs/get-started/install">flutter.dev</a></li>
<li>Install rust, see <a href="https://rustup.rs/">rustup.rs</a></li>
<li>Build for your platform: e.g. <code>flutter build apk</code></li>
</ol>
<h2>Create a new comic source</h2>
<p>See <a href="https://github.com/venera-app/venera-configs">venera-configs</a></p>
<h2>Thanks</h2>
<h3>Tags Translation</h3>
<p><a href="https://github.com/EhTagTranslation/Database"><img src="https://github-readme-stats.vercel.app/api/pin/?username=EhTagTranslation&amp;repo=Database" alt="Readme Card"></a></p>
<p>The Chinese translation of the manga tags is from this project.</p> <p>The Chinese translation of the manga tags is from this project.</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

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

@@ -1,5 +1,27 @@
part of 'components.dart'; part of 'components.dart';
ImageProvider? _findImageProvider(Comic comic) {
ImageProvider image;
if (comic is LocalComic) {
image = LocalComicImageProvider(comic);
} else if (comic is History) {
image = HistoryImageProvider(comic);
} else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local);
if (localComic == null) {
return null;
}
image = FileImage(localComic.coverFile);
} else {
image = CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
}
return image;
}
class ComicTile extends StatelessWidget { class ComicTile extends StatelessWidget {
const ComicTile( const ComicTile(
{super.key, {super.key,
@@ -27,8 +49,14 @@ class ComicTile extends StatelessWidget {
onTap!(); onTap!();
return; return;
} }
App.mainNavigatorKey?.currentContext App.mainNavigatorKey?.currentContext?.to(
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); () => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
),
);
} }
void _onLongPressed(context) { void _onLongPressed(context) {
@@ -61,8 +89,14 @@ class ComicTile extends StatelessWidget {
icon: Icons.chrome_reader_mode_outlined, icon: Icons.chrome_reader_mode_outlined,
text: 'Details'.tl, text: 'Details'.tl,
onClick: () { onClick: () {
App.mainNavigatorKey?.currentContext App.mainNavigatorKey?.currentContext?.to(
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); () => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
),
);
}, },
), ),
MenuEntry( MenuEntry(
@@ -161,24 +195,10 @@ class ComicTile extends StatelessWidget {
} }
Widget buildImage(BuildContext context) { Widget buildImage(BuildContext context) {
ImageProvider image; var image = _findImageProvider(comic);
if (comic is LocalComic) { if (image == null) {
image = LocalComicImageProvider(comic as LocalComic);
} else if (comic is History) {
image = HistoryImageProvider(comic as History);
} else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local);
if (localComic == null) {
return const SizedBox(); return const SizedBox();
} }
image = FileImage(localComic.coverFile);
} else {
image = CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
}
return AnimatedImage( return AnimatedImage(
image: image, image: image,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -199,16 +219,26 @@ class ComicTile extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row( child: Row(
children: [ children: [
Container( Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
width: height * 0.68, width: height * 0.68,
height: double.infinity, height: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: buildImage(context), child: buildImage(context),
), ),
),
SizedBox.fromSize( SizedBox.fromSize(
size: const Size(16, 5), size: const Size(16, 5),
), ),
@@ -248,6 +278,8 @@ class ComicTile extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer, color: context.colorScheme.secondaryContainer,
@@ -264,6 +296,7 @@ class ComicTile extends StatelessWidget {
child: buildImage(context), child: buildImage(context),
), ),
), ),
),
Align( Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
child: (() { child: (() {
@@ -726,9 +759,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();
@@ -1393,7 +1433,7 @@ class _RatingWidgetState extends State<RatingWidget> {
} }
if (full < widget.count) { if (full < widget.count) {
children.add(ClipRect( children.add(ClipRect(
clipper: SMClipper(rating: star() * widget.size), clipper: _SMClipper(rating: star() * widget.size),
child: Icon( child: Icon(
Icons.star, Icons.star,
size: widget.size, size: widget.size,
@@ -1442,10 +1482,10 @@ class _RatingWidgetState extends State<RatingWidget> {
} }
} }
class SMClipper extends CustomClipper<Rect> { class _SMClipper extends CustomClipper<Rect> {
final double rating; final double rating;
SMClipper({required this.rating}); _SMClipper({required this.rating});
@override @override
Rect getClip(Size size) { Rect getClip(Size size) {
@@ -1453,7 +1493,52 @@ class SMClipper extends CustomClipper<Rect> {
} }
@override @override
bool shouldReclip(SMClipper oldClipper) { bool shouldReclip(_SMClipper oldClipper) {
return rating != oldClipper.rating; return rating != oldClipper.rating;
} }
} }
class SimpleComicTile extends StatelessWidget {
const SimpleComicTile({super.key, required this.comic, this.onTap});
final Comic comic;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
var image = _findImageProvider(comic);
var child = image == null
? const SizedBox()
: AnimatedImage(
image: image,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
);
return AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ?? () {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
),
);
}
}

View File

@@ -41,39 +41,44 @@ class AnimatedTapRegion extends StatefulWidget {
} }
class _AnimatedTapRegionState extends State<AnimatedTapRegion> { class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
bool isScaled = false;
bool isHovered = false; bool isHovered = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MouseRegion( return MouseRegion(
onEnter: (_) { onEnter: (_) {
setState(() {
isHovered = true; isHovered = true;
if (!isScaled) {
Future.delayed(const Duration(milliseconds: 100), () {
if (isHovered) {
setState(() => isScaled = true);
}
}); });
}
}, },
onExit: (_) { onExit: (_) {
setState(() {
isHovered = false; isHovered = false;
if(isScaled) { });
setState(() => isScaled = false);
}
}, },
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: ClipRRect( child: AnimatedContainer(
borderRadius: BorderRadius.circular(widget.borderRadius),
clipBehavior: Clip.antiAlias,
child: AnimatedScale(
duration: _fastAnimationDuration, duration: _fastAnimationDuration,
scale: isScaled ? 1.1 : 1, decoration: BoxDecoration(
child: widget.child, borderRadius: BorderRadius.circular(widget.borderRadius),
boxShadow: isHovered
? [
BoxShadow(
color: context.colorScheme.outline,
blurRadius: 2,
offset: const Offset(0, 2),
), ),
]
: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: widget.child,
), ),
), ),
); );

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

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
? Appbar(
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null, onPressed: dismissible ? context.pop : null,
), ),
title: Text(title), title: Text(title!),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
), )
: const SizedBox.shrink(),
this.content, this.content,
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(

View File

@@ -200,16 +200,18 @@ class NaviPaneState extends State<NaviPane>
} }
Widget buildMainView() { Widget buildMainView() {
return Navigator( return HeroControllerScope(
controller: MaterialApp.createMaterialHeroController(),
child: Navigator(
observers: [widget.observer], observers: [widget.observer],
key: widget.navigatorKey, key: widget.navigatorKey,
onGenerateRoute: (settings) => AppPageRoute( onGenerateRoute: (settings) => AppPageRoute(
preventRebuild: false, preventRebuild: false,
isRootRoute: true,
builder: (context) { builder: (context) {
return _NaviMainView(state: this); return _NaviMainView(state: this);
}, },
), ),
),
); );
} }
@@ -362,13 +364,11 @@ class _SideNaviWidget extends StatelessWidget {
color: enabled ? colorScheme.primaryContainer : null, color: enabled ? colorScheme.primaryContainer : null,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: showTitle ? Row( child: showTitle
children: [ ? Row(
icon, children: [icon, const SizedBox(width: 12), Text(entry.label)],
const SizedBox(width: 12), )
Text(entry.label) : Align(
],
) : Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: icon, child: icon,
), ),
@@ -395,13 +395,11 @@ class _PaneActionWidget extends StatelessWidget {
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
height: 38, height: 38,
child: showTitle ? Row( child: showTitle
children: [ ? Row(
icon, children: [icon, const SizedBox(width: 12), Text(entry.label)],
const SizedBox(width: 12), )
Text(entry.label) : Align(
],
) : Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: icon, child: icon,
), ),

View File

@@ -102,6 +102,8 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
duration: _fastAnimationDuration, curve: Curves.linear); duration: _fastAnimationDuration, curve: Curves.linear);
} }
}, },
child: ScrollControllerProvider._(
controller: _controller,
child: widget.builder( child: widget.builder(
context, context,
_controller, _controller,
@@ -109,6 +111,27 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
? const NeverScrollableScrollPhysics() ? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(), : 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

@@ -10,7 +10,7 @@ export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.1.4"; final version = "1.2.0";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;

View File

@@ -19,7 +19,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
super.barrierDismissible = false, super.barrierDismissible = false,
this.enableIOSGesture = true, this.enableIOSGesture = true,
this.preventRebuild = true, this.preventRebuild = true,
this.isRootRoute = false,
}) { }) {
assert(opaque); assert(opaque);
} }
@@ -50,9 +49,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
@override @override
final bool preventRebuild; final bool preventRebuild;
@override
final bool isRootRoute;
} }
mixin _AppRouteTransitionMixin<T> on PageRoute<T> { mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@@ -79,8 +75,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
bool get preventRebuild; bool get preventRebuild;
bool get isRootRoute;
Widget? _child; Widget? _child;
@override @override
@@ -121,22 +115,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@override @override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
if(isRootRoute) {
return FadeTransition(
opacity: Tween<double>(begin: 0, end: 1.0).animate(CurvedAnimation(
parent: animation,
curve: Curves.ease
)),
child: FadeTransition(
opacity: Tween<double>(begin: 1.0, end: 0).animate(CurvedAnimation(
parent: secondaryAnimation,
curve: Curves.ease
)),
child: child,
),
);
}
return SlidePageTransitionBuilder().buildTransitions( return SlidePageTransitionBuilder().buildTransitions(
this, this,
context, context,

View File

@@ -85,6 +85,7 @@ class _Appdata {
"proxy", "proxy",
"authorizationRequired", "authorizationRequired",
"customImageProcessing", "customImageProcessing",
"webdav",
]; ];
/// Sync data from another device /// Sync data from another device
@@ -143,12 +144,13 @@ 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,
'dnsOverrides': {}, 'dnsOverrides': {},
'enableCustomImageProcessing': false, 'enableCustomImageProcessing': false,
'customImageProcessing': _defaultCustomImageProcessing, 'customImageProcessing': defaultCustomImageProcessing,
'sni': true, 'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
}; };
@@ -168,15 +170,20 @@ class _Settings with ChangeNotifier {
} }
} }
const _defaultCustomImageProcessing = ''' const defaultCustomImageProcessing = '''
/** /**
* Process an image * Process an image
* @param image {ArayBuffer} - The image to process * @param image {ArrayBuffer} - The image to process
* @param cid {string} - The comic ID * @param cid {string} - The comic ID
* @param eid {string} - The episode ID * @param eid {string} - The episode ID
* @returns {Promise<ArrayBuffer>} - The processed image * @param page {number} - The page number
* @param sourceKey {string} - The source key
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
*/ */
async function processImage(image, cid, eid) { function processImage(image, cid, eid, page, sourceKey) {
let image = new Promise((resolve, reject) => {
resolve(image);
});
return image; return image;
} }
'''; ''';

View File

@@ -6,6 +6,7 @@ import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
@@ -136,6 +137,8 @@ class ComicSource {
notifyListeners(); notifyListeners();
} }
static final availableUpdates = <String, String>{};
static bool get isEmpty => _sources.isEmpty; static bool get isEmpty => _sources.isEmpty;
/// Name of this source. /// Name of this source.
@@ -201,7 +204,7 @@ class ComicSource {
final LikeCommentFunc? likeCommentFunc; final LikeCommentFunc? likeCommentFunc;
final Map<String, dynamic>? settings; final Map<String, Map<String, dynamic>>? settings;
final Map<String, Map<String, String>>? translations; final Map<String, Map<String, String>>? translations;

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 {

View File

@@ -1,5 +1,6 @@
part of 'comic_source.dart'; part of 'comic_source.dart';
/// return true if ver1 > ver2
bool compareSemVer(String ver1, String ver2) { bool compareSemVer(String ver1, String ver2) {
ver1 = ver1.replaceFirst("-", "."); ver1 = ver1.replaceFirst("-", ".");
ver2 = ver2.replaceFirst("-", "."); ver2 = ver2.replaceFirst("-", ".");
@@ -618,6 +619,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 +772,7 @@ class ComicSourceParser {
addFolder: addFolder, addFolder: addFolder,
deleteFolder: deleteFolder, deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc, addOrDelFavorite: addOrDelFavFunc,
isOldToNewSort: isOldToNewSort,
); );
} }
@@ -920,8 +923,30 @@ class ComicSourceParser {
}; };
} }
Map<String, dynamic> _parseSettings() { Map<String, Map<String, dynamic>> _parseSettings() {
return _getValue("settings") ?? {}; var value = _getValue("settings");
if (value is Map) {
var newMap = <String, Map<String, dynamic>>{};
for (var e in value.entries) {
if (e.key is! String) {
continue;
}
var v = <String, dynamic>{};
for (var e2 in e.value.entries) {
if (e2.key is! String) {
continue;
}
var v2 = e2.value;
if (v2 is JSInvokable) {
v2 = JSAutoFreeFunction(v2);
}
v[e2.key] = v2;
}
newMap[e.key] = v;
}
return newMap;
}
return {};
} }
RegExp? _parseIdMatch() { RegExp? _parseIdMatch() {

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

@@ -594,7 +594,10 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void onReadEnd(String id, ComicType type) async { void onRead(String id, ComicType type) async {
if (appdata.settings['moveFavoriteAfterRead'] == "none") {
return;
}
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
for (final folder in folderNames) { for (final folder in folderNames) {
var rows = _db.select(""" var rows = _db.select("""

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;
@@ -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.
@@ -319,6 +337,7 @@ class HistoryManager with ChangeNotifier {
} }
void close() { void close() {
isInitialized = false;
_db.dispose(); _db.dispose();
} }
} }

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> {
@@ -77,7 +78,13 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
while (data == null && !stop) { while (data == null && !stop) {
try { try {
data = await load(chunkEvents); data = await load(chunkEvents, () {
if (stop) {
throw const _ImageLoadingStopException();
}
});
} on _ImageLoadingStopException {
rethrow;
} catch (e) { } catch (e) {
if (e.toString().contains("Invalid Status Code: 404")) { if (e.toString().contains("Invalid Status Code: 404")) {
rethrow; rethrow;
@@ -99,7 +106,7 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
if (stop) { if (stop) {
throw Exception("Image loading is stopped"); throw const _ImageLoadingStopException();
} }
if (data!.isEmpty) { if (data!.isEmpty) {
@@ -126,17 +133,23 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
rethrow; rethrow;
} }
} catch (e) { } on _ImageLoadingStopException {
rethrow;
} 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();
} }
} }
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents); Future<Uint8List> load(
StreamController<ImageChunkEvent> chunkEvents,
void Function() checkStop,
);
String get key; String get key;
@@ -157,3 +170,7 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List); typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List);
class _ImageLoadingStopException implements Exception {
const _ImageLoadingStopException();
}

View File

@@ -1,4 +1,4 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
@@ -26,9 +26,10 @@ class CachedImageProvider
static const _kMaxLoadingCount = 8; static const _kMaxLoadingCount = 8;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
while(loadingCount > _kMaxLoadingCount) { while(loadingCount > _kMaxLoadingCount) {
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
checkStop();
} }
loadingCount++; loadingCount++;
try { try {
@@ -37,6 +38,7 @@ class CachedImageProvider
return file.readAsBytes(); return file.readAsBytes();
} }
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
checkStop();
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes, expectedTotalBytes: progress.totalBytes,

View File

@@ -1,4 +1,4 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
@@ -17,7 +17,7 @@ class HistoryImageProvider
final History history; final History history;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
var url = history.cover; var url = history.cover;
if (!url.contains('/')) { if (!url.contains('/')) {
var localComic = LocalManager().find(history.id, history.type); var localComic = LocalManager().find(history.id, history.type);
@@ -27,6 +27,7 @@ class HistoryImageProvider
var comicSource = var comicSource =
history.type.comicSource ?? (throw "Comic source not found."); history.type.comicSource ?? (throw "Comic source not found.");
var comic = await comicSource.loadComicInfo!(history.id); var comic = await comicSource.loadComicInfo!(history.id);
checkStop();
url = comic.data.cover; url = comic.data.cover;
history.cover = url; history.cover = url;
HistoryManager().addHistory(history); HistoryManager().addHistory(history);
@@ -36,6 +37,7 @@ class HistoryImageProvider
history.type.sourceKey, history.type.sourceKey,
history.id, history.id,
)) { )) {
checkStop();
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes, expectedTotalBytes: progress.totalBytes,

View File

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

@@ -1,4 +1,4 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
@@ -16,7 +16,7 @@ class LocalComicImageProvider
final LocalComic comic; final LocalComic comic;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
File? file = comic.coverFile; File? file = comic.coverFile;
if(! await file.exists()) { if(! await file.exists()) {
file = null; file = null;
@@ -49,6 +49,7 @@ class LocalComicImageProvider
if(file == null) { if(file == null) {
throw "Error: Cover not found."; throw "Error: Cover not found.";
} }
checkStop();
var data = await file.readAsBytes(); var data = await file.readAsBytes();
if(data.isEmpty) { if(data.isEmpty) {
throw "Exception: Empty file(${file.path})."; throw "Exception: Empty file(${file.path}).";

View File

@@ -1,4 +1,4 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -28,7 +28,7 @@ class LocalFavoriteImageProvider
} }
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
var sourceKey = ComicSource.fromIntKey(intKey)?.key; var sourceKey = ComicSource.fromIntKey(intKey)?.key;
var fileName = key.hashCode.toString(); var fileName = key.hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName)); var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
@@ -37,7 +37,9 @@ class LocalFavoriteImageProvider
} else { } else {
await file.create(recursive: true); await file.create(recursive: true);
} }
checkStop();
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
checkStop();
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes, expectedTotalBytes: progress.totalBytes,
@@ -52,7 +54,8 @@ class LocalFavoriteImageProvider
} }
@override @override
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFavoriteImageProvider> obtainKey(
ImageConfiguration configuration) {
return SynchronousFuture(this); return SynchronousFuture(this);
} }

View File

@@ -1,4 +1,4 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:flutter_qjs/flutter_qjs.dart';
@@ -12,7 +12,7 @@ import 'package:venera/foundation/appdata.dart';
class ReaderImageProvider class ReaderImageProvider
extends BaseImageProvider<image_provider.ReaderImageProvider> { extends BaseImageProvider<image_provider.ReaderImageProvider> {
/// Image provider for normal image. /// Image provider for normal image.
const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid); const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid, this.page);
final String imageKey; final String imageKey;
@@ -22,8 +22,10 @@ class ReaderImageProvider
final String eid; final String eid;
final int page;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
Uint8List? imageBytes; Uint8List? imageBytes;
if (imageKey.startsWith('file://')) { if (imageKey.startsWith('file://')) {
var file = File(imageKey); var file = File(imageKey);
@@ -35,6 +37,7 @@ class ReaderImageProvider
} else { } else {
await for (var event await for (var event
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) { in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
checkStop();
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: event.currentBytes, cumulativeBytesLoaded: event.currentBytes,
expectedTotalBytes: event.totalBytes, expectedTotalBytes: event.totalBytes,
@@ -60,14 +63,57 @@ class ReaderImageProvider
})() })()
'''); ''');
if (func is JSInvokable) { if (func is JSInvokable) {
var result = await func.invoke([imageBytes, cid, eid]); var result = func.invoke([imageBytes, cid, eid, page, sourceKey]);
func.free();
if (result is Uint8List) { if (result is Uint8List) {
return result; imageBytes = result;
} else if (result is Future) {
var futureResult = await result;
if (futureResult is Uint8List) {
imageBytes = futureResult;
}
} else if (result is Map) {
var image = result['image'];
if (image is Uint8List) {
imageBytes = image;
} else if (image is Future) {
JSInvokable? onCancel;
if (result['onCancel'] is JSInvokable) {
onCancel = result['onCancel'];
}
if (onCancel == null) {
var futureImage = await image;
if (futureImage is Uint8List) {
imageBytes = futureImage;
}
} else {
dynamic futureImage;
image.then((value) {
futureImage = value;
futureImage ??= Uint8List(0);
});
while (futureImage == null) {
try {
checkStop();
}
catch(e) {
onCancel.invoke([]);
onCancel.free();
func.free();
rethrow;
}
await Future.delayed(Duration(milliseconds: 50));
}
if (futureImage is Uint8List) {
imageBytes = futureImage;
} }
} }
onCancel?.free();
} }
return imageBytes; }
func.free();
}
}
return imageBytes!;
} }
@override @override

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html; import 'package:html/parser.dart' as html;
import 'package:html/dom.dart' as dom; import 'package:html/dom.dart' as dom;
@@ -19,7 +20,9 @@ import 'package:pointycastle/block/modes/cbc.dart';
import 'package:pointycastle/block/modes/cfb.dart'; import 'package:pointycastle/block/modes/cfb.dart';
import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ecb.dart';
import 'package:pointycastle/block/modes/ofb.dart'; import 'package:pointycastle/block/modes/ofb.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
@@ -39,7 +42,7 @@ class JavaScriptRuntimeException implements Exception {
} }
} }
class JsEngine with _JSEngineApi { class JsEngine with _JSEngineApi, _JsUiApi {
factory JsEngine() => _cache ?? (_cache = JsEngine._create()); factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache; static JsEngine? _cache;
@@ -93,7 +96,6 @@ class JsEngine with _JSEngineApi {
String method = message["method"] as String; String method = message["method"] as String;
switch (method) { switch (method) {
case "log": case "log":
{
String level = message["level"]; String level = message["level"];
Log.addLog( Log.addLog(
switch (level) { switch (level) {
@@ -104,15 +106,11 @@ class JsEngine with _JSEngineApi {
}, },
message["title"], message["title"],
message["content"].toString()); message["content"].toString());
}
case 'load_data': case 'load_data':
{
String key = message["key"]; String key = message["key"];
String dataKey = message["data_key"]; String dataKey = message["data_key"];
return ComicSource.find(key)?.data[dataKey]; return ComicSource.find(key)?.data[dataKey];
}
case 'save_data': case 'save_data':
{
String key = message["key"]; String key = message["key"];
String dataKey = message["data_key"]; String dataKey = message["data_key"];
if (dataKey == 'setting') { if (dataKey == 'setting') {
@@ -122,56 +120,43 @@ class JsEngine with _JSEngineApi {
var source = ComicSource.find(key)!; var source = ComicSource.find(key)!;
source.data[dataKey] = data; source.data[dataKey] = data;
source.saveData(); source.saveData();
}
case 'delete_data': case 'delete_data':
{
String key = message["key"]; String key = message["key"];
String dataKey = message["data_key"]; String dataKey = message["data_key"];
var source = ComicSource.find(key); var source = ComicSource.find(key);
source?.data.remove(dataKey); source?.data.remove(dataKey);
source?.saveData(); source?.saveData();
}
case 'http': case 'http':
{
return _http(Map.from(message)); return _http(Map.from(message));
}
case 'html': case 'html':
{
return handleHtmlCallback(Map.from(message)); return handleHtmlCallback(Map.from(message));
}
case 'convert': case 'convert':
{
return _convert(Map.from(message)); return _convert(Map.from(message));
}
case "random": case "random":
{
return _random( return _random(
message["min"] ?? 0, message["min"] ?? 0,
message["max"] ?? 1, message["max"] ?? 1,
message["type"], message["type"],
); );
}
case "cookie": case "cookie":
{
return handleCookieCallback(Map.from(message)); return handleCookieCallback(Map.from(message));
}
case "uuid": case "uuid":
{
return const Uuid().v1(); return const Uuid().v1();
}
case "load_setting": case "load_setting":
{
String key = message["key"]; String key = message["key"];
String settingKey = message["setting_key"]; String settingKey = message["setting_key"];
var source = ComicSource.find(key)!; var source = ComicSource.find(key)!;
return source.data["settings"]?[settingKey] ?? return source.data["settings"]?[settingKey] ??
source.settings?[settingKey]['default'] ?? source.settings?[settingKey]!['default'] ??
(throw "Setting not found: $settingKey"); (throw "Setting not found: $settingKey");
}
case "isLogged": case "isLogged":
{
return ComicSource.find(message["key"])!.isLogged; return ComicSource.find(message["key"])!.isLogged;
} // temporary solution for [setTimeout] function
// TODO: implement [setTimeout] in quickjs project
case "delay":
return Future.delayed(Duration(milliseconds: message["time"]));
case "UI":
handleUIMessage(Map.from(message));
} }
} }
return null; return null;
@@ -688,3 +673,62 @@ class DocumentWrapper {
return elements.length - 1; return elements.length - 1;
} }
} }
class JSAutoFreeFunction {
final JSInvokable func;
/// Automatically free the function when it's not used anymore
JSAutoFreeFunction(this.func) {
finalizer.attach(this, func);
}
dynamic call(List<dynamic> args) {
return func(args);
}
static final finalizer = Finalizer<JSInvokable>((func) {
func.free();
});
}
mixin class _JsUiApi {
void handleUIMessage(Map<String, dynamic> message) {
switch (message['function']) {
case 'showMessage':
var m = message['message'];
if (m.toString().isNotEmpty) {
App.rootContext.showMessage(message: m.toString());
}
case 'showDialog':
_showDialog(message);
case 'launchUrl':
var url = message['url'];
if (url.toString().isNotEmpty) {
launchUrlString(url.toString());
}
}
}
void _showDialog(Map<String, dynamic> message) {
var title = message['title'];
var content = message['content'];
var actions = <String, JSAutoFreeFunction>{};
for (var action in message['actions']) {
actions[action['text']] = JSAutoFreeFunction(action['callback']);
}
showDialog(context: App.rootContext, builder: (context) {
return ContentDialog(
title: title,
content: Text(content).paddingHorizontal(16),
actions: actions.entries.map((entry) {
return TextButton(
onPressed: () {
entry.value.call([]);
},
child: Text(entry.key),
);
}).toList(),
);
});
}
}

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

@@ -42,6 +42,9 @@ class NetworkCacheManager implements Interceptor {
static const _maxCacheSize = 10 * 1024 * 1024; static const _maxCacheSize = 10 * 1024 * 1024;
void setCache(NetworkCache cache) { void setCache(NetworkCache cache) {
if (_cache.containsKey(cache.uri)) {
size -= _cache[cache.uri]!.size;
}
while (size > _maxCacheSize) { while (size > _maxCacheSize) {
size -= _cache.values.first.size; size -= _cache.values.first.size;
_cache.remove(_cache.keys.first); _cache.remove(_cache.keys.first);
@@ -94,7 +97,7 @@ class NetworkCacheManager implements Interceptor {
var time = DateTime.now(); var time = DateTime.now();
var diff = time.difference(cache.time); var diff = time.difference(cache.time);
if (options.headers['cache-time'] == 'long' && if (options.headers['cache-time'] == 'long' &&
diff < const Duration(hours: 2)) { diff < const Duration(hours: 6)) {
return handler.resolve(Response( return handler.resolve(Response(
requestOptions: options, requestOptions: options,
data: cache.data, data: cache.data,
@@ -110,7 +113,7 @@ class NetworkCacheManager implements Interceptor {
..set('venera-cache', 'true'), ..set('venera-cache', 'true'),
statusCode: 200, statusCode: 200,
)); ));
} else if (diff < const Duration(hours: 1)) { } else if (diff < const Duration(hours: 2)) {
var o = options.copyWith( var o = options.copyWith(
method: "HEAD", method: "HEAD",
); );
@@ -132,15 +135,42 @@ class NetworkCacheManager implements Interceptor {
} }
static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) { static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) {
a.remove('cache-time'); const shouldIgnore = [
a.remove('prevent-parallel'); 'cache-time',
b.remove('cache-time'); 'prevent-parallel',
b.remove('prevent-parallel'); 'date',
'x-varnish',
'cf-ray',
'connection',
'vary',
'content-encoding',
'report-to',
'server-timing',
'token',
'set-cookie',
'cf-cache-status',
'cf-request-id',
'cf-ray',
'authorization',
];
for (var key in shouldIgnore) {
a.remove(key);
b.remove(key);
}
if (a.length != b.length) { if (a.length != b.length) {
return false; return false;
} }
for (var key in a.keys) { for (var key in a.keys) {
if (a[key] != b[key]) { if (a[key] is List && b[key] is List) {
if (a[key].length != b[key].length) {
return false;
}
for (var i = 0; i < a[key].length; i++) {
if (a[key][i] != b[key][i]) {
return false;
}
}
} else if (a[key] != b[key]) {
return false; return false;
} }
} }
@@ -161,7 +191,7 @@ class NetworkCacheManager implements Interceptor {
var cache = NetworkCache( var cache = NetworkCache(
uri: response.requestOptions.uri, uri: response.requestOptions.uri,
requestHeaders: response.requestOptions.headers, requestHeaders: response.requestOptions.headers,
responseHeaders: response.headers.map, responseHeaders: Map.from(response.headers.map),
data: response.data, data: response.data,
time: DateTime.now(), time: DateTime.now(),
size: size, size: size,

View File

@@ -1,14 +1,11 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:shimmer/shimmer.dart"; import 'package:shimmer_animation/shimmer_animation.dart';
import "package:venera/components/components.dart"; import "package:venera/components/components.dart";
import "package:venera/foundation/app.dart"; import "package:venera/foundation/app.dart";
import "package:venera/foundation/comic_source/comic_source.dart"; import "package:venera/foundation/comic_source/comic_source.dart";
import "package:venera/foundation/image_provider/cached_image.dart";
import "package:venera/pages/search_result_page.dart"; import "package:venera/pages/search_result_page.dart";
import "package:venera/utils/translations.dart"; import "package:venera/utils/translations.dart";
import "comic_page.dart";
class AggregatedSearchPage extends StatefulWidget { class AggregatedSearchPage extends StatefulWidget {
const AggregatedSearchPage({super.key, required this.keyword}); const AggregatedSearchPage({super.key, required this.keyword});
@@ -73,9 +70,9 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
bool isLoading = true; bool isLoading = true;
static const _kComicHeight = 144.0; static const _kComicHeight = 132.0;
get _comicWidth => _kComicHeight * 0.72; get _comicWidth => _kComicHeight * 0.7;
static const _kLeftPadding = 16.0; static const _kLeftPadding = 16.0;
@@ -123,28 +120,9 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
} }
Widget buildComic(Comic c) { Widget buildComic(Comic c) {
return AnimatedTapRegion( return SimpleComicTile(comic: c)
borderRadius: 8, .paddingLeft(_kLeftPadding)
onTap: () { .paddingBottom(2);
context.to(() => ComicPage(
id: c.id,
sourceKey: c.sourceKey,
));
},
child: Container(
height: _kComicHeight,
width: _comicWidth,
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
),
child: AnimatedImage(
width: _comicWidth,
height: _kComicHeight,
fit: BoxFit.cover,
image: CachedImageProvider(c.cover),
),
),
).paddingLeft(_kLeftPadding);
} }
@override @override
@@ -169,10 +147,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
SizedBox( SizedBox(
height: _kComicHeight, height: _kComicHeight,
width: double.infinity, width: double.infinity,
child: Shimmer.fromColors( child: Shimmer(
baseColor: context.colorScheme.surfaceContainerLow,
highlightColor: context.colorScheme.surfaceContainer,
direction: ShimmerDirection.ltr,
child: LayoutBuilder(builder: (context, constrains) { child: LayoutBuilder(builder: (context, constrains) {
var itemWidth = _comicWidth + _kLeftPadding; var itemWidth = _comicWidth + _kLeftPadding;
var items = (constrains.maxWidth / itemWidth).ceil(); var items = (constrains.maxWidth / itemWidth).ceil();

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shimmer_animation/shimmer_animation.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
@@ -26,12 +27,22 @@ import 'dart:math' as math;
import 'comments_page.dart'; import 'comments_page.dart';
class ComicPage extends StatefulWidget { class ComicPage extends StatefulWidget {
const ComicPage({super.key, required this.id, required this.sourceKey}); const ComicPage({
super.key,
required this.id,
required this.sourceKey,
this.cover,
this.title,
});
final String id; final String id;
final String sourceKey; final String sourceKey;
final String? cover;
final String? title;
@override @override
State<ComicPage> createState() => _ComicPageState(); State<ComicPage> createState() => _ComicPageState();
} }
@@ -55,13 +66,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override @override
Widget buildLoading() { Widget buildLoading() {
return Column( return _ComicPageLoadingPlaceHolder(
children: [ cover: widget.cover,
const Appbar(title: Text("")), title: widget.title,
Expanded( sourceKey: widget.sourceKey,
child: super.buildLoading(), cid: widget.id,
),
],
); );
} }
@@ -145,6 +154,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();
@@ -199,23 +210,34 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 16), const SizedBox(width: 16),
Container( Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.primaryContainer, color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
), ),
height: 144, height: 144,
width: 144 * 0.72, width: 144 * 0.72,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: AnimatedImage( child: AnimatedImage(
image: CachedImageProvider( image: CachedImageProvider(
comic.cover, widget.cover ?? comic.cover,
sourceKey: comic.sourceKey, sourceKey: comic.sourceKey,
cid: comic.id,
), ),
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
), ),
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
@@ -663,6 +685,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,
), ),
); );
} }
@@ -1217,10 +1241,12 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
} else { } else {
error = res.errorMessage; error = res.errorMessage;
} }
if (mounted) {
setState(() { setState(() {
isLoading = false; isLoading = false;
}); });
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1942,3 +1968,124 @@ class _CommentWidget extends StatelessWidget {
); );
} }
} }
class _ComicPageLoadingPlaceHolder extends StatelessWidget {
const _ComicPageLoadingPlaceHolder({
this.cover,
this.title,
required this.sourceKey,
required this.cid,
});
final String? cover;
final String? title;
final String sourceKey;
final String cid;
@override
Widget build(BuildContext context) {
Widget buildContainer(double? width, double? height,
{Color? color, double? radius}) {
return Container(
height: height,
width: width,
decoration: BoxDecoration(
color: color ?? context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(radius ?? 4),
),
);
}
return Shimmer(
child: Column(
children: [
Appbar(title: Text(""), backgroundColor: context.colorScheme.surface),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
buildImage(context),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(title ?? "", style: ts.s18)
else
buildContainer(200, 25),
const SizedBox(height: 8),
buildContainer(80, 20),
],
),
),
],
),
const SizedBox(height: 8),
if (context.width < changePoint)
Row(
children: [
Expanded(
child: buildContainer(null, 36, radius: 18),
),
const SizedBox(width: 16),
Expanded(
child: buildContainer(null, 36, radius: 18),
),
],
).paddingHorizontal(16),
const Divider(),
const SizedBox(height: 8),
Center(
child: CircularProgressIndicator(
strokeWidth: 2.4,
).fixHeight(24).fixWidth(24),
)
],
),
);
}
Widget buildImage(BuildContext context) {
Widget child;
if (cover != null) {
child = AnimatedImage(
image: CachedImageProvider(
cover!,
sourceKey: sourceKey,
cid: cid,
),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
);
} else {
child = const SizedBox();
}
return Hero(
tag: "cover$cid$sourceKey",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: child,
),
);
}
}

View File

@@ -14,17 +14,15 @@ import 'package:venera/utils/translations.dart';
class ComicSourcePage extends StatefulWidget { class ComicSourcePage extends StatefulWidget {
const ComicSourcePage({super.key}); const ComicSourcePage({super.key});
static Future<void> checkComicSourceUpdate([bool implicit = false]) async { static Future<int> checkComicSourceUpdate() async {
if (ComicSource.all().isEmpty) { if (ComicSource.all().isEmpty) {
return; return 0;
} }
var controller = implicit ? null : showLoadingDialog(App.rootContext);
var dio = AppDio(); var dio = AppDio();
var res = await dio.get<String>( var res = await dio.get<String>(
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json"); "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
if (res.statusCode != 200) { if (res.statusCode != 200) {
App.rootContext.showMessage(message: "Network error".tl); return -1;
return;
} }
var list = jsonDecode(res.data!) as List; var list = jsonDecode(res.data!) as List;
var versions = <String, String>{}; var versions = <String, String>{};
@@ -34,34 +32,17 @@ class ComicSourcePage extends StatefulWidget {
var shouldUpdate = <String>[]; var shouldUpdate = <String>[];
for (var source in ComicSource.all()) { for (var source in ComicSource.all()) {
if (versions.containsKey(source.key) && if (versions.containsKey(source.key) &&
versions[source.key] != source.version) { compareSemVer(versions[source.key]!, source.version)) {
shouldUpdate.add(source.key); shouldUpdate.add(source.key);
} }
} }
controller?.close(); if (shouldUpdate.isNotEmpty) {
if (shouldUpdate.isEmpty) {
if (!implicit) {
App.rootContext.showMessage(message: "No Update Available".tl);
}
return;
}
var msg = "";
for (var key in shouldUpdate) { for (var key in shouldUpdate) {
msg += "${ComicSource.find(key)?.name}: v${versions[key]}\n"; ComicSource.availableUpdates[key] = versions[key]!;
} }
msg = msg.trim(); ComicSource.notifyListeners();
await showConfirmDialog(
context: App.rootContext,
title: "Updates Available".tl,
content: msg,
confirmText: "Update",
onConfirm: () async {
for (var key in shouldUpdate) {
var source = ComicSource.find(key);
await _BodyState.update(source!);
} }
}, return shouldUpdate.length;
);
} }
@override @override
@@ -72,9 +53,6 @@ class _ComicSourcePageState extends State<ComicSourcePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: Appbar(
title: Text('Comic Source'.tl),
),
body: const _Body(), body: const _Body(),
); );
} }
@@ -90,10 +68,30 @@ class _Body extends StatefulWidget {
class _BodyState extends State<_Body> { class _BodyState extends State<_Body> {
var url = ""; var url = "";
void updateUI() {
setState(() {});
}
@override
void initState() {
super.initState();
ComicSource.addListener(updateUI);
}
@override
void dispose() {
super.dispose();
ComicSource.removeListener(updateUI);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SmoothCustomScrollView( return SmoothCustomScrollView(
slivers: [ slivers: [
SliverAppbar(
title: Text('Comic Source'.tl),
style: AppbarStyle.shadow,
),
buildCard(context), buildCard(context),
for (var source in ComicSource.all()) buildSource(context, source), for (var source in ComicSource.all()) buildSource(context, source),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
@@ -102,12 +100,36 @@ class _BodyState extends State<_Body> {
} }
Widget buildSource(BuildContext context, ComicSource source) { Widget buildSource(BuildContext context, ComicSource source) {
var newVersion = ComicSource.availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Column( child: Column(
children: [ children: [
const Divider(), const Divider(),
ListTile( ListTile(
title: Text(source.name), title: Row(
children: [
Text(source.name),
const SizedBox(width: 6),
if (hasUpdate)
Tooltip(
message: newVersion,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
"New Version".tl,
style: const TextStyle(fontSize: 13),
),
),
)
],
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -223,6 +245,8 @@ class _BodyState extends State<_Body> {
}, },
), ),
); );
} else if (type == "callback") {
yield _CallbackSetting(setting: item);
} }
} catch (e, s) { } catch (e, s) {
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s"); Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
@@ -299,6 +323,9 @@ class _BodyState extends State<_Body> {
controller.close(); controller.close();
await ComicSourceParser().parse(res.data!, source.filePath); await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!); await File(source.filePath).writeAsString(res.data!);
if (ComicSource.availableUpdates.containsKey(source.key)) {
ComicSource.availableUpdates.remove(source.key);
}
} catch (e) { } catch (e) {
if (cancel) return; if (cancel) return;
App.rootContext.showMessage(message: e.toString()); App.rootContext.showMessage(message: e.toString());
@@ -368,10 +395,7 @@ class _BodyState extends State<_Body> {
), ),
ListTile( ListTile(
title: Text("Check updates".tl), title: Text("Check updates".tl),
trailing: buildButton( trailing: _CheckUpdatesButton(),
onPressed: () => ComicSourcePage.checkComicSourceUpdate(false),
child: Text("Check".tl),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
@@ -619,3 +643,88 @@ class __EditFilePageState extends State<_EditFilePage> {
); );
} }
} }
class _CheckUpdatesButton extends StatefulWidget {
const _CheckUpdatesButton();
@override
State<_CheckUpdatesButton> createState() => _CheckUpdatesButtonState();
}
class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
bool isLoading = false;
void check() async {
setState(() {
isLoading = true;
});
var count = await ComicSourcePage.checkComicSourceUpdate();
if (count == -1) {
context.showMessage(message: "Network error".tl);
} else if (count == 0) {
context.showMessage(message: "No updates".tl);
} else {
context.showMessage(message: "@c updates".tlParams({"c": count}));
}
setState(() {
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Button.normal(
onPressed: check,
isLoading: isLoading,
child: Text("Check".tl),
).fixHeight(32);
}
}
class _CallbackSetting extends StatefulWidget {
const _CallbackSetting({required this.setting});
final MapEntry<String, Map<String, dynamic>> setting;
@override
State<_CallbackSetting> createState() => _CallbackSettingState();
}
class _CallbackSettingState extends State<_CallbackSetting> {
String get key => widget.setting.key;
String get buttonText => widget.setting.value['buttonText'] ?? "Click";
String get title => widget.setting.value['title'] ?? key;
bool isLoading = false;
Future<void> onClick() async {
var func = widget.setting.value['callback'];
var result = func([]);
if (result is Future) {
setState(() {
isLoading = true;
});
try {
await result;
} finally {
setState(() {
isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title.ts(key)),
trailing: Button.normal(
onPressed: onClick,
isLoading: isLoading,
child: Text(buttonText.ts(key)),
).fixHeight(32),
);
}
}

View File

@@ -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 {
@@ -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';
@@ -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

@@ -50,9 +50,16 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder); var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a; networkSource = a;
networkFolder = b; networkFolder = b;
LocalFavoritesManager().addListener(updateComics);
super.initState(); super.initState();
} }
@override
void dispose() {
super.dispose();
LocalFavoritesManager().removeListener(updateComics);
}
void selectAll() { void selectAll() {
setState(() { setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
@@ -136,17 +143,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 +161,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(
@@ -380,6 +389,35 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
selections: selectedComics, selections: selectedComics,
menuBuilder: (c) { menuBuilder: (c) {
return [ return [
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
),
MenuEntry(
icon: Icons.check,
text: "Select".tl,
onClick: () {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
}
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry( MenuEntry(
icon: Icons.download, icon: Icons.download,
text: "Download".tl, text: "Download".tl,
@@ -655,7 +693,6 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
(c as FavoriteItem).type, (c as FavoriteItem).type,
); );
} }
updateComics();
_cancel(); _cancel();
} }
} }
@@ -741,6 +778,17 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
); );
}, },
), ),
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
});
},
),
], ],
), ),
body: ReorderableBuilder<FavoriteItem>( body: ReorderableBuilder<FavoriteItem>(
@@ -776,3 +824,76 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
); );
} }
} }
class _SelectUpdatePageNum extends StatefulWidget {
const _SelectUpdatePageNum({
required this.networkSource,
this.networkFolder,
super.key,
});
final String? networkFolder;
final String networkSource;
@override
State<_SelectUpdatePageNum> createState() => _SelectUpdatePageNumState();
}
class _SelectUpdatePageNumState extends State<_SelectUpdatePageNum> {
int updatePageNum = 9999999;
String get _allPageText => 'All'.tl;
List<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

@@ -6,17 +6,18 @@ import 'package:venera/foundation/comic_source/comic_source.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/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/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 +36,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 +85,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();
@@ -162,14 +165,12 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
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();
} }),
),
], ],
), ),
), ),
@@ -264,8 +265,8 @@ class _HistoryState extends State<_History> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: history.length, itemCount: history.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return AnimatedTapRegion( return SimpleComicTile(
borderRadius: 8, comic: history[index],
onTap: () { onTap: () {
context.to( context.to(
() => ComicPage( () => ComicPage(
@@ -274,25 +275,7 @@ class _HistoryState extends State<_History> {
), ),
); );
}, },
child: Container( ).paddingHorizontal(8).paddingVertical(2);
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context)
.colorScheme
.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: HistoryImageProvider(history[index]),
width: 96,
height: 128,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
).paddingHorizontal(8);
}, },
), ),
).paddingHorizontal(8).paddingBottom(16), ).paddingHorizontal(8).paddingBottom(16),
@@ -385,32 +368,8 @@ class _LocalState extends State<_Local> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: local.length, itemCount: local.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return AnimatedTapRegion( return SimpleComicTile(comic: local[index])
onTap: () { .paddingHorizontal(8);
local[index].read();
},
borderRadius: 8,
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context)
.colorScheme
.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: LocalComicImageProvider(
local[index],
),
width: 96,
height: 128,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
).paddingHorizontal(8);
}, },
), ),
).paddingHorizontal(8), ).paddingHorizontal(8),
@@ -494,15 +453,15 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
String info = [ String info = [
"Select a directory which contains the comic files.".tl, "Select a directory which contains the comic files.".tl,
"Select a directory which contains the comic directories.".tl, "Select a directory which contains the comic directories.".tl,
"Select a cbz/zip file.".tl, "Select an archive file (cbz, zip, 7z, cb7)".tl,
"Select a directory which contains multiple cbz/zip files.".tl, "Select a directory which contains multiple archive files.".tl,
"Select an EhViewer database and a download folder.".tl "Select an EhViewer database and a download folder.".tl
][type]; ][type];
List<String> importMethods = [ List<String> importMethods = [
"Single Comic".tl, "Single Comic".tl,
"Multiple Comics".tl, "Multiple Comics".tl,
"A cbz file".tl, "An archive file".tl,
"Multiple cbz files".tl, "Multiple archive files".tl,
"EhViewer downloads".tl "EhViewer downloads".tl
]; ];
@@ -534,7 +493,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
}, },
); );
}), }),
if(type != 3) if (type != 4)
ListTile( ListTile(
title: Text("Add to favorites".tl), title: Text("Add to favorites".tl),
trailing: Select( trailing: Select(
@@ -548,7 +507,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
}, },
), ),
).paddingHorizontal(8), ).paddingHorizontal(8),
if(!App.isIOS && !App.isMacOS) if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
CheckboxListTile( CheckboxListTile(
enabled: true, enabled: true,
title: Text("Copy to app local path".tl), title: Text("Copy to app local path".tl),
@@ -591,7 +550,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,8 +585,7 @@ 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),
@@ -736,6 +696,30 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}).toList(), }).toList(),
).paddingHorizontal(16).paddingBottom(16), ).paddingHorizontal(16).paddingBottom(16),
), ),
if (ComicSource.availableUpdates.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.update, color: context.colorScheme.primary, size: 20,),
const SizedBox(width: 8),
Text("@c updates".tlParams({
'c': ComicSource.availableUpdates.length,
}), style: ts.withColor(context.colorScheme.primary),),
],
),
).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8),
], ],
), ),
), ),
@@ -911,3 +895,281 @@ class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon>
); );
} }
} }
class ImageFavorites extends StatefulWidget {
const ImageFavorites({super.key});
@override
State<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, 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

@@ -37,9 +37,6 @@ class _MainPageState extends State<MainPage> {
} }
void checkUpdates() async { void checkUpdates() async {
if (!appdata.settings['checkUpdateOnStart']) {
return;
}
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch; var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) { if (now - lastCheck < 24 * 60 * 60 * 1000) {
@@ -47,9 +44,11 @@ class _MainPageState extends State<MainPage> {
} }
appdata.implicitData['lastCheckUpdate'] = now; appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData(); appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false); await checkUpdateUi(false);
await ComicSourcePage.checkComicSourceUpdate(true); }
} }
@override @override

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;
@@ -45,7 +45,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_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, () {
@@ -55,8 +57,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_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!);
}
} }
} }
}); });
@@ -66,7 +70,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_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) {
@@ -75,7 +81,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
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;
@@ -87,7 +95,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
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 {

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();
} }
@@ -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()) {
@@ -665,6 +673,7 @@ ImageProvider _createImageProviderFromKey(
reader.type.comicSource?.key, reader.type.comicSource?.key,
reader.cid, reader.cid,
reader.eid, reader.eid,
reader.page,
); );
} }

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,8 @@ 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/favorites.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 +29,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 +61,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 +124,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;
@@ -138,6 +150,12 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
void initState() { void initState() {
page = widget.initialPage ?? 1; page = widget.initialPage ?? 1;
chapter = widget.initialChapter ?? 1; chapter = widget.initialChapter ?? 1;
if (page < 1) {
page = 1;
}
if (chapter < 1) {
chapter = 1;
}
mode = ReaderMode.fromKey(appdata.settings['readerMode']); mode = ReaderMode.fromKey(appdata.settings['readerMode']);
history = widget.history; history = widget.history;
Future.microtask(() { Future.microtask(() {
@@ -148,6 +166,9 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
handleVolumeEvent(); handleVolumeEvent();
} }
setImageCacheSize(); setImageCacheSize();
Future.delayed(const Duration(milliseconds: 200), () {
LocalFavoritesManager().onRead(cid, type);
});
super.initState(); super.initState();
} }
@@ -164,7 +185,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;
} }
@@ -300,7 +322,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,7 +18,8 @@ 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.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft; 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) {
@@ -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
? Border(
top: BorderSide( top: BorderSide(
color: Colors.grey.toOpacity(0.5), color: Colors.grey.toOpacity(0.5),
width: 0.5, width: 0.5,
), ),
), )
: null,
), ),
padding: EdgeInsets.only(bottom: context.padding.bottom), padding: EdgeInsets.only(bottom: context.padding.bottom),
child: child, child: child,
@@ -478,6 +650,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
reader.type.comicSource!.key, reader.type.comicSource!.key,
reader.cid, reader.cid,
reader.eid, reader.eid,
reader.page,
); );
} }
return InkWell( return InkWell(
@@ -559,7 +732,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 +740,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context.reader.stopVolumeEvent(); context.reader.stopVolumeEvent();
} }
} }
if (key == "quickCollectImage") {
addDragListener();
}
context.reader.update(); context.reader.update();
}, },
), ),

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -184,7 +185,7 @@ class _SearchPageState extends State<SearchPage> {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: buildSearchOptions(), child: buildSearchOptions(),
); );
yield buildSearchHistory(); yield _SearchHistory(search);
} }
} }
@@ -228,6 +229,11 @@ class _SearchPageState extends State<SearchPage> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
aggregatedSearch = value ?? false; aggregatedSearch = value ?? false;
if (!aggregatedSearch &&
appdata.settings['defaultSearchTarget'] ==
"_aggregated_") {
searchTarget = sources.first.key;
}
}); });
}, },
), ),
@@ -286,78 +292,6 @@ class _SearchPageState extends State<SearchPage> {
); );
} }
Widget buildSearchHistory() {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
return const SizedBox(
height: 16,
);
}
if (index == 1) {
return ListTile(
leading: const Icon(Icons.history),
contentPadding: EdgeInsets.zero,
title: Text("Search History".tl),
trailing: Flyout(
flyoutBuilder: (context) {
return FlyoutContent(
title: "Clear Search History".tl,
actions: [
FilledButton(
child: Text("Clear".tl),
onPressed: () {
appdata.clearSearchHistory();
context.pop();
update();
},
)
],
);
},
child: Builder(
builder: (context) {
return Tooltip(
message: "Clear".tl,
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
context
.findAncestorStateOfType<FlyoutState>()!
.show();
},
),
);
},
),
),
);
}
return InkWell(
onTap: () {
search(appdata.searchHistory[index - 2]);
},
child: Container(
decoration: BoxDecoration(
// color: context.colorScheme.surfaceContainer,
border: Border(
left: BorderSide(
color: context.colorScheme.outlineVariant,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(appdata.searchHistory[index - 2], style: ts.s14),
),
).paddingBottom(8).paddingHorizontal(4);
},
childCount: 2 + appdata.searchHistory.length,
),
).sliverPaddingHorizontal(16);
}
Widget buildSuggestions(BuildContext context) { Widget buildSuggestions(BuildContext context) {
bool check(String text, String key, String value) { bool check(String text, String key, String value) {
if (text.removeAllBlank == "") { if (text.removeAllBlank == "") {
@@ -577,3 +511,130 @@ class SearchOptionWidget extends StatelessWidget {
); );
} }
} }
class _SearchHistory extends StatefulWidget {
const _SearchHistory(this.search);
final void Function(String) search;
@override
State<_SearchHistory> createState() => _SearchHistoryState();
}
class _SearchHistoryState extends State<_SearchHistory> {
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
return const SizedBox(
height: 16,
);
}
if (index == 1) {
return ListTile(
leading: const Icon(Icons.history),
contentPadding: EdgeInsets.zero,
title: Text("Search History".tl),
trailing: Flyout(
flyoutBuilder: (context) {
return FlyoutContent(
title: "Clear Search History".tl,
actions: [
FilledButton(
child: Text("Clear".tl),
onPressed: () {
appdata.clearSearchHistory();
context.pop();
setState(() {});
},
)
],
);
},
child: Builder(
builder: (context) {
return Tooltip(
message: "Clear".tl,
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
context
.findAncestorStateOfType<FlyoutState>()!
.show();
},
),
);
},
),
),
);
}
return buildItem(index - 2);
},
childCount: 2 + appdata.searchHistory.length,
),
).sliverPaddingHorizontal(16);
}
Widget buildItem(int index) {
void showMenu(Offset offset) {
showMenuX(
context,
offset,
[
MenuEntry(
icon: Icons.copy,
text: 'Copy'.tl,
onClick: () {
Clipboard.setData(
ClipboardData(text: appdata.searchHistory[index]));
},
),
MenuEntry(
icon: Icons.delete,
text: 'Delete'.tl,
onClick: () {
appdata.removeSearchHistory(appdata.searchHistory[index]);
appdata.saveData();
setState(() {});
},
),
],
);
}
return Builder(builder: (context) {
return InkWell(
onTap: () {
widget.search(appdata.searchHistory[index]);
},
onLongPress: () {
var renderBox = context.findRenderObject() as RenderBox;
var offset = renderBox.localToGlobal(Offset.zero);
showMenu(Offset(
offset.dx + renderBox.size.width / 2 - 121,
offset.dy + renderBox.size.height - 8,
));
},
onSecondaryTapUp: (details) {
showMenu(details.globalPosition);
},
child: Container(
decoration: BoxDecoration(
// color: context.colorScheme.surfaceContainer,
border: Border(
left: BorderSide(
color: context.colorScheme.outlineVariant,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(appdata.searchHistory[index], style: ts.s14),
),
).paddingBottom(8).paddingHorizontal(4);
});
}
}

View File

@@ -107,12 +107,13 @@ 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')) {
@@ -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

@@ -250,13 +250,16 @@ class _DNSOverrides extends StatefulWidget {
} }
class __DNSOverridesState extends State<_DNSOverrides> { class __DNSOverridesState extends State<_DNSOverrides> {
var overrides = <(String, String)>[]; var overrides = <(TextEditingController, TextEditingController)>[];
@override @override
void initState() { void initState() {
for (var entry in (appdata.settings['dnsOverrides'] as Map).entries) { for (var entry in (appdata.settings['dnsOverrides'] as Map).entries) {
if (entry.key is String && entry.value is String) { if (entry.key is String && entry.value is String) {
overrides.add((entry.key, entry.value)); overrides.add((
TextEditingController(text: entry.key),
TextEditingController(text: entry.value)
));
} }
} }
super.initState(); super.initState();
@@ -266,7 +269,7 @@ class __DNSOverridesState extends State<_DNSOverrides> {
void dispose() { void dispose() {
var map = <String, String>{}; var map = <String, String>{};
for (var entry in overrides) { for (var entry in overrides) {
map[entry.$1] = entry.$2; map[entry.$1.text] = entry.$2.text;
} }
appdata.settings['dnsOverrides'] = map; appdata.settings['dnsOverrides'] = map;
appdata.saveData(); appdata.saveData();
@@ -300,7 +303,8 @@ class __DNSOverridesState extends State<_DNSOverrides> {
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: () {
setState(() { setState(() {
overrides.add(('', '')); overrides
.add((TextEditingController(), TextEditingController()));
}); });
}, },
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
@@ -315,6 +319,7 @@ class __DNSOverridesState extends State<_DNSOverrides> {
Widget buildOverride(int index) { Widget buildOverride(int index) {
var entry = overrides[index]; var entry = overrides[index];
return Container( return Container(
key: ValueKey(index),
height: 48, height: 48,
margin: EdgeInsets.symmetric(horizontal: 8), margin: EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -338,10 +343,7 @@ class __DNSOverridesState extends State<_DNSOverrides> {
border: InputBorder.none, border: InputBorder.none,
hintText: "Domain".tl, hintText: "Domain".tl,
), ),
controller: TextEditingController(text: entry.$1), controller: entry.$1,
onChanged: (v) {
overrides[index] = (v, entry.$2);
},
).paddingHorizontal(8), ).paddingHorizontal(8),
), ),
Container( Container(
@@ -354,10 +356,7 @@ class __DNSOverridesState extends State<_DNSOverrides> {
border: InputBorder.none, border: InputBorder.none,
hintText: "IP".tl, hintText: "IP".tl,
), ),
controller: TextEditingController(text: entry.$2), controller: entry.$2,
onChanged: (v) {
overrides[index] = (entry.$1, v);
},
).paddingHorizontal(8), ).paddingHorizontal(8),
), ),
Container( Container(

View File

@@ -116,9 +116,25 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call("enableClockAndBatteryInfoInReader"); widget.onChanged?.call("enableClockAndBatteryInfoInReader");
}, },
).toSliver(), ).toSliver(),
_PopupWindowSetting( SelectSetting(
title: "Quick collect image".tl,
settingKey: "quickCollectImage",
optionTranslation: {
"No": "Not enable".tl,
"DoubleTap": "Double Tap".tl,
"Swipe": "Swipe".tl,
},
onChanged: () {
widget.onChanged?.call("quickCollectImage");
},
help:
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
.tl,
).toSliver(),
_CallbackSetting(
title: "Custom Image Processing".tl, title: "Custom Image Processing".tl,
builder: () => _CustomImageProcessing(), callback: () => context.to(() => _CustomImageProcessing()),
actionTitle: "Edit".tl,
).toSliver(), ).toSliver(),
], ],
); );
@@ -148,10 +164,25 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
super.dispose(); super.dispose();
} }
int resetKey = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopUpWidgetScaffold( return Scaffold(
title: "Custom Image Processing".tl, appBar: Appbar(
title: Text("Custom Image Processing".tl),
actions: [
TextButton(
onPressed: () {
current = defaultCustomImageProcessing;
appdata.settings['customImageProcessing'] = current;
resetKey++;
setState(() {});
},
child: Text("Reset".tl),
)
],
),
body: Column( body: Column(
children: [ children: [
_SwitchSetting( _SwitchSetting(
@@ -167,6 +198,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
), ),
child: SizedBox.expand( child: SizedBox.expand(
child: CodeEditor( child: CodeEditor(
key: ValueKey(resetKey),
initialValue: appdata.settings['customImageProcessing'], initialValue: appdata.settings['customImageProcessing'],
onChanged: (value) { onChanged: (value) {
current = value; current = value;

View File

@@ -1,9 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_7zip/flutter_7zip.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:zip_flutter/zip_flutter.dart'; import 'package:zip_flutter/zip_flutter.dart';
@@ -57,12 +58,33 @@ class ComicChapter {
ComicChapter({required this.title, required this.start, required this.end}); ComicChapter({required this.title, required this.start, required this.end});
} }
/// Comic Book Archive. Currently supports CBZ, ZIP and 7Z formats.
abstract class CBZ { abstract class CBZ {
static Future<FileType> checkType(File file) async {
var header = <int>[];
await for (var bytes in file.openRead()) {
header.addAll(bytes);
if (header.length >= 32) break;
}
return detectFileType(header);
}
static Future<void> extractArchive(File file, Directory out) async {
var fileType = await checkType(file);
if (fileType.mime == 'application/zip') {
await ZipFile.openAndExtractAsync(file.path, out.path, 4);
} else if (fileType.mime == "application/x-7z-compressed") {
await SZArchive.extractIsolates(file.path, out.path, 4);
} else {
throw Exception('Unsupported archive type');
}
}
static Future<LocalComic> import(File file) async { static Future<LocalComic> import(File file) async {
var cache = Directory(FilePath.join(App.cachePath, 'cbz_import')); var cache = Directory(FilePath.join(App.cachePath, 'cbz_import'));
if (cache.existsSync()) cache.deleteSync(recursive: true); if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync(); cache.createSync();
await ZipFile.openAndExtractAsync(file.path, cache.path, 4); await extractArchive(file, cache);
var metaDataFile = File(FilePath.join(cache.path, 'metadata.json')); var metaDataFile = File(FilePath.join(cache.path, 'metadata.json'));
ComicMetaData? metaData; ComicMetaData? metaData;
if (metaDataFile.existsSync()) { if (metaDataFile.existsSync()) {
@@ -72,7 +94,7 @@ abstract class CBZ {
} catch (_) {} } catch (_) {}
} }
metaData ??= ComicMetaData( metaData ??= ComicMetaData(
title: file.name.replaceLast('.cbz', ''), title: file.name.substring(0, file.name.lastIndexOf('.')),
author: "", author: "",
tags: [], tags: [],
); );
@@ -86,6 +108,7 @@ abstract class CBZ {
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
}); });
if(files.isEmpty) { if(files.isEmpty) {
cache.deleteSync(recursive: true);
throw Exception('No images found in the archive'); throw Exception('No images found in the archive');
} }
files.sort((a, b) => a.path.compareTo(b.path)); files.sort((a, b) => a.path.compareTo(b.path));

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);
@@ -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();
} }
} }
@@ -176,25 +192,74 @@ Future<void> importPicaData(File file) async {
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 =
ImageFavoriteManager().comics;
for (var comic in db.select("SELECT * FROM image_favorites;")) {
String sourceKey = comic["id"].split("-")[0];
// 换名字了, 绅士漫画
if (sourceKey.toLowerCase() == "htmanga") {
sourceKey = "wnacg";
} }
catch(e) { if (ComicSource.find(sourceKey) == null) {
Log.error("Import Data", "Failed to import history: $e"); continue;
} }
finally { 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

@@ -21,8 +21,17 @@ class FileType {
} }
} }
final _resolver = MimeTypeResolver()
// zip
..addMagicNumber([0x50, 0x4B], 'application/zip')
// 7z
..addMagicNumber([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed')
// rar
..addMagicNumber([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar')
;
FileType detectFileType(List<int> data) { FileType detectFileType(List<int> data) {
var mime = lookupMimeType('no-file', headerBytes: data); var mime = _resolver.lookup('no-file', headerBytes: data);
var ext = mime == null ? '' : extensionFromMime(mime); var ext = mime == null ? '' : extensionFromMime(mime);
if(ext == 'jpe') { if(ext == 'jpe') {
ext = 'jpg'; ext = 'jpg';

View File

@@ -20,7 +20,7 @@ class ImportComic {
const ImportComic({this.selectedFolder, this.copyToLocal = true}); const ImportComic({this.selectedFolder, this.copyToLocal = true});
Future<bool> cbz() async { Future<bool> cbz() async {
var file = await selectFile(ext: ['cbz', 'zip']); var file = await selectFile(ext: ['cbz', 'zip', '7z', 'cb7']);
Map<String?, List<LocalComic>> imported = {}; Map<String?, List<LocalComic>> imported = {};
if (file == null) { if (file == null) {
return false; return false;
@@ -42,7 +42,8 @@ class ImportComic {
var dir = await picker.pickDirectory(directAccess: true); var dir = await picker.pickDirectory(directAccess: true);
if (dir != null) { if (dir != null) {
var files = (await dir.list().toList()).whereType<File>().toList(); var files = (await dir.list().toList()).whereType<File>().toList();
files.removeWhere((e) => e.extension != 'cbz' && e.extension != 'zip'); const supportedExtensions = ['cbz', 'zip', '7z', 'cb7'];
files.removeWhere((e) => !supportedExtensions.contains(e.extension));
Map<String?, List<LocalComic>> imported = {}; Map<String?, List<LocalComic>> imported = {};
var controller = showLoadingDialog(App.rootContext, allowCancel: false); var controller = showLoadingDialog(App.rootContext, allowCancel: false);
var comics = <LocalComic>[]; var comics = <LocalComic>[];

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

@@ -303,6 +303,15 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_7zip:
dependency: "direct main"
description:
path: "."
ref: b33344797f1d2469339e0e1b75f5f954f1da224c
resolved-ref: b33344797f1d2469339e0e1b75f5f954f1da224c
url: "https://github.com/wgh136/flutter_7zip"
source: git
version: "0.0.1"
flutter_file_dialog: flutter_file_dialog:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -867,14 +876,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.2" version: "5.0.2"
shimmer: shimmer_animation:
dependency: "direct main" dependency: "direct main"
description: description:
name: shimmer name: shimmer_animation
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" sha256: "9357080b7dd892aae837d569e1fbbcbe7f9a02ca994e558561d90e35e92f1101"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "2.2.2"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

@@ -2,7 +2,7 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.1.4+114 version: 1.2.0+120
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.6.0 <4.0.0'
@@ -69,10 +69,14 @@ dependencies:
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
pdf: ^3.11.1 pdf: ^3.11.1
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
shimmer: ^3.0.0 shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1 flutter_memory_info: ^0.0.1
syntax_highlight: ^0.4.0 syntax_highlight: ^0.4.0
text_scroll: ^0.2.0 text_scroll: ^0.2.0
flutter_7zip:
git:
url: https://github.com/wgh136/flutter_7zip
ref: b33344797f1d2469339e0e1b75f5f954f1da224c
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -59,6 +59,7 @@ Source: "{#RootPath}\build\windows\x64\runner\Release\zip_flutter.dll"; DestDir:
Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\dynamic_color_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\dynamic_color_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\flutter_7zip.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#RootPath}\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files ; NOTE: Don't use "Flags: ignoreversion" on any shared system files