48 Commits

Author SHA1 Message Date
nyne
578c06fdc1 v1.2.1 2025-01-21 16:02:01 +08:00
8645dda967 v1.2.1 2025-01-21 15:38:52 +08:00
ded9055363 Update flutter_qjs 2025-01-21 15:37:46 +08:00
ff42c726fa Fix network header 2025-01-21 15:15:11 +08:00
53b033258a Fix ios build 2025-01-20 21:31:17 +08:00
6ec4817dc1 Fix ios and macos build 2025-01-20 21:17:08 +08:00
283afbc6d4 Improve ui api 2025-01-20 21:06:45 +08:00
c3a09c8870 Update flutter_qjs 2025-01-20 20:48:16 +08:00
f2388c81e0 Lower iOS version requirements 2025-01-20 20:21:43 +08:00
c334e4fa05 Add a setting for comic source list url 2025-01-20 19:28:03 +08:00
nyne
cc8277d462 Update import_comic.md 2025-01-20 19:17:36 +08:00
e6b7f5b014 Move help to GitHub 2025-01-20 19:15:06 +08:00
1edf284709 Add doc 2025-01-20 19:06:20 +08:00
6033a3cde9 Add app api 2025-01-20 15:18:16 +08:00
27e7356721 Upgrade to flutter 3.27.2 2025-01-20 15:09:48 +08:00
d88ae57320 Add select dialog 2025-01-20 15:02:36 +08:00
7b7710b441 Update flutter_qjs 2025-01-19 22:35:00 +08:00
63346396e0 Add input dialog 2025-01-19 20:55:53 +08:00
51b7df02e7 Improve ui api 2025-01-19 20:36:17 +08:00
nyne
811fbb04dc Merge pull request #147 from UjuiUjuMandan/rust
rustup default stable
2025-01-19 16:40:47 +08:00
UjuiUjuMandan
eaf94363ae rustup default stable 2025-01-19 08:17:27 +00:00
5e3ff48d35 fix explore page 2025-01-19 10:06:52 +08:00
c6ec38632f fix data sync 2025-01-19 10:05:08 +08:00
1c1f418019 fix UI api 2025-01-18 22:55:34 +08:00
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
82 changed files with 5900 additions and 808 deletions

View File

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

View File

@@ -86,6 +86,10 @@ jobs:
with:
distribution: 'oracle'
java-version: '17'
- name: Setup Rust
run: |
rustup update
rustup default stable
- run: flutter pub get
- run: flutter build apk --release
- uses: actions/upload-artifact@v4

1
.gitignore vendored
View File

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

View File

@@ -8,6 +8,10 @@
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
- Read local comics
@@ -27,7 +31,7 @@ A comic reader that support reading local and network comics.
## Create a new comic source
See [venera-configs](https://github.com/venera-app/venera-configs)
See [Comic Source](doc/comic_source.md)
## Thanks

View File

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

View File

@@ -4,6 +4,13 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app.
*/
function setTimeout(callback, delay) {
sendMessage({
method: 'delay',
time: delay,
}).then(callback);
}
/// encode, decode, hash, decrypt
let Convert = {
/**
@@ -486,6 +493,37 @@ let Network = {
},
};
/**
* [fetch] function for sending HTTP requests. Same api as the browser fetch.
* @param url {string}
* @param options {{method: string, headers: Object, body: any}}
* @returns {Promise<{ok: boolean, status: number, statusText: string, headers: {}, arrayBuffer: (function(): Promise<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.
*/
@@ -1166,3 +1204,144 @@ class Image {
return new Image(key);
}
}
/**
* UI related apis
* @since 1.2.0
*/
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 | Promise<void>, style: "text"|"filled"|"danger"}[]} - If callback returns a promise, the button will show a loading indicator until the promise is resolved.
* @returns {Promise<void>} - Resolved when the dialog is closed.
* @since 1.2.1
*/
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,
})
},
/**
* Show a loading dialog.
* @param onCancel {() => void | null | undefined} - Called when the loading dialog is canceled. If [onCancel] is null, the dialog cannot be canceled by the user.
* @returns {number} - A number that can be used to cancel the loading dialog.
* @since 1.2.1
*/
showLoading: (onCancel) => {
return sendMessage({
method: 'UI',
function: 'showLoading',
onCancel: onCancel
})
},
/**
* Cancel a loading dialog.
* @param id {number} - returned by [showLoading]
* @since 1.2.1
*/
cancelLoading: (id) => {
sendMessage({
method: 'UI',
function: 'cancelLoading',
id: id
})
},
/**
* Show an input dialog
* @param title {string}
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
*/
showInputDialog: (title, validator) => {
return sendMessage({
method: 'UI',
function: 'showInputDialog',
title: title,
validator: validator
})
},
/**
* Show a select dialog
* @param title {string}
* @param options {string[]}
* @param initialIndex {number?}
* @returns {Promise<number | null>} - The selected index. If the dialog is canceled, return null.
*/
showSelectDialog: (title, options, initialIndex) => {
return sendMessage({
method: 'UI',
function: 'showSelectDialog',
title: title,
options: options,
initialIndex: initialIndex
})
}
}
/**
* App related apis
* @since 1.2.1
*/
let APP = {
/**
* Get the app version
* @returns {string} - The app version
*/
get version() {
return appVersion // defined in the engine
},
/**
* Get current app locale
* @returns {string} - The app locale, in the format of [languageCode]_[countryCode]
*/
get locale() {
return sendMessage({
method: 'getLocale'
})
},
/**
* Get current running platform
* @returns {string} - The platform name, "android", "ios", "windows", "macos", "linux"
*/
get platform() {
return sendMessage({
method: 'getPlatform'
})
}
}

View File

@@ -18,7 +18,7 @@
"help": "帮助",
"Select": "选择",
"Selected @a comics": "已选择 @a 部漫画",
"Imported @a comics": "已导入 @a 部漫画",
"Imported @a comics, loaded @b pages, received @c comics": "已导入 @a 部漫画, 加载 @b 页, 接收到 @c 部漫画",
"Downloading": "下载中",
"Back": "后退",
"Delete": "删除",
@@ -41,6 +41,7 @@
"Select a folder": "选择一个文件夹",
"Folder": "文件夹",
"Confirm": "确认",
"Reversed successfully": "反转成功",
"Remove comic from favorite?": "从收藏中移除漫画?",
"Move": "移动",
"Move to folder": "移动到文件夹",
@@ -147,14 +148,9 @@
"Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
"Help": "帮助",
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有当目录满足以下条件之一时,才被视为漫画:",
"1. The directory only contains image files." : "1. 目录只包含图片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n",
"Export as cbz": "导出为cbz",
"Select a cbz/zip file." : "选择一个cbz/zip文件",
"A cbz file" : "一个cbz文件",
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一个归档文件",
"Fullscreen": "全屏",
"Exit": "退出",
"View more": "查看更多",
@@ -164,7 +160,7 @@
"Date Desc": "日期降序",
"Start": "开始",
"Export App Data": "导出应用数据",
"Import App Data": "导入应用数据",
"Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)",
"Export": "导出",
"Download Threads": "下载线程数",
"Update Time": "更新时间",
@@ -248,6 +244,47 @@
"Export as pdf": "导出为pdf",
"Export as epub": "导出为epub",
"Aggregated Search": "聚合搜索",
"Local comic collection is not supported at present": "本地收藏暂不支持",
"The cover cannot be uncollected here": "封面不能在此取消收藏",
"Uncollected the image": "取消收藏图片",
"Successfully collected": "收藏成功",
"Collect the image": "收藏图片",
"Quick collect image": "快速收藏图片",
"Not enable": "不启用",
"Double Tap": "双击",
"Swipe": "滑动",
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在图片浏览页面, 你可以根据你的阅读模式横滑或者竖滑快速收藏图片",
"Calculate your favorite from @a comics and @b images": "从 @a 本漫画和 @b 张图片中, 计算你最喜欢的",
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括号后是图片数量或图片数比漫画页数",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫画的章节顺序可能发生了变化, 暂不支持收藏此章节",
"Author: ": "作者: ",
"Tags: ": "标签: ",
"Comics(number): ": "漫画(数量): ",
"Comics(percentage): ": "漫画(比例): ",
"Time Filter": "时间筛选",
"Image Favorites Greater Than": "图片收藏数大于",
"Collection time": "收藏时间",
"favoritesCompareComicPages": "收藏数与漫画页数比较",
"Cover": "封面",
"Page @a": "第 @a 页",
"Time Asc": "时间升序",
"Time Desc": "时间降序",
"Favorite Num": "收藏数",
"Favorite Num Compare Comic Pages": "收藏数比漫画页数",
"All": "全部",
"Last Week": "上周",
"Last Month": "上月",
"Last Half Year": "半年",
"Last Year": "一年",
"Filter": "筛选",
"Image Favorites": "图片收藏",
"Title": "标题",
"@a Cover": "@a 封面",
"Photo View": "图片浏览",
"Delete @a images": "删除 @a 张图片",
"Update the page number by the latest collection": "按最新收藏更新页数",
"Copy the title successfully": "复制标题成功",
"The comic is invalid, please long press to delete, you can double click the title to copy": "该漫画已失效, 请长按删除, 可以双击标题进行复制",
"No search results found": "未找到搜索结果",
"Added @c comics to download queue." : "已添加 @c 本漫画到下载队列",
"Download started": "下载已开始",
@@ -255,8 +292,8 @@
"End": "末尾",
"None": "无",
"View Detail": "查看详情",
"Select a directory which contains multiple cbz/zip files." : "选择一个包含多个cbz/zip文件的目录",
"Multiple cbz files" : "多个cbz文件",
"Select a directory which contains multiple archive files." : "选择一个包含多个归档文件的目录",
"Multiple archive files" : "多个归档文件",
"No valid comics found" : "未找到有效的漫画",
"Enable DNS Overrides": "启用DNS覆写",
"DNS Overrides": "DNS覆写",
@@ -265,7 +302,19 @@
"Aggregated": "聚合",
"Default Search Target": "默认搜索目标",
"Auto Language Filters": "自动语言筛选",
"Check for updates on startup": "启动时检查更新"
"Check for updates on startup": "启动时检查更新",
"Start Time": "开始时间",
"End Time": "结束时间",
"Custom": "自定义",
"Reset": "重置",
"Tags": "标签",
"Authors": "作者",
"Comics": "漫画",
"Imported @a comics": "已导入 @a 本漫画",
"New Version": "新版本",
"@c updates": "@c 项更新",
"No updates": "无更新",
"Set comic source list url": "设置漫画源列表URL"
},
"zh_TW": {
"Home": "首頁",
@@ -287,7 +336,7 @@
"help": "幫助",
"Select": "選擇",
"Selected @a comics": "已選擇 @a 部漫畫",
"Imported @a comics": "已匯入 @a 部漫畫",
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 加載 @b 頁, 接收到 @c 部漫畫",
"Downloading": "下載中",
"Back": "後退",
"Delete": "刪除",
@@ -415,14 +464,9 @@
"Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
"Help": "幫助",
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有當目錄滿足以下條件之一時,才被視為漫畫:",
"1. The directory only contains image files." : "1. 目錄只包含圖片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n",
"Export as cbz": "匯出為cbz",
"Select a cbz/zip file." : "選擇一個cbz/zip文件",
"A cbz file" : "一個cbz文件",
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一個歸檔文件",
"Fullscreen": "全螢幕",
"Exit": "退出",
"View more": "查看更多",
@@ -431,8 +475,9 @@
"Date": "日期",
"Date Desc": "日期降序",
"Start": "開始",
"Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據",
"Import App Data": "匯入應用數據",
"Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)",
"Export": "匯出",
"Download Threads": "下載線程數",
"Update Time": "更新時間",
@@ -520,11 +565,52 @@
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載隊列",
"Download started": "下載已開始",
"Click favorite": "點擊收藏",
"Local comic collection is not supported at present": "本地收藏暫不支持",
"The cover cannot be uncollected here": "封面不能在此取消收藏",
"Uncollected the image": "取消收藏圖片",
"Successfully collected": "收藏成功",
"Collect the image": "收藏圖片",
"Quick collect image": "快速收藏圖片",
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在圖片瀏覽頁面, 你可以根據你的閱讀模式橫向或者縱向滑動快速收藏圖片",
"Calculate your favorite from @a comics and @b images": "從 @a 本漫畫和 @b 張圖片中, 計算你最喜歡的",
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括號後是圖片數量或圖片數比漫畫頁數",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支持收藏此章節",
"Author: ": "作者: ",
"Tags: ": "標籤: ",
"Comics(number): ": "漫畫(數量): ",
"Comics(percentage): ": "漫畫(比例): ",
"Time Filter": "時間篩選",
"Image Favorites Greater Than": "圖片收藏數大於",
"Collection time": "收藏時間",
"Not enable": "不启用",
"Double Tap": "雙擊",
"Swipe": "滑動",
"favoritesCompareComicPages": "收藏數與漫畫頁數比較",
"Cover": "封面",
"Page @a": "第 @a 頁",
"Time Asc": "時間升序",
"Time Desc": "時間降序",
"Favorite Num": "收藏數",
"Favorite Num Compare Comic Pages": "收藏數比漫畫頁數",
"All": "全部",
"Last Week": "上周",
"Last Month": "上月",
"Last Half Year": "半年",
"Last Year": "一年",
"Filter": "篩選",
"Image Favorites": "圖片收藏",
"Title": "標題",
"@a Cover": "@a 封面",
"Photo View": "圖片瀏覽",
"Delete @a images": "刪除 @a 張圖片",
"Update the page number by the latest collection": "按最新收藏更新頁數",
"Copy the title successfully": "複製標題成功",
"The comic is invalid, please long press to delete, you can double click the title to copy": "該漫畫已失效, 請長按刪除, 可以雙擊標題進行複製",
"End": "末尾",
"None": "無",
"View Detail": "查看詳情",
"Select a directory which contains multiple cbz/zip files." : "選擇一個包含多個cbz/zip文件的目錄",
"Multiple cbz files" : "多個cbz文件",
"Select a directory which contains multiple archive files." : "選擇一個包含多個歸檔文件的目錄",
"Multiple archive files" : "多個歸檔文件",
"No valid comics found" : "未找到有效的漫畫",
"Enable DNS Overrides": "啟用DNS覆寫",
"DNS Overrides": "DNS覆寫",
@@ -533,6 +619,18 @@
"Aggregated": "聚合",
"Default Search Target": "默認搜索目標",
"Auto Language Filters": "自動語言篩選",
"Check for updates on startup": "啟動時檢查更新"
"Check for updates on startup": "啟動時檢查更新",
"Start Time": "開始時間",
"End Time": "結束時間",
"Custom": "自定義",
"Reset": "重置",
"Tags": "標籤",
"Authors": "作者",
"Comics": "漫畫",
"Imported @a comics": "已匯入 @a 部漫畫",
"New Version": "新版本",
"@c updates": "@c 項更新",
"No updates": "無更新",
"Set comic source list url": "設置漫畫源列表URL"
}
}

655
doc/comic_source.md Normal file
View File

@@ -0,0 +1,655 @@
# Comic Source
## Introduction
Venera is a comic reader that can read comics from various sources.
All comic sources are written in javascript.
Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine which is forked from [ekibun](https://github.com/ekibun/flutter_qjs).
This document will describe how to write a comic source for Venera.
## Preparation
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
- An editor that supports javascript.
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
## Start Writing
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
Here is a brief introduction to the template:
> Note: Javascript api document is [here](js_api.md).
### Write basic information
```javascript
class NewComicSource extends ComicSource {
// Note: The fields which are marked as [Optional] should be removed if not used
// name of the source
name = ""
// unique id of the source
key = ""
version = "1.0.0"
minAppVersion = "1.0.0"
// update url
url = ""
// ...
}
```
In this part, you need to do the following:
- Change the class name to your source name.
- Fill in the name, key, version, minAppVersion, and url fields.
### init function
```javascript
/**
* [Optional] init function
*/
init() {
}
```
The function will be called when the source is initialized. You can do some initialization work here.
Remove this function if not used.
### Account
```javascript
// [Optional] account related
account = {
/**
* [Optional] login with account and password, return any value to indicate success
* @param account {string}
* @param pwd {string}
* @returns {Promise<any>}
*/
login: async (account, pwd) => {
},
/**
* [Optional] login with webview
*/
loginWithWebview: {
url: "",
/**
* check login status
* @param url {string} - current url
* @param title {string} - current title
* @returns {boolean} - return true if login success
*/
checkStatus: (url, title) => {
},
/**
* [Optional] Callback when login success
*/
onLoginSuccess: () => {
},
},
/**
* [Optional] login with cookies
* Note: If `this.account.login` is implemented, this will be ignored
*/
loginWithCookies: {
fields: [
"ipb_member_id",
"ipb_pass_hash",
"igneous",
"star",
],
/**
* Validate cookies, return false if cookies are invalid.
*
* Use `Network.setCookies` to set cookies before validate.
* @param values {string[]} - same order as `fields`
* @returns {Promise<boolean>}
*/
validate: async (values) => {
},
},
/**
* logout function, clear account related data
*/
logout: () => {
},
// {string?} - register url
registerWebsite: null
}
```
In this part, you can implement login, logout, and register functions.
Remove this part if not used.
### Explore page
```javascript
// explore page list
explore = [
{
// title of the page.
// title is used to identify the page, it should be unique
title: "",
/// multiPartPage or multiPageComicList or mixed
type: "multiPartPage",
/**
* load function
* @param page {number | null} - page number, null for `singlePageWithMultiPart` type
* @returns {{}}
* - for `multiPartPage` type, return {title: string, comics: Comic[], viewMore: string?}[]
* - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number}
* - for `mixed` type, use param `page` as index. for each index(0-based), return {data: [], maxPage: number?}, data is an array contains Comic[] or {title: string, comics: Comic[], viewMore: string?}
*/
load: async (page) => {
},
/**
* Only use for `multiPageComicList` type.
* `loadNext` would be ignored if `load` function is implemented.
* @param next {string | null} - next page token, null if first page
* @returns {Promise<{comics: Comic[], next: string?}>} - next is null if no next page.
*/
loadNext(next) {},
}
]
```
In this part, you can implement the explore page.
A comic source can have multiple explore pages.
There are three types of explore pages:
- multiPartPage: An explore page contains multiple parts, each part contains multiple comics.
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
### Category Page
```javascript
// categories
category = {
/// title of the category page, used to identify the page, it should be unique
title: "",
parts: [
{
// title of the part
name: "Theme",
// fixed or random
// if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time
type: "fixed",
// number of comics to display at the same time
// randomNumber: 5,
categories: ["All", "Adventure", "School"],
// category or search
// if `category`, use categoryComics.load to load comics
// if `search`, use search.load to load comics
itemType: "category",
// [Optional] {string[]?} must have same length as categories, used to provide loading param for each category
categoryParams: ["all", "adventure", "school"],
// [Optional] {string} cannot be used with `categoryParams`, set all category params to this value
groupParam: null,
}
],
// enable ranking page
enableRankingPage: false,
}
```
Category page is a static page that contains multiple parts, each part contains multiple categories.
A comic source can only have one category page.
### Category Comics Page
```javascript
/// category comic loading related
categoryComics = {
/**
* load comics of a category
* @param category {string} - category name
* @param param {string?} - category param
* @param options {string[]} - options from optionList
* @param page {number} - page number
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
load: async (category, param, options, page) => {
},
// provide options for category comic loading
optionList: [
{
// For a single option, use `-` to separate the value and text, left for value, right for text
options: [
"newToOld-New to Old",
"oldToNew-Old to New"
],
// [Optional] {string[]} - show this option only when the value not in the list
notShowWhen: null,
// [Optional] {string[]} - show this option only when the value in the list
showWhen: null
}
],
ranking: {
// For a single option, use `-` to separate the value and text, left for value, right for text
options: [
"day-Day",
"week-Week"
],
/**
* load ranking comics
* @param option {string} - option from optionList
* @param page {number} - page number
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
load: async (option, page) => {
}
}
}
```
When user clicks on a category, the category comics page will be displayed.
This part is used to load comics of a category.
### Search
```javascript
/// search related
search = {
/**
* load search result
* @param keyword {string}
* @param options {(string | null)[]} - options from optionList
* @param page {number}
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
load: async (keyword, options, page) => {
},
/**
* load search result with next page token.
* The field will be ignored if `load` function is implemented.
* @param keyword {string}
* @param options {(string)[]} - options from optionList
* @param next {string | null}
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
loadNext: async (keyword, options, next) => {
},
// provide options for search
optionList: [
{
// [Optional] default is `select`
// type: select, multi-select, dropdown
// For select, there is only one selected value
// For multi-select, there are multiple selected values or none. The `load` function will receive a json string which is an array of selected values
// For dropdown, there is one selected value at most. If no selected value, the `load` function will receive a null
type: "select",
// For a single option, use `-` to separate the value and text, left for value, right for text
options: [
"0-time",
"1-popular"
],
// option label
label: "sort",
// default selected options
default: null,
}
],
// enable tags suggestions
enableTagsSuggestions: false,
}
```
This part is used to load search results.
`load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored.
### Favorites
```javascript
// favorite related
favorites = {
// whether support multi folders
multiFolder: false,
/**
* add or delete favorite.
* throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite
* @param comicId {string}
* @param folderId {string}
* @param isAdding {boolean} - true for add, false for delete
* @param favoriteId {string?} - [Comic.favoriteId]
* @returns {Promise<any>} - return any value to indicate success
*/
addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => {
},
/**
* load favorite folders.
* throw `Login expired` to indicate login expired, App will automatically re-login retry.
* if comicId is not null, return favorite folders which contains the comic.
* @param comicId {string?}
* @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic
*/
loadFolders: async (comicId) => {
},
/**
* add a folder
* @param name {string}
* @returns {Promise<any>} - return any value to indicate success
*/
addFolder: async (name) => {
},
/**
* delete a folder
* @param folderId {string}
* @returns {Promise<void>} - return any value to indicate success
*/
deleteFolder: async (folderId) => {
},
/**
* load comics in a folder
* throw `Login expired` to indicate login expired, App will automatically re-login retry.
* @param page {number}
* @param folder {string?} - folder id, null for non-multi-folder
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
loadComics: async (page, folder) => {
},
/**
* load comics with next page token
* @param next {string | null} - next page token, null for first page
* @param folder {string}
* @returns {Promise<{comics: Comic[], next: string?}>}
*/
loadNext: async (next, folder) => {
},
}
```
This part is used to manage network favorites of the source.
`load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored.
### Comic Details
```javascript
/// single comic related
comic = {
/**
* load comic info
* @param id {string}
* @returns {Promise<ComicDetails>}
*/
loadInfo: async (id) => {
},
/**
* [Optional] load thumbnails of a comic
*
* To render a part of an image as thumbnail, return `${url}@x=${start}-${end}&y=${start}-${end}`
* - If width is not provided, use full width
* - If height is not provided, use full height
* @param id {string}
* @param next {string?} - next page token, null for first page
* @returns {Promise<{thumbnails: string[], next: string?}>} - `next` is next page token, null for no more
*/
loadThumbnails: async (id, next) => {
},
/**
* rate a comic
* @param id
* @param rating {number} - [0-10] app use 5 stars, 1 rating = 0.5 stars,
* @returns {Promise<any>} - return any value to indicate success
*/
starRating: async (id, rating) => {
},
/**
* load images of a chapter
* @param comicId {string}
* @param epId {string?}
* @returns {Promise<{images: string[]}>}
*/
loadEp: async (comicId, epId) => {
},
/**
* [Optional] provide configs for an image loading
* @param url
* @param comicId
* @param epId
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
*/
onImageLoad: (url, comicId, epId) => {
return {}
},
/**
* [Optional] provide configs for a thumbnail loading
* @param url {string}
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
*
* `ImageLoadingConfig.modifyImage` and `ImageLoadingConfig.onLoadFailed` will be ignored.
* They are not supported for thumbnails.
*/
onThumbnailLoad: (url) => {
return {}
},
/**
* [Optional] like or unlike a comic
* @param id {string}
* @param isLike {boolean} - true for like, false for unlike
* @returns {Promise<void>}
*/
likeComic: async (id, isLike) => {
},
/**
* [Optional] load comments
*
* Since app version 1.0.6, rich text is supported in comments.
* Following html tags are supported: ['a', 'b', 'i', 'u', 's', 'br', 'span', 'img'].
* span tag supports style attribute, but only support font-weight, font-style, text-decoration.
* All images will be placed at the end of the comment.
* Auto link detection is enabled, but only http/https links are supported.
* @param comicId {string}
* @param subId {string?} - ComicDetails.subId
* @param page {number}
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
* @returns {Promise<{comments: Comment[], maxPage: number?}>}
*/
loadComments: async (comicId, subId, page, replyTo) => {
},
/**
* [Optional] send a comment, return any value to indicate success
* @param comicId {string}
* @param subId {string?} - ComicDetails.subId
* @param content {string}
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
* @returns {Promise<any>}
*/
sendComment: async (comicId, subId, content, replyTo) => {
},
/**
* [Optional] like or unlike a comment
* @param comicId {string}
* @param subId {string?} - ComicDetails.subId
* @param commentId {string}
* @param isLike {boolean} - true for like, false for unlike
* @returns {Promise<void>}
*/
likeComment: async (comicId, subId, commentId, isLike) => {
},
/**
* [Optional] vote a comment
* @param id {string} - comicId
* @param subId {string?} - ComicDetails.subId
* @param commentId {string} - commentId
* @param isUp {boolean} - true for up, false for down
* @param isCancel {boolean} - true for cancel, false for vote
* @returns {Promise<number>} - new score
*/
voteComment: async (id, subId, commentId, isUp, isCancel) => {
},
// {string?} - regex string, used to identify comic id from user input
idMatch: null,
/**
* [Optional] Handle tag click event
* @param namespace {string}
* @param tag {string}
* @returns {{action: string, keyword: string, param: string?}}
*/
onClickTag: (namespace, tag) => {
},
/**
* [Optional] Handle links
*/
link: {
/**
* set accepted domains
*/
domains: [
'example.com'
],
/**
* parse url to comic id
* @param url {string}
* @returns {string | null}
*/
linkToId: (url) => {
}
},
// enable tags translate
enableTagsTranslate: false,
}
```
This part is used to load comic details.
### Settings
```javascript
/*
[Optional] settings related
Use this.loadSetting to load setting
```
let setting1Value = this.loadSetting('setting1')
console.log(setting1Value)
```
*/
settings = {
setting1: {
// title
title: "Setting1",
// type: input, select, switch
type: "select",
// options
options: [
{
// value
value: 'o1',
// [Optional] text, if not set, use value as text
text: 'Option 1',
},
],
default: 'o1',
},
setting2: {
title: "Setting2",
type: "switch",
default: true,
},
setting3: {
title: "Setting3",
type: "input",
validator: null, // string | null, regex string
default: '',
},
setting4: {
title: "Setting4",
type: "callback",
buttonText: "Click me",
/**
* callback function
*
* If the callback function returns a Promise, the button will show a loading indicator until the promise is resolved.
* @returns {void | Promise<any>}
*/
callback: () => {
// do something
}
}
}
```
This part is used to provide settings for the source.
### Translations
```javascript
// [Optional] translations for the strings in this config
translation = {
'zh_CN': {
'Setting1': '设置1',
'Setting2': '设置2',
'Setting3': '设置3',
},
'zh_TW': {},
'en': {}
}
```
This part is used to provide translations for the source.
> Note: strings in the UI api will not be translated automatically. You need to translate them manually.

61
doc/import_comic.md Normal file
View File

@@ -0,0 +1,61 @@
# Import Comic
## Introduction
Venera supports importing comics from local files.
However, the comic files must be in a specific format.
## Comic Directory
A directory considered as a comic directory only if it follows one of the following two types of structure:
**Without Chapter**
```
comic_directory
├── cover.[ext]
├── img1.[ext]
├── img2.[ext]
├── img3.[ext]
├── ...
```
**With Chapter**
```
comic_directory
├── cover.[ext]
├── chapter1
│ ├── img1.[ext]
│ ├── img2.[ext]
│ ├── img3.[ext]
│ ├── ...
├── chapter2
│ ├── img1.[ext]
│ ├── img2.[ext]
│ ├── img3.[ext]
│ ├── ...
├── ...
```
The file name can be anything, but the extension must be a valid image extension.
The page order is determined by the file name. App will sort the files by name and display them in that order.
Cover image is optional.
If there is a file named `cover.[ext]` in the directory, it will be considered as the cover image.
Otherwise, the first image will be considered as the cover image.
The name of directory will be used as comic title. And the name of chapter directory will be used as chapter title.
## Archive
Venera supports importing comics from archive files.
The archive file must follow [Comic Book Archive](https://en.wikipedia.org/wiki/Comic_book_archive_file) format.
Currently, Venera supports the following archive formats:
- `.cbz`
- `.cb7`
- `.zip`
- `.7z`

513
doc/js_api.md Normal file
View File

@@ -0,0 +1,513 @@
# Javascript API
## Overview
The Javascript API is a set of functions that used to interact application.
There are following parts in the API:
- [Convert](#Convert)
- [Network](#Network)
- [Html](#Html)
- [UI](#UI)
- [Utils](#Utils)
- [Types](#Types)
## Convert
Convert is a set of functions that used to convert data between different types.
### `Convert.encodeUtf8(str: string): ArrayBuffer`
Convert a string to an ArrayBuffer.
### `Convert.decodeUtf8(value: ArrayBuffer): string`
Convert an ArrayBuffer to a string.
### `Convert.encodeBase64(value: ArrayBuffer): string`
Convert an ArrayBuffer to a base64 string.
### `Convert.decodeBase64(value: string): ArrayBuffer`
Convert a base64 string to an ArrayBuffer.
### `Convert.md5(value: ArrayBuffer): ArrayBuffer`
Calculate the md5 hash of an ArrayBuffer.
### `Convert.sha1(value: ArrayBuffer): ArrayBuffer`
Calculate the sha1 hash of an ArrayBuffer.
### `Convert.sha256(value: ArrayBuffer): ArrayBuffer`
Calculate the sha256 hash of an ArrayBuffer.
### `Convert.sha512(value: ArrayBuffer): ArrayBuffer`
Calculate the sha512 hash of an ArrayBuffer.
### `Convert.hmac(key: ArrayBuffer, value: ArrayBuffer, hash: string): ArrayBuffer`
Calculate the hmac hash of an ArrayBuffer.
### `Convert.hmacString(key: ArrayBuffer, value: ArrayBuffer, hash: string): string`
Calculate the hmac hash of an ArrayBuffer and return a string.
### `Convert.decryptAesEcb(value: ArrayBuffer, key: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with AES ECB mode.
### `Convert.decryptAesCbc(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with AES CBC mode.
### `Convert.decryptAesCfb(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with AES CFB mode.
### `Convert.decryptAesOfb(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with AES OFB mode.
### `Convert.decryptRsa(value: ArrayBuffer, key: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with RSA.
### `Convert.hexEncode(value: ArrayBuffer): string`
Convert an ArrayBuffer to a hex string.
## Network
Network is a set of functions that used to send network requests and manage network resources.
### `Network.fetchBytes(method: string, url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: ArrayBuffer}>`
Send a network request and return the response as an ArrayBuffer.
### `Network.sendRequest(method: string, url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
Send a network request and return the response as a string.
### `Network.get(url: string, headers: object): Promise<{status: number, headers: object, body: string}>`
Send a GET request and return the response as a string.
### `Network.post(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
Send a POST request and return the response as a string.
### `Network.put(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
Send a PUT request and return the response as a string.
### `Network.delete(url: string, headers: object): Promise<{status: number, headers: object, body: string}>`
Send a DELETE request and return the response as a string.
### `Network.patch(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
Send a PATCH request and return the response as a string.
### `Network.setCookies(url: string, cookies: Cookie[]): void`
Set cookies for a specific url.
### `Network.getCookies(url: string): Cookie[]`
Get cookies for a specific url.
### `Network.deleteCookies(url: string): void`
Delete cookies for a specific url.
### `fetch`
The fetch function is a wrapper of the `Network.fetchBytes` function. Same as the `fetch` function in the browser.
## Html
Api for parsing HTML.
### `new HtmlDocument(html: string): HtmlDocument`
Create a HtmlDocument object from a html string.
### `HtmlDocument.querySelector(selector: string): HtmlElement`
Find the first element that matches the selector.
### `HtmlDocument.querySelectorAll(selector: string): HtmlElement[]`
Find all elements that match the selector.
### `HtmlDocument.getElementById(id: string): HtmlElement`
Find the element with the id.
### `HtmlDocument.dispose(): void`
Dispose the HtmlDocument object.
### `HtmlElement.querySelector(selector: string): HtmlElement`
Find the first element that matches the selector.
### `HtmlElement.querySelectorAll(selector: string): HtmlElement[]`
Find all elements that match the selector.
### `HtmlElement.getElementById(id: string): HtmlElement`
Find the element with the id.
### `get HtmlElement.text(): string`
Get the text content of the element.
### `get HtmlElement.attributes(): object`
Get the attributes of the element.
### `get HtmlElement.children(): HtmlElement[]`
Get the children
### `get HtmlElement.nodes(): HtmlNode[]`
Get the child nodes
### `get HtmlElement.parent(): HtmlElement | null`
Get the parent element
### `get HtmlElement.innerHtml(): string`
Get the inner html
### `get HtmlElement.classNames(): string[]`
Get the class names
### `get HtmlElement.id(): string | null`
Get the id
### `get HtmlElement.localName(): string`
Get the local name
### `get HtmlElement.previousSibling(): HtmlElement | null`
Get the previous sibling
### `get HtmlElement.nextSibling(): HtmlElement | null`
Get the next sibling
### `get HtmlNode.type(): string`
Get the node type ("text", "element", "comment", "document", "unknown")
### `HtmlNode.toElement(): HtmlElement | null`
Convert the node to an element
### `get HtmlNode.text(): string`
Get the text content of the node
## UI
### `UI.showMessage(message: string): void`
Show a message.
### `UI.showDialog(title: string, content: string, actions: {text: string, callback: () => void | Promise<void>, style: "text"|"filled"|"danger"}[]): void`
Show a dialog. Any action will close the dialog.
### `UI.launchUrl(url: string): void`
Open a url in external browser.
### `UI.showLoading(onCancel: () => void | null | undefined): number`
Show a loading dialog.
### `UI.cancelLoading(id: number): void`
Cancel a loading dialog.
### `UI.showInputDialog(title: string, validator: (string) => string | null | undefined): string | null`
Show an input dialog.
### `UI.showSelectDialog(title: string, options: string[], initialIndex?: number): number | null`
Show a select dialog.
## Utils
### `createUuid(): string`
create a time-based uuid.
### `randomInt(min: number, max: number): number`
Generate a random integer between min and max.
### `randomDouble(min: number, max: number): number`
Generate a random double between min and max.
### console
Send log to application console. Same api as the browser console.
## Types
### `Cookie`
```javascript
/**
* Create a cookie object.
* @param name {string}
* @param value {string}
* @param domain {string}
* @constructor
*/
function Cookie({name, value, domain}) {
this.name = name;
this.value = value;
this.domain = domain;
}
```
### `Comic`
```javascript
/**
* Create a comic object
* @param id {string}
* @param title {string}
* @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string}
* @param tags {string[]}
* @param description {string}
* @param maxPage {number?}
* @param language {string?}
* @param favoriteId {string?} - Only set this field if the comic is from favorites page
* @param stars {number?} - 0-5, double
* @constructor
*/
function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage, language, favoriteId, stars}) {
this.id = id;
this.title = title;
this.subtitle = subtitle;
this.subTitle = subTitle;
this.cover = cover;
this.tags = tags;
this.description = description;
this.maxPage = maxPage;
this.language = language;
this.favoriteId = favoriteId;
this.stars = stars;
}
```
### `ComicDetails`
```javascript
/**
* Create a comic details object
* @param title {string}
* @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string}
* @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined}
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null
* @param subId {string?} - a param which is passed to comments api
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[]?} - related comics
* @param commentCount {number?}
* @param likesCount {number?}
* @param isLiked {boolean?}
* @param uploader {string?}
* @param updateTime {string?}
* @param uploadTime {string?}
* @param url {string?}
* @param stars {number?} - 0-5, double
* @param maxPage {number?}
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
* @constructor
*/
function ComicDetails({title, subtitle, subTitle, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
this.title = title;
this.subtitle = subtitle ?? subTitle;
this.cover = cover;
this.description = description;
this.tags = tags;
this.chapters = chapters;
this.isFavorite = isFavorite;
this.subId = subId;
this.thumbnails = thumbnails;
this.recommend = recommend;
this.commentCount = commentCount;
this.likesCount = likesCount;
this.isLiked = isLiked;
this.uploader = uploader;
this.updateTime = updateTime;
this.uploadTime = uploadTime;
this.url = url;
this.stars = stars;
this.maxPage = maxPage;
this.comments = comments;
}
```
### `Comment`
```javascript
/**
* Create a comment object
* @param userName {string}
* @param avatar {string?}
* @param content {string}
* @param time {string?}
* @param replyCount {number?}
* @param id {string?}
* @param isLiked {boolean?}
* @param score {number?}
* @param voteStatus {number?} - 1: upvote, -1: downvote, 0: none
* @constructor
*/
function Comment({userName, avatar, content, time, replyCount, id, isLiked, score, voteStatus}) {
this.userName = userName;
this.avatar = avatar;
this.content = content;
this.time = time;
this.replyCount = replyCount;
this.id = id;
this.isLiked = isLiked;
this.score = score;
this.voteStatus = voteStatus;
}
```
### `ImageLoadingConfig`
```javascript
/**
* Create image loading config
* @param url {string?}
* @param method {string?} - http method, uppercase
* @param data {any} - request data, may be null
* @param headers {Object?} - request headers
* @param onResponse {((ArrayBuffer) => ArrayBuffer)?} - modify response data
* @param modifyImage {string?}
* A js script string.
* The script will be executed in a new Isolate.
* A function named `modifyImage` should be defined in the script, which receives an [Image] as the only argument, and returns an [Image]..
* @param onLoadFailed {(() => ImageLoadingConfig)?} - called when the image loading failed
* @constructor
* @since 1.0.5
*
* To keep the compatibility with the old version, do not use the constructor. Consider creating a new object with the properties directly.
*/
function ImageLoadingConfig({url, method, data, headers, onResponse, modifyImage, onLoadFailed}) {
this.url = url;
this.method = method;
this.data = data;
this.headers = headers;
this.onResponse = onResponse;
this.modifyImage = modifyImage;
this.onLoadFailed = onLoadFailed;
}
```
### `ComicSource`
```javascript
class ComicSource {
name = ""
key = ""
version = ""
minAppVersion = ""
url = ""
/**
* load data with its key
* @param {string} dataKey
* @returns {any}
*/
loadData(dataKey) {
return sendMessage({
method: 'load_data',
key: this.key,
data_key: dataKey
})
}
/**
* load a setting with its key
* @param key {string}
* @returns {any}
*/
loadSetting(key) {
return sendMessage({
method: 'load_setting',
key: this.key,
setting_key: key
})
}
/**
* save data
* @param {string} dataKey
* @param data
*/
saveData(dataKey, data) {
return sendMessage({
method: 'save_data',
key: this.key,
data_key: dataKey,
data: data
})
}
/**
* delete data
* @param {string} dataKey
*/
deleteData(dataKey) {
return sendMessage({
method: 'delete_data',
key: this.key,
data_key: dataKey,
})
}
/**
*
* @returns {boolean}
*/
get isLogged() {
return sendMessage({
method: 'isLogged',
key: this.key,
});
}
init() { }
static sources = {}
}
```

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>
<h2>Features</h2>
<h3>Features</h3>
<ul>
<li>Read local comics</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>Login to comment, rate, and other operations if the source supports</li>
</ul>
<h2>Build from source</h2>
<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>
<h3>Thanks</h3>
<h4>Tags Translation</h4>
<li><a href="https://github.com/EhTagTranslation/Database">github.com/EhTagTranslation/Database</a></li>
<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

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '15.0'
platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

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 {
_SearchBarMixin? _state;

View File

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

View File

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

View File

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

242
lib/components/js_ui.dart Normal file
View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/js_engine.dart';
import 'components.dart';
mixin class JsUiApi {
final Map<int, LoadingDialogController> _loadingDialogControllers = {};
dynamic 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':
return _showDialog(message);
case 'launchUrl':
var url = message['url'];
if (url.toString().isNotEmpty) {
launchUrlString(url.toString());
}
case 'showLoading':
var onCancel = message['onCancel'];
if (onCancel != null && onCancel is! JSInvokable) {
return;
}
return _showLoading(onCancel);
case 'cancelLoading':
var id = message['id'];
if (id is int) {
_cancelLoading(id);
}
case 'showInputDialog':
var title = message['title'];
var validator = message['validator'];
if (title is! String) return;
if (validator != null && validator is! JSInvokable) return;
return _showInputDialog(title, validator);
case 'showSelectDialog':
var title = message['title'];
var options = message['options'];
var initialIndex = message['initialIndex'];
if (title is! String) return;
if (options is! List) return;
if (initialIndex != null && initialIndex is! int) return;
return _showSelectDialog(
title,
options.whereType<String>().toList(),
initialIndex,
);
}
}
Future<void> _showDialog(Map<String, dynamic> message) {
BuildContext? dialogContext;
var title = message['title'];
var content = message['content'];
var actions = <Widget>[];
for (var action in message['actions']) {
if (action['callback'] is! JSInvokable) {
continue;
}
var callback = action['callback'] as JSInvokable;
var text = action['text'].toString();
var style = (action['style'] ?? 'text').toString();
actions.add(_JSCallbackButton(
text: text,
callback: JSAutoFreeFunction(callback),
style: style,
onCallbackFinished: () {
dialogContext?.pop();
},
));
}
if (actions.isEmpty) {
actions.add(TextButton(
onPressed: () {
dialogContext?.pop();
},
child: Text('OK'),
));
}
return showDialog(
context: App.rootContext,
builder: (context) {
dialogContext = context;
return ContentDialog(
title: title,
content: Text(content).paddingHorizontal(16),
actions: actions,
);
},
).then((value) {
dialogContext = null;
});
}
int _showLoading(JSInvokable? onCancel) {
var func = onCancel == null ? null : JSAutoFreeFunction(onCancel);
var controller = showLoadingDialog(
App.rootContext,
barrierDismissible: onCancel != null,
allowCancel: onCancel != null,
onCancel: onCancel == null
? null
: () {
func?.call([]);
},
);
var i = 0;
while (_loadingDialogControllers.containsKey(i)) {
i++;
}
_loadingDialogControllers[i] = controller;
return i;
}
void _cancelLoading(int id) {
var controller = _loadingDialogControllers.remove(id);
controller?.close();
}
Future<String?> _showInputDialog(String title, JSInvokable? validator) async {
String? result;
var func = validator == null ? null : JSAutoFreeFunction(validator);
await showInputDialog(
context: App.rootContext,
title: title,
onConfirm: (v) {
if (func != null) {
var res = func.call([v]);
if (res != null) {
return res.toString();
} else {
result = v;
}
} else {
result = v;
}
return null;
},
);
return result;
}
Future<int?> _showSelectDialog(
String title,
List<String> options,
int? initialIndex,
) {
if (options.isEmpty) {
return Future.value(null);
}
if (initialIndex != null &&
(initialIndex >= options.length || initialIndex < 0)) {
initialIndex = null;
}
return showSelectDialog(
title: title,
options: options,
initialIndex: initialIndex,
);
}
}
class _JSCallbackButton extends StatefulWidget {
const _JSCallbackButton({
required this.text,
required this.callback,
required this.style,
this.onCallbackFinished,
});
final JSAutoFreeFunction callback;
final String text;
final String style;
final void Function()? onCallbackFinished;
@override
State<_JSCallbackButton> createState() => _JSCallbackButtonState();
}
class _JSCallbackButtonState extends State<_JSCallbackButton> {
bool isLoading = false;
void onClick() async {
if (isLoading) {
return;
}
var res = widget.callback.call([]);
if (res is Future) {
setState(() {
isLoading = true;
});
await res;
setState(() {
isLoading = false;
});
}
widget.onCallbackFinished?.call();
}
@override
Widget build(BuildContext context) {
return switch (widget.style) {
"filled" => FilledButton(
onPressed: onClick,
child: isLoading
? CircularProgressIndicator(strokeWidth: 1.4)
.fixWidth(18)
.fixHeight(18)
: Text(widget.text),
),
"danger" => FilledButton(
onPressed: onClick,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(context.colorScheme.error),
),
child: isLoading
? CircularProgressIndicator(strokeWidth: 1.4)
.fixWidth(18)
.fixHeight(18)
: Text(widget.text),
),
_ => TextButton(
onPressed: onClick,
child: isLoading
? CircularProgressIndicator(strokeWidth: 1.4)
.fixWidth(18)
.fixHeight(18)
: Text(widget.text),
),
};
}
}

View File

@@ -5,6 +5,7 @@ void showToast({
required BuildContext context,
Widget? icon,
Widget? trailing,
int? seconds,
}) {
var newEntry = OverlayEntry(
builder: (context) => _ToastOverlay(
@@ -17,7 +18,7 @@ void showToast({
state?.addOverlay(newEntry);
Timer(const Duration(seconds: 2), () => state?.remove(newEntry));
Timer(Duration(seconds: seconds ?? 2), () => state?.remove(newEntry));
}
class _ToastOverlay extends StatelessWidget {
@@ -48,7 +49,8 @@ class _ToastOverlay extends StatelessWidget {
color: Theme.of(context).colorScheme.onInverseSurface),
child: IntrinsicWidth(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
constraints: BoxConstraints(
maxWidth: context.width - 32,
),
@@ -241,13 +243,13 @@ LoadingDialogController showLoadingDialog(BuildContext context,
class ContentDialog extends StatelessWidget {
const ContentDialog({
super.key,
required this.title,
this.title, // 如果不传 title 将不会展示
required this.content,
this.dismissible = true,
this.actions = const [],
});
final String title;
final String? title;
final Widget content;
@@ -261,14 +263,16 @@ class ContentDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title),
backgroundColor: Colors.transparent,
),
title != null
? Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title!),
backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content,
const SizedBox(height: 16),
Row(
@@ -360,7 +364,7 @@ Future<void> showInputDialog({
} else {
result = futureOr;
}
if(result == null) {
if (result == null) {
context.pop();
} else {
setState(() => error = result.toString());
@@ -398,3 +402,59 @@ void showInfoDialog({
},
);
}
Future<int?> showSelectDialog({
required String title,
required List<String> options,
int? initialIndex,
}) async {
int? current = initialIndex;
await showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: title,
content: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Select(
current: current == null ? "" : options[current!],
values: options,
minWidth: 156,
onTap: (i) {
setState(() {
current = i;
});
},
)
],
),
),
actions: [
TextButton(
onPressed: () {
current = null;
context.pop();
},
child: Text('Cancel'.tl),
),
FilledButton(
onPressed: current == null
? null
: context.pop,
child: Text('Confirm'.tl),
),
],
);
},
);
},
);
return current;
}

View File

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

View File

@@ -102,13 +102,36 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
duration: _fastAnimationDuration, curve: Curves.linear);
}
},
child: widget.builder(
context,
_controller,
_isMouseScroll
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
child: ScrollControllerProvider._(
controller: _controller,
child: widget.builder(
context,
_controller,
_isMouseScroll
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
),
),
);
}
}
class ScrollControllerProvider extends InheritedWidget {
const ScrollControllerProvider._({
required this.controller,
required super.child,
});
final ScrollController controller;
static ScrollController of(BuildContext context) {
final ScrollControllerProvider? provider =
context.dependOnInheritedWidgetOfExactType<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";
class _App {
final version = "1.1.4";
final version = "1.2.1";
bool get isAndroid => Platform.isAndroid;

View File

@@ -19,7 +19,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
super.barrierDismissible = false,
this.enableIOSGesture = true,
this.preventRebuild = true,
this.isRootRoute = false,
}) {
assert(opaque);
}
@@ -50,9 +49,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
@override
final bool preventRebuild;
@override
final bool isRootRoute;
}
mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@@ -79,8 +75,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
bool get preventRebuild;
bool get isRootRoute;
Widget? _child;
@override
@@ -121,22 +115,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@override
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(
this,
context,

View File

@@ -85,17 +85,20 @@ class _Appdata {
"proxy",
"authorizationRequired",
"customImageProcessing",
"webdav",
];
/// Sync data from another device
void syncData(Map<String, dynamic> data) {
for (var key in data.keys) {
if (_disableSync.contains(key)) {
continue;
if (data['settings'] is Map) {
var settings = data['settings'] as Map<String, dynamic>;
for (var key in settings.keys) {
if (!_disableSync.contains(key)) {
this.settings[key] = settings[key];
}
}
settings[key] = data[key];
}
searchHistory = List.from(data['searchHistory']);
searchHistory = List.from(data['searchHistory'] ?? []);
saveData();
}
@@ -143,14 +146,16 @@ class _Settings with ChangeNotifier {
'quickFavorite': null,
'enableTurnPageByVolumeKey': true,
'enableClockAndBatteryInfoInReader': true,
'quickCollectImage': 'No', // No, DoubleTap, Swipe
'authorizationRequired': false,
'onClickFavorite': 'viewDetail', // viewDetail, read
'enableDnsOverrides': false,
'dnsOverrides': {},
'enableCustomImageProcessing': false,
'customImageProcessing': _defaultCustomImageProcessing,
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json",
};
operator [](String key) {
@@ -168,15 +173,20 @@ class _Settings with ChangeNotifier {
}
}
const _defaultCustomImageProcessing = '''
const defaultCustomImageProcessing = '''
/**
* Process an image
* @param image {ArayBuffer} - The image to process
* @param image {ArrayBuffer} - The image to process
* @param cid {string} - The comic ID
* @param eid {string} - The episode ID
* @returns {Promise<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;
}
''';
''';

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
part of 'comic_source.dart';
/// return true if ver1 > ver2
bool compareSemVer(String ver1, String ver2) {
ver1 = ver1.replaceFirst("-", ".");
ver2 = ver2.replaceFirst("-", ".");
@@ -193,7 +194,7 @@ class ComicSourceParser {
login = (account, pwd) async {
try {
await JsEngine().runCode("""
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
${jsonEncode(pwd)})
""");
var source = ComicSource.find(_key!)!;
@@ -502,9 +503,9 @@ class ComicSourceParser {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load(
${jsonEncode(category)},
${jsonEncode(param)},
${jsonEncode(options)},
${jsonEncode(category)},
${jsonEncode(param)},
${jsonEncode(options)},
${jsonEncode(page)}
)
""");
@@ -618,6 +619,7 @@ class ComicSourceParser {
if (!_checkExists("favorites")) return null;
final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) {
@@ -770,6 +772,7 @@ class ComicSourceParser {
addFolder: addFolder,
deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc,
isOldToNewSort: isOldToNewSort,
);
}
@@ -920,8 +923,30 @@ class ComicSourceParser {
};
}
Map<String, dynamic> _parseSettings() {
return _getValue("settings") ?? {};
Map<String, Map<String, dynamic>> _parseSettings() {
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() {

View File

@@ -1,6 +1,17 @@
/// If window width is less than this value, it is considered as mobile.
const changePoint = 600;
/// If window width is less than this value, it is considered as tablet.
///
/// If it is more than this value, it is considered as desktop.
const changePoint2 = 1300;
/// Default user agent for http requests.
const webUA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
"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;
bool get isDarkMode => brightness == Brightness.dark;
void showMessage({required String message}) {
showToast(message: message, context: this);
}

View File

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

View File

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

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/material.dart';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/log.dart';
abstract class BaseImageProvider<T extends BaseImageProvider<T>>
extends ImageProvider<T> {
@@ -77,7 +78,13 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
while (data == null && !stop) {
try {
data = await load(chunkEvents);
data = await load(chunkEvents, () {
if (stop) {
throw const _ImageLoadingStopException();
}
});
} on _ImageLoadingStopException {
rethrow;
} catch (e) {
if (e.toString().contains("Invalid Status Code: 404")) {
rethrow;
@@ -99,7 +106,7 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
}
if (stop) {
throw Exception("Image loading is stopped");
throw const _ImageLoadingStopException();
}
if (data!.isEmpty) {
@@ -126,17 +133,23 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
}
rethrow;
}
} catch (e) {
} on _ImageLoadingStopException {
rethrow;
} catch (e, s) {
scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key);
});
Log.error("Image Loading", e, s);
rethrow;
} finally {
chunkEvents.close();
}
}
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents);
Future<Uint8List> load(
StreamController<ImageChunkEvent> chunkEvents,
void Function() checkStop,
);
String get key;
@@ -157,3 +170,7 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
}
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/material.dart';
import 'package:venera/network/images.dart';
@@ -26,9 +26,10 @@ class CachedImageProvider
static const _kMaxLoadingCount = 8;
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
Future<Uint8List> load(chunkEvents, checkStop) async {
while(loadingCount > _kMaxLoadingCount) {
await Future.delayed(const Duration(milliseconds: 100));
checkStop();
}
loadingCount++;
try {
@@ -37,6 +38,7 @@ class CachedImageProvider
return file.readAsBytes();
}
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
checkStop();
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,

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

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

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/material.dart';
import 'package:venera/foundation/app.dart';
@@ -22,13 +22,13 @@ class LocalFavoriteImageProvider
static void delete(String id, int intKey) {
var fileName = (id + intKey.toString()).hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
if(file.existsSync()) {
if (file.existsSync()) {
file.delete();
}
}
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
Future<Uint8List> load(chunkEvents, checkStop) async {
var sourceKey = ComicSource.fromIntKey(intKey)?.key;
var fileName = key.hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
@@ -37,12 +37,14 @@ class LocalFavoriteImageProvider
} else {
await file.create(recursive: true);
}
checkStop();
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
checkStop();
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,
));
if(progress.imageBytes != null) {
if (progress.imageBytes != null) {
var data = progress.imageBytes!;
await file.writeAsBytes(data);
return data;
@@ -52,7 +54,8 @@ class LocalFavoriteImageProvider
}
@override
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) {
Future<LocalFavoriteImageProvider> obtainKey(
ImageConfiguration configuration) {
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/material.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
@@ -12,7 +12,7 @@ import 'package:venera/foundation/appdata.dart';
class ReaderImageProvider
extends BaseImageProvider<image_provider.ReaderImageProvider> {
/// Image provider for normal image.
const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid);
const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid, this.page);
final String imageKey;
@@ -22,8 +22,10 @@ class ReaderImageProvider
final String eid;
final int page;
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
Future<Uint8List> load(chunkEvents, checkStop) async {
Uint8List? imageBytes;
if (imageKey.startsWith('file://')) {
var file = File(imageKey);
@@ -35,6 +37,7 @@ class ReaderImageProvider
} else {
await for (var event
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
checkStop();
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: event.currentBytes,
expectedTotalBytes: event.totalBytes,
@@ -60,14 +63,54 @@ class ReaderImageProvider
})()
''');
if (func is JSInvokable) {
var result = await func.invoke([imageBytes, cid, eid]);
func.free();
var autoFreeFunc = JSAutoFreeFunction(func);
var result = autoFreeFunc([imageBytes, cid, eid, page, sourceKey]);
if (result is Uint8List) {
return result;
imageBytes = result;
} else if (result is Future) {
var futureResult = await result;
if (futureResult is Uint8List) {
imageBytes = futureResult;
}
} else if (result is Map) {
var image = result['image'];
if (image is Uint8List) {
imageBytes = image;
} else if (image is Future) {
JSAutoFreeFunction? onCancel;
if (result['onCancel'] is JSInvokable) {
onCancel = JSAutoFreeFunction(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([]);
rethrow;
}
await Future.delayed(Duration(milliseconds: 50));
}
if (futureImage is Uint8List) {
imageBytes = futureImage;
}
}
}
}
}
}
return imageBytes;
return imageBytes!;
}
@override

View File

@@ -20,6 +20,7 @@ import 'package:pointycastle/block/modes/cfb.dart';
import 'package:pointycastle/block/modes/ecb.dart';
import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart';
import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
@@ -39,7 +40,7 @@ class JavaScriptRuntimeException implements Exception {
}
}
class JsEngine with _JSEngineApi {
class JsEngine with _JSEngineApi, JsUiApi {
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache;
@@ -93,85 +94,71 @@ class JsEngine with _JSEngineApi {
String method = message["method"] as String;
switch (method) {
case "log":
{
String level = message["level"];
Log.addLog(
switch (level) {
"error" => LogLevel.error,
"warning" => LogLevel.warning,
"info" => LogLevel.info,
_ => LogLevel.warning
},
message["title"],
message["content"].toString());
}
String level = message["level"];
Log.addLog(
switch (level) {
"error" => LogLevel.error,
"warning" => LogLevel.warning,
"info" => LogLevel.info,
_ => LogLevel.warning
},
message["title"],
message["content"].toString());
case 'load_data':
{
String key = message["key"];
String dataKey = message["data_key"];
return ComicSource.find(key)?.data[dataKey];
}
String key = message["key"];
String dataKey = message["data_key"];
return ComicSource.find(key)?.data[dataKey];
case 'save_data':
{
String key = message["key"];
String dataKey = message["data_key"];
if (dataKey == 'setting') {
throw "setting is not allowed to be saved";
}
var data = message["data"];
var source = ComicSource.find(key)!;
source.data[dataKey] = data;
source.saveData();
String key = message["key"];
String dataKey = message["data_key"];
if (dataKey == 'setting') {
throw "setting is not allowed to be saved";
}
var data = message["data"];
var source = ComicSource.find(key)!;
source.data[dataKey] = data;
source.saveData();
case 'delete_data':
{
String key = message["key"];
String dataKey = message["data_key"];
var source = ComicSource.find(key);
source?.data.remove(dataKey);
source?.saveData();
}
String key = message["key"];
String dataKey = message["data_key"];
var source = ComicSource.find(key);
source?.data.remove(dataKey);
source?.saveData();
case 'http':
{
return _http(Map.from(message));
}
return _http(Map.from(message));
case 'html':
{
return handleHtmlCallback(Map.from(message));
}
return handleHtmlCallback(Map.from(message));
case 'convert':
{
return _convert(Map.from(message));
}
return _convert(Map.from(message));
case "random":
{
return _random(
message["min"] ?? 0,
message["max"] ?? 1,
message["type"],
);
}
return _random(
message["min"] ?? 0,
message["max"] ?? 1,
message["type"],
);
case "cookie":
{
return handleCookieCallback(Map.from(message));
}
return handleCookieCallback(Map.from(message));
case "uuid":
{
return const Uuid().v1();
}
return const Uuid().v1();
case "load_setting":
{
String key = message["key"];
String settingKey = message["setting_key"];
var source = ComicSource.find(key)!;
return source.data["settings"]?[settingKey] ??
source.settings?[settingKey]['default'] ??
(throw "Setting not found: $settingKey");
}
String key = message["key"];
String settingKey = message["setting_key"];
var source = ComicSource.find(key)!;
return source.data["settings"]?[settingKey] ??
source.settings?[settingKey]!['default'] ??
(throw "Setting not found: $settingKey");
case "isLogged":
{
return ComicSource.find(message["key"])!.isLogged;
}
return ComicSource.find(message["key"])!.isLogged;
// temporary solution for [setTimeout] function
// TODO: implement [setTimeout] in quickjs project
case "delay":
return Future.delayed(Duration(milliseconds: message["time"]));
case "UI":
return handleUIMessage(Map.from(message));
case "getLocale":
return "${App.locale.languageCode}-${App.locale.countryCode}";
case "getPlatform":
return Platform.operatingSystem;
}
}
return null;
@@ -688,3 +675,21 @@ class DocumentWrapper {
return elements.length - 1;
}
}
class JSAutoFreeFunction {
final JSInvokable func;
/// Automatically free the function when it's not used anymore
JSAutoFreeFunction(this.func) {
func.dup();
finalizer.attach(this, func);
}
dynamic call(List<dynamic> args) {
return func(args);
}
static final finalizer = Finalizer<JSInvokable>((func) {
func.destroy();
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -124,18 +124,7 @@ class _ExplorePageState extends State<ExplorePage>
}
return NetworkError(
message: msg,
retry: () {
setState(() {
pages = ComicSource.all()
.map((e) => e.explorePages)
.expand((e) => e.map((e) => e.title))
.toList();
controller = TabController(
length: pages.length,
vsync: this,
);
});
},
retry: onSettingsChanged,
withAppbar: false,
);
}

View File

@@ -147,13 +147,13 @@ Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for(var entry in newInfo.tags.entries) {
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for(var tag in entry.value) {
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
@@ -305,6 +305,7 @@ Future<void> sortFolders() async {
Future<void> importNetworkFolder(
String source,
int updatePageNum,
String? folder,
String? folderID,
) async {
@@ -312,7 +313,7 @@ Future<void> importNetworkFolder(
if (comicSource == null) {
return;
}
if(folder != null && folder.isEmpty) {
if (folder != null && folder.isEmpty) {
folder = null;
}
var resultName = folder ?? comicSource.name;
@@ -324,7 +325,7 @@ Future<void> importNetworkFolder(
return;
}
}
if(!exists) {
if (!exists) {
LocalFavoritesManager().createFolder(resultName);
LocalFavoritesManager().linkFolderToNetwork(
resultName,
@@ -332,37 +333,46 @@ Future<void> importNetworkFolder(
folderID ?? "",
);
}
bool isOldToNewSort = comicSource.favoriteData?.isOldToNewSort ?? false;
var current = 0;
int receivedComics = 0;
int requestCount = 0;
var isFinished = false;
int maxPage = 1;
List<FavoriteItem> comics = [];
String? next;
// 如果是从旧到新, 先取一下maxPage
if (isOldToNewSort) {
var res = await comicSource.favoriteData?.loadComic!(1, folderID);
maxPage = res?.subData ?? 1;
}
Future<void> fetchNext() async {
var retry = 3;
while (true) {
while (updatePageNum > requestCount && !isFinished) {
try {
if (comicSource.favoriteData?.loadComic != null) {
next ??= '1';
// 从旧到新的情况下, 假设有10页, 更新3页, 则从第8页开始, 8, 9, 10 三页
next ??=
isOldToNewSort ? (maxPage - updatePageNum + 1).toString() : '1';
var page = int.parse(next!);
var res = await comicSource.favoriteData!.loadComic!(page, folderID);
var count = 0;
receivedComics += res.data.length;
for (var c in res.data) {
var result = LocalFavoritesManager().addComic(
resultName,
FavoriteItem(
if (!LocalFavoritesManager()
.comicExists(resultName, c.id, ComicType(source.hashCode))) {
count++;
comics.add(FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
),
);
if (result) {
count++;
));
}
}
requestCount++;
current += count;
if (res.data.isEmpty || res.subData == page) {
isFinished = true;
@@ -373,22 +383,22 @@ Future<void> importNetworkFolder(
} else if (comicSource.favoriteData?.loadNext != null) {
var res = await comicSource.favoriteData!.loadNext!(next, folderID);
var count = 0;
receivedComics += res.data.length;
for (var c in res.data) {
var result = LocalFavoritesManager().addComic(
resultName,
FavoriteItem(
if (!LocalFavoritesManager()
.comicExists(resultName, c.id, ComicType(source.hashCode))) {
count++;
comics.add(FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
),
);
if (result) {
count++;
));
}
}
requestCount++;
current += count;
if (res.data.isEmpty || res.subData == null) {
isFinished = true;
@@ -408,6 +418,8 @@ Future<void> importNetworkFolder(
continue;
}
}
// 跳出循环, 表示已经完成, 强制为 true, 避免死循环
isFinished = true;
}
bool isCanceled = false;
@@ -415,6 +427,7 @@ Future<void> importNetworkFolder(
bool isErrored() => errorMsg != null;
void Function()? updateDialog;
void Function()? closeDialog;
showDialog(
context: App.rootContext,
@@ -422,6 +435,7 @@ Future<void> importNetworkFolder(
return StatefulBuilder(
builder: (context, setState) {
updateDialog = () => setState(() {});
closeDialog = () => Navigator.pop(context);
return ContentDialog(
title: isFinished
? "Finished".tl
@@ -437,8 +451,11 @@ Future<void> importNetworkFolder(
value: isFinished ? 1 : null,
),
const SizedBox(height: 4),
Text("Imported @c comics".tlParams({
"c": current,
Text("Imported @a comics, loaded @b pages, received @c comics"
.tlParams({
"a": current,
"b": requestCount,
"c": receivedComics,
})),
const SizedBox(height: 4),
if (isErrored()) Text("Error: $errorMsg"),
@@ -476,4 +493,18 @@ Future<void> importNetworkFolder(
break;
}
}
try {
if (appdata.settings['newFavoriteAddTo'] == "start" && !isOldToNewSort) {
// 如果是插到最前, 并且是从新到旧, 反转一下
comics = comics.reversed.toList();
}
for (var c in comics) {
LocalFavoritesManager().addComic(resultName, c);
}
// 延迟一点, 让用户看清楚到底新增了多少
await Future.delayed(const Duration(milliseconds: 500));
closeDialog?.call();
} catch (e, stackTrace) {
Log.error("Unhandled Exception", e.toString(), stackTrace);
}
}

View File

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

View File

@@ -50,9 +50,16 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a;
networkFolder = b;
LocalFavoritesManager().addListener(updateComics);
super.initState();
}
@override
void dispose() {
super.dispose();
LocalFavoritesManager().removeListener(updateComics);
}
void selectAll() {
setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
@@ -136,17 +143,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
message: "Sync".tl,
child: Flyout(
flyoutBuilder: (context) {
var sourceName = ComicSource.find(networkSource!)?.name ??
networkSource!;
var text = "The folder is Linked to @source".tlParams({
"source": sourceName,
});
if (networkFolder != null && networkFolder!.isNotEmpty) {
text += "\n${"Source Folder".tl}: $networkFolder";
}
final GlobalKey<_SelectUpdatePageNumState>
selectUpdatePageNumKey =
GlobalKey<_SelectUpdatePageNumState>();
var updatePageWidget = _SelectUpdatePageNum(
networkSource: networkSource!,
networkFolder: networkFolder,
key: selectUpdatePageNumKey,
);
return FlyoutContent(
title: "Sync".tl,
content: Text(text),
content: updatePageWidget,
actions: [
Button.filled(
child: Text("Update".tl),
@@ -154,6 +161,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
context.pop();
importNetworkFolder(
networkSource!,
selectUpdatePageNumKey
.currentState!.updatePageNum,
widget.folder,
networkFolder!,
).then(
@@ -380,6 +389,35 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
selections: selectedComics,
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
),
MenuEntry(
icon: Icons.check,
text: "Select".tl,
onClick: () {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
}
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
@@ -655,7 +693,6 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
(c as FavoriteItem).type,
);
}
updateComics();
_cancel();
}
}
@@ -741,6 +778,17 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
);
},
),
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
});
},
),
],
),
body: ReorderableBuilder<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 ContentDialog(
title: "Remove".tl,
content: Text("Remove comic from favorite?".tl)
.paddingHorizontal(16),
content: Text("Remove comic from favorite?".tl).paddingHorizontal(16),
actions: [
Button.filled(
isLoading: loading,
@@ -94,9 +93,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
return ComicList(
key: comicListKey,
leadingSliver: SliverAppbar(
style: context.width < changePoint
? AppbarStyle.shadow
: AppbarStyle.blur,
style:
context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
leading: Tooltip(
message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth
@@ -117,7 +115,7 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
icon: Icons.sync,
text: "Convert to local".tl,
onClick: () {
importNetworkFolder(widget.data.key, null, null);
importNetworkFolder(widget.data.key, 9999999, null, null);
},
)
]),
@@ -215,9 +213,8 @@ class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> {
@override
Widget build(BuildContext context) {
var sliverAppBar = SliverAppbar(
style: context.width < changePoint
? AppbarStyle.shadow
: AppbarStyle.blur,
style:
context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
leading: Tooltip(
message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth
@@ -431,8 +428,7 @@ class _FolderTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Delete".tl,
content: Text("Delete folder?".tl)
.paddingHorizontal(16),
content: Text("Delete folder?".tl).paddingHorizontal(16),
actions: [
Button.filled(
isLoading: loading,
@@ -558,7 +554,7 @@ class _FavoriteFolder extends StatelessWidget {
icon: Icons.sync,
text: "Convert to local".tl,
onClick: () {
importNetworkFolder(data.key, title, folderID);
importNetworkFolder(data.key, 9999999, title, folderID);
},
)
]),

View File

@@ -1,22 +1,24 @@
import 'package:flutter/material.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/image_favorites_page/image_favorites_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/import_comic.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'local_comics_page.dart';
@@ -35,6 +37,7 @@ class HomePage extends StatelessWidget {
const _Local(),
const _ComicSourceWidget(),
const _AccountsWidget(),
const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
],
);
@@ -83,7 +86,8 @@ class _SyncDataWidget extends StatefulWidget {
State<_SyncDataWidget> createState() => _SyncDataWidgetState();
}
class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver {
class _SyncDataWidgetState extends State<_SyncDataWidget>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
@@ -93,7 +97,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
}
void update() {
if(mounted) {
if (mounted) {
setState(() {});
}
}
@@ -110,8 +114,8 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if(state == AppLifecycleState.resumed) {
if(DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) {
if (state == AppLifecycleState.resumed) {
if (DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) {
lastCheck = DateTime.now();
DataSync().downloadData();
}
@@ -121,7 +125,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
@override
Widget build(BuildContext context) {
Widget child;
if(!DataSync().isEnabled) {
if (!DataSync().isEnabled) {
child = const SliverPadding(padding: EdgeInsets.zero);
} else if (DataSync().isUploading || DataSync().isDownloading) {
child = SliverToBoxAdapter(
@@ -159,17 +163,15 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}
),
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}),
IconButton(
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}
),
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}),
],
),
),
@@ -264,8 +266,8 @@ class _HistoryState extends State<_History> {
scrollDirection: Axis.horizontal,
itemCount: history.length,
itemBuilder: (context, index) {
return AnimatedTapRegion(
borderRadius: 8,
return SimpleComicTile(
comic: history[index],
onTap: () {
context.to(
() => ComicPage(
@@ -274,25 +276,7 @@ class _HistoryState extends State<_History> {
),
);
},
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context)
.colorScheme
.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: HistoryImageProvider(history[index]),
width: 96,
height: 128,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
).paddingHorizontal(8);
).paddingHorizontal(8).paddingVertical(2);
},
),
).paddingHorizontal(8).paddingBottom(16),
@@ -385,32 +369,8 @@ class _LocalState extends State<_Local> {
scrollDirection: Axis.horizontal,
itemCount: local.length,
itemBuilder: (context, index) {
return AnimatedTapRegion(
onTap: () {
local[index].read();
},
borderRadius: 8,
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context)
.colorScheme
.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: LocalComicImageProvider(
local[index],
),
width: 96,
height: 128,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
).paddingHorizontal(8);
return SimpleComicTile(comic: local[index])
.paddingHorizontal(8);
},
),
).paddingHorizontal(8),
@@ -494,15 +454,15 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
String info = [
"Select a directory which contains the comic files.".tl,
"Select a directory which contains the comic directories.".tl,
"Select a cbz/zip file.".tl,
"Select a directory which contains multiple cbz/zip files.".tl,
"Select an archive file (cbz, zip, 7z, cb7)".tl,
"Select a directory which contains multiple archive files.".tl,
"Select an EhViewer database and a download folder.".tl
][type];
List<String> importMethods = [
"Single Comic".tl,
"Multiple Comics".tl,
"A cbz file".tl,
"Multiple cbz files".tl,
"An archive file".tl,
"Multiple archive files".tl,
"EhViewer downloads".tl
];
@@ -518,50 +478,50 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
),
)
: Column(
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 600),
...List.generate(importMethods.length, (index) {
return RadioListTile(
title: Text(importMethods[index]),
value: index,
groupValue: type,
onChanged: (value) {
setState(() {
type = value as int;
});
},
);
}),
if(type != 3)
ListTile(
title: Text("Add to favorites".tl),
trailing: Select(
current: selectedFolder,
values: folders,
minWidth: 112,
onTap: (v) {
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 600),
...List.generate(importMethods.length, (index) {
return RadioListTile(
title: Text(importMethods[index]),
value: index,
groupValue: type,
onChanged: (value) {
setState(() {
selectedFolder = folders[v];
type = value as int;
});
},
),
).paddingHorizontal(8),
if(!App.isIOS && !App.isMacOS)
CheckboxListTile(
enabled: true,
title: Text("Copy to app local path".tl),
value: copyToLocalFolder,
onChanged:(v) {
setState(() {
copyToLocalFolder = !copyToLocalFolder;
});
}).paddingHorizontal(8),
const SizedBox(height: 8),
Text(info).paddingHorizontal(24),
],
),
);
}),
if (type != 4)
ListTile(
title: Text("Add to favorites".tl),
trailing: Select(
current: selectedFolder,
values: folders,
minWidth: 112,
onTap: (v) {
setState(() {
selectedFolder = folders[v];
});
},
),
).paddingHorizontal(8),
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
CheckboxListTile(
enabled: true,
title: Text("Copy to app local path".tl),
value: copyToLocalFolder,
onChanged: (v) {
setState(() {
copyToLocalFolder = !copyToLocalFolder;
});
}).paddingHorizontal(8),
const SizedBox(height: 8),
Text(info).paddingHorizontal(24),
],
),
actions: [
Button.text(
child: Row(
@@ -576,36 +536,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
],
),
onPressed: () {
showDialog(
context: context,
barrierColor: Colors.black.toOpacity(0.2),
builder: (context) {
var help = '';
help +=
'${"A directory is considered as a comic only if it matches one of the following conditions:".tl}\n';
help += '${'1. The directory only contains image files.'.tl}\n';
help +=
'${'2. The directory contains directories which contain image files. Each directory is considered as a chapter.'.tl}\n\n';
help +=
'${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n';
help +=
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n"
.tl;
help +="If you import an EhViewer's database, program will automatically create folders according to the download label in that database.".tl;
return ContentDialog(
title: "Help".tl,
content: Text(help).paddingHorizontal(16),
actions: [
Button.filled(
child: Text("OK".tl),
onPressed: () {
context.pop();
},
),
],
);
},
);
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
},
).fixWidth(90).paddingRight(8),
Button.filled(
@@ -624,9 +555,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
loading = true;
});
var importer = ImportComic(
selectedFolder: selectedFolder,
copyToLocal: copyToLocalFolder);
var result = switch(type) {
selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder);
var result = switch (type) {
0 => await importer.directory(true),
1 => await importer.directory(false),
2 => await importer.cbz(),
@@ -634,7 +564,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
4 => await importer.ehViewer(),
int() => true,
};
if(result) {
if (result) {
context.pop();
} else {
setState(() {
@@ -736,6 +666,30 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}).toList(),
).paddingHorizontal(16).paddingBottom(16),
),
if (ComicSource.availableUpdates.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.update, color: context.colorScheme.primary, size: 20,),
const SizedBox(width: 8),
Text("@c updates".tlParams({
'c': ComicSource.availableUpdates.length,
}), style: ts.withColor(context.colorScheme.primary),),
],
),
).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8),
],
),
),
@@ -911,3 +865,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 {
if (!appdata.settings['checkUpdateOnStart']) {
return;
}
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) {
@@ -47,9 +44,11 @@ class _MainPageState extends State<MainPage> {
}
appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData();
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
await ComicSourcePage.checkComicSourceUpdate(true);
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
}
}
@override

View File

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

View File

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

View File

@@ -5,12 +5,18 @@ class ReaderWithLoading extends StatefulWidget {
super.key,
required this.id,
required this.sourceKey,
this.initialEp,
this.initialPage,
});
final String id;
final String sourceKey;
final int? initialEp;
final int? initialPage;
@override
State<ReaderWithLoading> createState() => _ReaderWithLoadingState();
}
@@ -25,8 +31,10 @@ class _ReaderWithLoadingState
name: data.name,
chapters: data.chapters,
history: data.history,
initialChapter: data.history.ep,
initialPage: data.history.page,
initialChapter: widget.initialEp ?? data.history.ep,
initialPage: widget.initialPage ?? data.history.page,
author: data.author,
tags: data.tags,
);
}
@@ -57,6 +65,8 @@ class _ReaderWithLoadingState
ep: 0,
page: 0,
),
author: localComic.subtitle,
tags: localComic.tags,
),
);
} else {
@@ -76,6 +86,8 @@ class _ReaderWithLoadingState
ep: 0,
page: 0,
),
author: comic.data.findAuthor() ?? "",
tags: comic.data.plainTags,
),
);
}
@@ -93,11 +105,17 @@ class ReaderProps {
final History history;
final String author;
final List<String> tags;
const ReaderProps({
required this.type,
required this.cid,
required this.name,
required this.chapters,
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/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart';
@@ -27,8 +29,10 @@ import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'package:venera/utils/volume.dart';
import 'package:window_manager/window_manager.dart';
@@ -57,10 +61,16 @@ class Reader extends StatefulWidget {
required this.history,
this.initialPage,
this.initialChapter,
required this.author,
required this.tags,
});
final ComicType type;
final String author;
final List<String> tags;
final String cid;
final String name;
@@ -114,12 +124,14 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage);
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
}
}
void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) {
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
@@ -138,16 +150,25 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
void initState() {
page = widget.initialPage ?? 1;
chapter = widget.initialChapter ?? 1;
if (page < 1) {
page = 1;
}
if (chapter < 1) {
chapter = 1;
}
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
history = widget.history;
Future.microtask(() {
updateHistory();
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if(appdata.settings['enableTurnPageByVolumeKey']) {
if (appdata.settings['enableTurnPageByVolumeKey']) {
handleVolumeEvent();
}
setImageCacheSize();
Future.delayed(const Duration(milliseconds: 200), () {
LocalFavoritesManager().onRead(cid, type);
});
super.initState();
}
@@ -164,7 +185,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} else {
maxImageCacheSize = 500 << 20;
}
Log.info("Reader", "Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize");
Log.info("Reader",
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize");
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
}
@@ -209,7 +231,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
}
void updateHistory() {
if(history != null) {
if (history != null) {
history!.page = page;
history!.ep = chapter;
if (maxPage > 1) {
@@ -222,11 +244,11 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
}
void handleVolumeEvent() {
if(!App.isAndroid) {
if (!App.isAndroid) {
// Currently only support Android
return;
}
if(volumeListener != null) {
if (volumeListener != null) {
volumeListener?.cancel();
}
volumeListener = VolumeListener(
@@ -240,7 +262,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
}
void stopVolumeEvent() {
if(volumeListener != null) {
if (volumeListener != null) {
volumeListener?.cancel();
volumeListener = null;
}
@@ -300,7 +322,8 @@ abstract mixin class _ReaderLocation {
bool toPage(int page) {
if (_validatePage(page)) {
if (page == this.page) {
if(!(chapter == 1 && page == 1) && !(chapter == maxChapter && page == maxPage)) {
if (!(chapter == 1 && page == 1) &&
!(chapter == maxChapter && page == maxPage)) {
return false;
}
}

View File

@@ -18,8 +18,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
bool get isOpen => _isOpen;
bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft;
bool get isReversed =>
context.reader.mode == ReaderMode.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft;
int showFloatingButtonValue = 0;
@@ -29,6 +30,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
_ReaderGestureDetectorState? _gestureDetectorState;
_DragListener? _floatingButtonDragListener;
void setFloatingButton(int value) {
lastValue = showFloatingButtonValue;
if (value == 0) {
@@ -37,12 +40,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0;
update();
}
_gestureDetectorState!.dragListener = null;
if (_floatingButtonDragListener != null) {
_gestureDetectorState!.removeDragListener(_floatingButtonDragListener!);
_floatingButtonDragListener = null;
}
}
var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1;
_gestureDetectorState!.dragListener = _DragListener(
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy;
@@ -62,10 +68,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update();
} else if (value == -1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = -1;
_gestureDetectorState!.dragListener = _DragListener(
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value += offset.dy;
@@ -85,10 +92,48 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update();
}
}
_DragListener? _imageFavoriteDragListener;
void addDragListener() async {
if (!mounted) return;
var readerMode = context.reader.mode;
// 横向阅读的时候, 如果纵向滑就触发收藏, 纵向阅读的时候, 如果横向滑动就触发收藏
if (appdata.settings['quickCollectImage'] == 'Swipe') {
if (_imageFavoriteDragListener == null) {
double distance = 0;
_imageFavoriteDragListener = _DragListener(
onMove: (offset) {
switch (readerMode) {
case ReaderMode.continuousTopToBottom:
case ReaderMode.galleryTopToBottom:
distance += offset.dx;
case ReaderMode.continuousLeftToRight:
case ReaderMode.galleryLeftToRight:
case ReaderMode.galleryRightToLeft:
case ReaderMode.continuousRightToLeft:
distance += offset.dy;
}
},
onEnd: () {
if (distance.abs() > 150) {
addImageFavorite();
}
distance = 0;
},
);
}
_gestureDetectorState!.addDragListener(_imageFavoriteDragListener!);
} else if (_imageFavoriteDragListener != null) {
_gestureDetectorState!.removeDragListener(_imageFavoriteDragListener!);
}
}
@override
void initState() {
sliderFocus.canRequestFocus = false;
@@ -101,6 +146,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
super.initState();
Future.delayed(const Duration(milliseconds: 200), addDragListener);
}
@override
@@ -203,6 +249,123 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
}
bool isLiked() {
return ImageFavoriteManager().has(
context.reader.cid,
context.reader.type.sourceKey,
context.reader.eid,
context.reader.page,
context.reader.chapter,
);
}
void addImageFavorite() {
try {
if (context.reader.images![0].contains('file://')) {
showToast(
message: "Local comic collection is not supported at present".tl,
context: context);
return;
}
String id = context.reader.cid;
int ep = context.reader.chapter;
String eid = context.reader.eid;
String title = context.reader.history!.title;
String subTitle = context.reader.history!.subtitle;
int maxPage = context.reader.images!.length;
int page = context.reader.page;
String sourceKey = context.reader.type.sourceKey;
String imageKey = context.reader.images![page - 1];
List<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() {
var text = "E${context.reader.chapter} : P${context.reader.page}";
if (context.reader.widget.chapters == null) {
@@ -233,13 +396,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: buildSlider(),
),
IconButton.filledTonal(
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1),
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1),
icon: const Icon(Icons.last_page)),
const SizedBox(
width: 8,
@@ -263,6 +426,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
),
),
const Spacer(),
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(
isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite),
),
if (App.isWindows)
Tooltip(
message: "${"Full Screen".tl}(F12)",
@@ -358,12 +528,14 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.82),
border: Border(
top: BorderSide(
color: Colors.grey.toOpacity(0.5),
width: 0.5,
),
),
border: isOpen
? Border(
top: BorderSide(
color: Colors.grey.toOpacity(0.5),
width: 0.5,
),
)
: null,
),
padding: EdgeInsets.only(bottom: context.padding.bottom),
child: child,
@@ -478,6 +650,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
reader.type.comicSource!.key,
reader.cid,
reader.eid,
reader.page,
);
}
return InkWell(
@@ -559,7 +732,6 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
onChanged: (key) {
if (key == "readerMode") {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
App.rootContext.pop();
}
if (key == "enableTurnPageByVolumeKey") {
if (appdata.settings[key]) {
@@ -568,6 +740,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context.reader.stopVolumeEvent();
}
}
if (key == "quickCollectImage") {
addDragListener();
}
context.reader.update();
},
),

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
@@ -184,7 +185,7 @@ class _SearchPageState extends State<SearchPage> {
duration: const Duration(milliseconds: 200),
child: buildSearchOptions(),
);
yield buildSearchHistory();
yield _SearchHistory(search);
}
}
@@ -228,6 +229,11 @@ class _SearchPageState extends State<SearchPage> {
onChanged: (value) {
setState(() {
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) {
bool check(String text, String key, String value) {
if (text.removeAllBlank == "") {
@@ -577,3 +511,130 @@ class SearchOptionWidget extends StatelessWidget {
);
}
}
class _SearchHistory extends StatefulWidget {
const _SearchHistory(this.search);
final void Function(String) search;
@override
State<_SearchHistory> createState() => _SearchHistoryState();
}
class _SearchHistoryState extends State<_SearchHistory> {
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
return const SizedBox(
height: 16,
);
}
if (index == 1) {
return ListTile(
leading: const Icon(Icons.history),
contentPadding: EdgeInsets.zero,
title: Text("Search History".tl),
trailing: Flyout(
flyoutBuilder: (context) {
return FlyoutContent(
title: "Clear Search History".tl,
actions: [
FilledButton(
child: Text("Clear".tl),
onPressed: () {
appdata.clearSearchHistory();
context.pop();
setState(() {});
},
)
],
);
},
child: Builder(
builder: (context) {
return Tooltip(
message: "Clear".tl,
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
context
.findAncestorStateOfType<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,15 +107,16 @@ class _AppSettingsState extends State<AppSettings> {
actionTitle: 'Export'.tl,
).toSliver(),
_CallbackSetting(
title: "Import App Data".tl,
title: "Import App Data (Please restart after success)".tl,
callback: () async {
var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera', 'picadata']);
if (file != null) {
var cacheFile = File(FilePath.join(App.cachePath, "import_data_temp"));
var cacheFile =
File(FilePath.join(App.cachePath, "import_data_temp"));
await file.saveTo(cacheFile.path);
try {
if(file.name.endsWith('picadata')) {
if (file.name.endsWith('picadata')) {
await importPicaData(cacheFile);
} else {
await importAppData(cacheFile);
@@ -123,8 +124,7 @@ class _AppSettingsState extends State<AppSettings> {
} catch (e, s) {
Log.error("Import data", e.toString(), s);
context.showMessage(message: "Failed to import data".tl);
}
finally {
} finally {
cacheFile.deleteIgnoreError();
}
}

View File

@@ -33,7 +33,9 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
SelectSetting(
title: "Quick Favorite".tl,
settingKey: "quickFavorite",
help: "Long press on the favorite button to quickly add to this folder".tl,
help:
"Long press on the favorite button to quickly add to this folder"
.tl,
optionTranslation: {
for (var e in LocalFavoritesManager().folderNames) e: e
},
@@ -44,7 +46,8 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
var controller = showLoadingDialog(context);
var count = await LocalFavoritesManager().removeInvalid();
controller.close();
context.showMessage(message: "Deleted @a favorite items".tlParams({'a': count}));
context.showMessage(
message: "Deleted @a favorite items".tlParams({'a': count}));
},
actionTitle: 'Delete'.tl,
).toSliver(),

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/ext.dart';
import 'package:zip_flutter/zip_flutter.dart';
import 'io.dart';
@@ -128,7 +129,24 @@ Future<void> importPicaData(File file) async {
.select("SELECT name FROM sqlite_master WHERE type='table';")
.map((e) => e["name"] as String)
.toList();
folderNames.removeWhere((e) => e == "folder_order" || e == "folder_sync");
folderNames
.removeWhere((e) => e == "folder_order" || e == "folder_sync");
for (var folderSyncValue in db.select("SELECT * FROM folder_sync;")) {
var folderName = folderSyncValue["folder_name"];
String sourceKey = folderSyncValue["key"];
sourceKey =
sourceKey.toLowerCase() == "htmanga" ? "wnacg" : sourceKey;
// 有值就跳过
if (LocalFavoritesManager().findLinked(folderName).$1 != null) {
continue;
}
try {
LocalFavoritesManager().linkFolderToNetwork(folderName, sourceKey,
jsonDecode(folderSyncValue["sync_data"])["folderId"]);
} catch (e, stack) {
Log.error(e.toString(), stack);
}
}
for (var folderName in folderNames) {
if (!LocalFavoritesManager().existsFolder(folderName)) {
LocalFavoritesManager().createFolder(folderName);
@@ -141,7 +159,7 @@ Future<void> importPicaData(File file) async {
name: comic['name'],
coverPath: comic['cover_path'],
author: comic['author'],
type: ComicType(switch(comic['type']) {
type: ComicType(switch (comic['type']) {
0 => 'picacg'.hashCode,
1 => 'ehentai'.hashCode,
2 => 'jm'.hashCode,
@@ -155,11 +173,9 @@ Future<void> importPicaData(File file) async {
);
}
}
}
catch(e) {
} catch (e) {
Log.error("Import Data", "Failed to import local favorite: $e");
}
finally {
} finally {
db.dispose();
}
}
@@ -170,31 +186,80 @@ Future<void> importPicaData(File file) async {
for (var comic in db.select("SELECT * FROM history;")) {
HistoryManager().addHistory(
History.fromMap({
"type": switch(comic['type']) {
"type": switch (comic['type']) {
0 => 'picacg'.hashCode,
1 => 'ehentai'.hashCode,
2 => 'jm'.hashCode,
3 => 'hitomi'.hashCode,
4 => 'wnacg'.hashCode,
6 => 'nhentai'.hashCode,
5 => 'nhentai'.hashCode,
_ => comic['type']
},
"id": comic['target'],
"maxPage": comic["max_page"],
"max_page": comic["max_page"],
"ep": comic["ep"],
"page": comic["page"],
"time": comic["time"],
"title": comic["title"],
"subtitle": comic["subtitle"],
"cover": comic["cover"],
"readEpisode": [comic["ep"]],
}),
);
}
}
catch(e) {
Log.error("Import Data", "Failed to import history: $e");
}
finally {
List<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";
}
if (ComicSource.find(sourceKey) == null) {
continue;
}
String id = comic["id"].split("-")[1];
int page = comic["page"];
// 章节和page是从1开始的, pica 可能有从 0 开始的, 得转一下
int ep = comic["ep"] == 0 ? 1 : comic["ep"];
String title = comic["title"];
String epName = "";
ImageFavoritesComic? tempComic = imageFavoritesComicList
.firstWhereOrNull((e) => e.id == id && e.sourceKey == sourceKey);
ImageFavorite curImageFavorite =
ImageFavorite(page, "", null, "", id, ep, sourceKey, epName);
if (tempComic == null) {
tempComic = ImageFavoritesComic(id, [], title, sourceKey, [], [],
DateTime.now(), "", {}, "", 1);
tempComic.imageFavoritesEp = [
ImageFavoritesEp("", ep, [curImageFavorite], epName, 1)
];
imageFavoritesComicList.add(tempComic);
} else {
ImageFavoritesEp? tempEp =
tempComic.imageFavoritesEp.firstWhereOrNull((e) => e.ep == ep);
if (tempEp == null) {
tempComic.imageFavoritesEp
.add(ImageFavoritesEp("", ep, [curImageFavorite], epName, 1));
} else {
// 如果已经有这个page了, 就不添加了
if (tempEp.imageFavorites
.firstWhereOrNull((e) => e.page == page) ==
null) {
tempEp.imageFavorites.add(curImageFavorite);
}
}
}
}
for (var temp in imageFavoritesComicList) {
ImageFavoriteManager().addOrUpdateOrDelete(
temp,
temp == imageFavoritesComicList.last,
);
}
} catch (e, stack) {
Log.error("Import Data", "Failed to import history: $e", stack);
} finally {
db.dispose();
}
}

View File

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

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) {
var mime = lookupMimeType('no-file', headerBytes: data);
var mime = _resolver.lookup('no-file', headerBytes: data);
var ext = mime == null ? '' : extensionFromMime(mime);
if(ext == 'jpe') {
ext = 'jpg';

View File

@@ -20,7 +20,7 @@ class ImportComic {
const ImportComic({this.selectedFolder, this.copyToLocal = true});
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 = {};
if (file == null) {
return false;
@@ -42,7 +42,8 @@ class ImportComic {
var dir = await picker.pickDirectory(directAccess: true);
if (dir != null) {
var files = (await dir.list().toList()).whereType<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 = {};
var controller = showLoadingDialog(App.rootContext, allowCancel: false);
var comics = <LocalComic>[];

View File

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

View File

@@ -303,6 +303,15 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_7zip:
dependency: "direct main"
description:
path: "."
ref: b33344797f1d2469339e0e1b75f5f954f1da224c
resolved-ref: b33344797f1d2469339e0e1b75f5f954f1da224c
url: "https://github.com/wgh136/flutter_7zip"
source: git
version: "0.0.1"
flutter_file_dialog:
dependency: "direct main"
description:
@@ -408,8 +417,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "9c99ac258a11f8e91761a5466a190efba3ca64af"
resolved-ref: "9c99ac258a11f8e91761a5466a190efba3ca64af"
ref: "598d50572a658f8e04775566fe3789954d9a01e3"
resolved-ref: "598d50572a658f8e04775566fe3789954d9a01e3"
url: "https://github.com/wgh136/flutter_qjs"
source: git
version: "0.3.7"
@@ -867,14 +876,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.2"
shimmer:
shimmer_animation:
dependency: "direct main"
description:
name: shimmer
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
name: shimmer_animation
sha256: "9357080b7dd892aae837d569e1fbbcbe7f9a02ca994e558561d90e35e92f1101"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "2.2.2"
sky_engine:
dependency: transitive
description: flutter
@@ -1141,10 +1150,10 @@ packages:
dependency: "direct main"
description:
name: zip_flutter
sha256: be21152c35fcb6d0ef4ce89fc3aed681f7adc0db5490ca3eb5893f23fd20e646
sha256: ea7fdc86c988174ef3bb80dc26e8e8bfdf634c55930e2d18d7e77e991acf0483
url: "https://pub.dev"
source: hosted
version: "0.0.6"
version: "0.0.8"
sdks:
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.1"
flutter: ">=3.27.2"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.1.4+114
version: 1.2.1+121
environment:
sdk: '>=3.6.0 <4.0.0'
flutter: 3.27.1
flutter: 3.27.2
dependencies:
flutter:
@@ -21,7 +21,7 @@ dependencies:
flutter_qjs:
git:
url: https://github.com/wgh136/flutter_qjs
ref: 9c99ac258a11f8e91761a5466a190efba3ca64af
ref: 598d50572a658f8e04775566fe3789954d9a01e3
crypto: ^3.0.6
dio: ^5.7.0
html: ^0.15.5
@@ -51,7 +51,7 @@ dependencies:
sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2
file_selector: ^1.0.3
zip_flutter: ^0.0.6
zip_flutter: ^0.0.8
lodepng_flutter:
git:
url: https://github.com/venera-app/lodepng_flutter
@@ -69,10 +69,14 @@ dependencies:
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
pdf: ^3.11.1
dynamic_color: ^1.7.0
shimmer: ^3.0.0
shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1
syntax_highlight: ^0.4.0
text_scroll: ^0.2.0
flutter_7zip:
git:
url: https://github.com/wgh136/flutter_7zip
ref: b33344797f1d2469339e0e1b75f5f954f1da224c
dev_dependencies:
flutter_test:

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\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\dynamic_color_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\flutter_7zip.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files