Compare commits
169 Commits
v1.1.0-pat
...
v1.2.3
Author | SHA1 | Date | |
---|---|---|---|
![]() |
24155746f2 | ||
340496da30 | |||
28a56b4612 | |||
4e6f71ef36 | |||
739685f60f | |||
8c5dae1e59 | |||
e2c69d882f | |||
0b9f0b7d35 | |||
9ea749a84a | |||
d675af3fb4 | |||
d99a30b7d8 | |||
![]() |
3c3c07b6fb | ||
![]() |
e688ab759a | ||
![]() |
64a3ef352f | ||
ef8dc9e8d4 | |||
![]() |
19af2d79dd | ||
5a11168f98 | |||
1564156e28 | |||
2534c55ffb | |||
ba4eff66db | |||
b43d907763 | |||
f5a814cfe4 | |||
24b9bcd86e | |||
812b36d1e9 | |||
bab2578b65 | |||
5cf2f9f33a | |||
040a5d7ad2 | |||
69da66904a | |||
11e4d7a9f2 | |||
7bd0c2b82a | |||
6b0a5184b9 | |||
864980079b | |||
de51b66d39 | |||
23205c518d | |||
3ae5c7c7f2 | |||
312e991935 | |||
5184130ff8 | |||
e555779419 | |||
5ef973cbfb | |||
8e2520f8e8 | |||
87f0f5bb55 | |||
![]() |
578c06fdc1 | ||
8645dda967 | |||
ded9055363 | |||
ff42c726fa | |||
53b033258a | |||
6ec4817dc1 | |||
283afbc6d4 | |||
c3a09c8870 | |||
f2388c81e0 | |||
c334e4fa05 | |||
![]() |
cc8277d462 | ||
e6b7f5b014 | |||
1edf284709 | |||
6033a3cde9 | |||
27e7356721 | |||
d88ae57320 | |||
7b7710b441 | |||
63346396e0 | |||
51b7df02e7 | |||
![]() |
811fbb04dc | ||
![]() |
eaf94363ae | ||
5e3ff48d35 | |||
c6ec38632f | |||
1c1f418019 | |||
![]() |
b6e5035509 | ||
52410bac03 | |||
0a187cca2e | |||
dda8d98e85 | |||
1abf9c151e | |||
d9084272e5 | |||
16512f2711 | |||
481bb97301 | |||
950690df48 | |||
825ef39605 | |||
5f36ef6ea3 | |||
bfd115046d | |||
4c6e4373e9 | |||
6467a46e5c | |||
0011738820 | |||
c640e6bfbf | |||
5d1d62e157 | |||
399b9abaee | |||
![]() |
d874920c88 | ||
![]() |
213c225e1e | ||
![]() |
d55c0aa325 | ||
![]() |
2d6e76a5a6 | ||
![]() |
2968f1fa29 | ||
72228515f6 | |||
![]() |
b56f8d7398 | ||
![]() |
8375fb721e | ||
9876da85da | |||
4b19ab57d2 | |||
91ee48cc6c | |||
![]() |
7495c11944 | ||
08e8a45236 | |||
fb1b017bc9 | |||
99a3788f4a | |||
a747179cc4 | |||
1ca8da1c83 | |||
8eddab5e13 | |||
030007159d | |||
43a054c12a | |||
51a6456dad | |||
3a320feda9 | |||
![]() |
a88bbe9ea6 | ||
![]() |
5be2dbcfd7 | ||
68a203a1c1 | |||
c06709aeb7 | |||
95649ca9fe | |||
1e09d69507 | |||
![]() |
a5c745f40d | ||
d27efb180a | |||
1f5382ff8c | |||
2238fcc68f | |||
df42cf320c | |||
eb14f973e4 | |||
99454041d3 | |||
1ae33c43b1 | |||
bed30d3cea | |||
06f953c1bc | |||
0b96d01afb | |||
6023e462d7 | |||
0e22574002 | |||
e1b2f83c48 | |||
e77424e00e | |||
9f67cd0d07 | |||
6a79f68909 | |||
aa66111f2c | |||
ddeaaf0856 | |||
18f450a0db | |||
a217b86c08 | |||
![]() |
79d2c91723 | ||
![]() |
731510e11d | ||
![]() |
b3d3c141f9 | ||
![]() |
bea861a83c | ||
![]() |
4a595a8aca | ||
![]() |
bf634f8654 | ||
![]() |
bda215ebb7 | ||
a70b690d3c | |||
0b8ae2d377 | |||
24c5a1bb01 | |||
ea973a2787 | |||
![]() |
17bce96143 | ||
![]() |
909c0014ac | ||
eb1abfc02a | |||
![]() |
788e41f584 | ||
929ec88e84 | |||
abaeaf4f77 | |||
![]() |
a614e83470 | ||
8b9fd0d03d | |||
![]() |
1964c4c0d5 | ||
43d724dd27 | |||
f9c42aef4b | |||
06a6e5156a | |||
![]() |
be45a06981 | ||
4763b9c7b4 | |||
7e608be70f | |||
211e6ab8c8 | |||
![]() |
100dc6458b | ||
![]() |
8dab5f9e88 | ||
d08383e14b | |||
a55e4eff67 | |||
ab3953292b | |||
b49e0974ab | |||
![]() |
b6cccb7749 | ||
![]() |
dac07cfac4 | ||
![]() |
da12b3bcca | ||
![]() |
017f964705 |
19
.github/workflows/analyze.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: "analyze"
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: flutter pub get
|
||||
- uses: invertase/github-action-dart-analyzer@v1
|
16
.github/workflows/fastlane.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Validate Fastlane metadata
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
go:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate Fastlane Supply Metadata
|
||||
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0
|
4
.github/workflows/main.yml
vendored
@@ -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
@@ -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
|
||||
|
14
README.md
@@ -1,15 +1,17 @@
|
||||
# venera
|
||||
|
||||
[](https://flutter.dev/)
|
||||
[](https://flutter.dev/)
|
||||
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
||||
[](https://github.com/venera-app/venera/releases)
|
||||
[](https://github.com/venera-app/venera/stargazers)
|
||||
[](https://github.com/venera-app/venera/stargazers)
|
||||
[](https://t.me/+Ws-IpmUutzkxMjhl)
|
||||
|
||||
A comic reader that support reading local and network comics.
|
||||
|
||||
## Features
|
||||
[<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
|
||||
- Use javascript to create comic sources
|
||||
- Read comics from network sources
|
||||
@@ -19,15 +21,13 @@ A comic reader that support reading local and network comics.
|
||||
- Login to comment, rate, and other operations if the source supports
|
||||
|
||||
## Build from source
|
||||
|
||||
1. Clone the repository
|
||||
2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install)
|
||||
3. Install rust, see [rustup.rs](https://rustup.rs/)
|
||||
4. Build for your platform: e.g. `flutter build apk`
|
||||
|
||||
## Create a new comic source
|
||||
|
||||
See [venera-configs](https://github.com/venera-app/venera-configs)
|
||||
See [Comic Source](doc/comic_source.md)
|
||||
|
||||
## Thanks
|
||||
|
||||
|
@@ -5,6 +5,8 @@ plugins {
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
ext.abiCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86_64": 3]
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
@@ -35,7 +37,7 @@ android {
|
||||
splits{
|
||||
abi {
|
||||
reset()
|
||||
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
|
||||
enable true
|
||||
universalApk true
|
||||
}
|
||||
@@ -78,20 +80,43 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
signingConfig signingConfigs.release
|
||||
}
|
||||
debug {
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all { output ->
|
||||
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
if (variant.buildType.name == "release") {
|
||||
if (abi != null) {
|
||||
outputFileName = "venera-${variant.versionName}-${abi}.apk"
|
||||
def abiVersionCode = project.ext.abiCodes.get(abi)
|
||||
if (abiVersionCode != null) {
|
||||
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||
}
|
||||
} else {
|
||||
outputFileName = "venera-${variant.versionName}.apk"
|
||||
versionCodeOverride = variant.versionCode * 10
|
||||
}
|
||||
} else if (variant.buildType.name == "debug") {
|
||||
versionCodeOverride = variant.versionCode * 10 + 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
// Disables dependency metadata when building APKs.
|
||||
includeInApk = false
|
||||
// Disables dependency metadata when building Android App Bundles.
|
||||
includeInBundle = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -53,6 +53,8 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<!-- [flutter 3.27.1] Impeller is still worse than skia, disable it -->
|
||||
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false"/>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
|
||||
|
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.2.1' apply false
|
||||
id "com.android.application" version '8.3.2' apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
|
||||
}
|
||||
|
||||
|
179
assets/init.js
@@ -4,6 +4,13 @@ Venera JavaScript Library
|
||||
This library provides a set of APIs for interacting with the Venera app.
|
||||
*/
|
||||
|
||||
function setTimeout(callback, delay) {
|
||||
sendMessage({
|
||||
method: 'delay',
|
||||
time: delay,
|
||||
}).then(callback);
|
||||
}
|
||||
|
||||
/// encode, decode, hash, decrypt
|
||||
let Convert = {
|
||||
/**
|
||||
@@ -486,6 +493,37 @@ let Network = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* [fetch] function for sending HTTP requests. Same api as the browser fetch.
|
||||
* @param url {string}
|
||||
* @param options {{method: string, headers: Object, body: any}}
|
||||
* @returns {Promise<{ok: boolean, status: number, statusText: string, headers: {}, arrayBuffer: (function(): Promise<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'
|
||||
})
|
||||
}
|
||||
}
|
@@ -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": "查看更多",
|
||||
@@ -218,7 +214,6 @@
|
||||
"Create Folder": "新建文件夹",
|
||||
"Select an image on screen": "选择屏幕上的图片",
|
||||
"Added @count comics to download queue.": "已添加 @count 本漫画到下载队列",
|
||||
"Ignore Certificate Errors": "忽略证书错误",
|
||||
"Authorization Required": "需要身份验证",
|
||||
"Sync": "同步",
|
||||
"The folder is Linked to @source": "文件夹已关联到 @source",
|
||||
@@ -234,7 +229,7 @@
|
||||
"Clear History": "清除历史",
|
||||
"Are you sure you want to clear your history?": "确定要清除您的历史记录吗?",
|
||||
"No Explore Pages": "没有探索页面",
|
||||
"Add a comic source in home page": "在主页添加一个漫画源",
|
||||
"Please add some sources": "请添加一些源",
|
||||
"Please check your settings": "请检查您的设置",
|
||||
"No Category Pages": "没有分类页面",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
@@ -249,9 +244,84 @@
|
||||
"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": "下载已开始"
|
||||
"Download started": "下载已开始",
|
||||
"Click favorite": "点击收藏",
|
||||
"End": "末尾",
|
||||
"None": "无",
|
||||
"View Detail": "查看详情",
|
||||
"Select a directory which contains multiple archive files." : "选择一个包含多个归档文件的目录",
|
||||
"Multiple archive files" : "多个归档文件",
|
||||
"No valid comics found" : "未找到有效的漫画",
|
||||
"Enable DNS Overrides": "启用DNS覆写",
|
||||
"DNS Overrides": "DNS覆写",
|
||||
"Custom Image Processing": "自定义图片处理",
|
||||
"Enable": "启用",
|
||||
"Aggregated": "聚合",
|
||||
"Default Search Target": "默认搜索目标",
|
||||
"Auto Language Filters": "自动语言筛选",
|
||||
"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",
|
||||
"Deselect All": "取消全选",
|
||||
"Add keyword": "添加关键词",
|
||||
"Keyword": "关键词",
|
||||
"Manage": "管理",
|
||||
"Verify": "验证",
|
||||
"Cloudflare verification required": "需要Cloudflare验证",
|
||||
"Success": "成功"
|
||||
},
|
||||
"zh_TW": {
|
||||
"Home": "首頁",
|
||||
@@ -273,7 +343,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": "刪除",
|
||||
@@ -401,14 +471,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": "查看更多",
|
||||
@@ -417,6 +482,7 @@
|
||||
"Date": "日期",
|
||||
"Date Desc": "日期降序",
|
||||
"Start": "開始",
|
||||
"Reversed successfully": "反轉成功",
|
||||
"Export App Data": "匯出應用數據",
|
||||
"Import App Data": "匯入應用數據",
|
||||
"Export": "匯出",
|
||||
@@ -472,7 +538,6 @@
|
||||
"Create Folder": "新建文件夾",
|
||||
"Select an image on screen": "選擇屏幕上的圖片",
|
||||
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載隊列",
|
||||
"Ignore Certificate Errors": "忽略證書錯誤",
|
||||
"Authorization Required": "需要身份驗證",
|
||||
"Sync": "同步",
|
||||
"The folder is Linked to @source": "文件夾已關聯到 @source",
|
||||
@@ -488,7 +553,7 @@
|
||||
"Clear History": "清除歷史",
|
||||
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
|
||||
"No Explore Pages": "沒有探索頁面",
|
||||
"Add a comic source in home page": "在主頁添加一個漫畫源",
|
||||
"Please add some sources": "請添加一些源",
|
||||
"Please check your settings": "請檢查您的設定",
|
||||
"No Category Pages": "沒有分類頁面",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
@@ -505,6 +570,81 @@
|
||||
"Aggregated Search": "聚合搜索",
|
||||
"No search results found": "未找到搜索結果",
|
||||
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載隊列",
|
||||
"Download started": "下載已開始"
|
||||
"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 archive files." : "選擇一個包含多個歸檔文件的目錄",
|
||||
"Multiple archive files" : "多個歸檔文件",
|
||||
"No valid comics found" : "未找到有效的漫畫",
|
||||
"Enable DNS Overrides": "啟用DNS覆寫",
|
||||
"DNS Overrides": "DNS覆寫",
|
||||
"Custom Image Processing": "自定義圖片處理",
|
||||
"Enable": "啟用",
|
||||
"Aggregated": "聚合",
|
||||
"Default Search Target": "默認搜索目標",
|
||||
"Auto Language Filters": "自動語言篩選",
|
||||
"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",
|
||||
"Deselect All": "取消全選",
|
||||
"Add keyword": "添加關鍵詞",
|
||||
"Keyword": "關鍵詞",
|
||||
"Manage": "管理",
|
||||
"Verify": "驗證",
|
||||
"Cloudflare verification required": "需要Cloudflare驗證",
|
||||
"Success": "成功"
|
||||
}
|
||||
}
|
2
debian/gui/venera.desktop
vendored
@@ -1,5 +1,4 @@
|
||||
[Desktop Entry]
|
||||
Version={{Version}}
|
||||
Name=Venera
|
||||
GenericName=Venera
|
||||
Comment=venera
|
||||
@@ -7,3 +6,4 @@ Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility
|
||||
Keywords=Flutter;comic;images;
|
||||
Icon=venera
|
655
doc/comic_source.md
Normal 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
@@ -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
@@ -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 = {}
|
||||
}
|
||||
```
|
15
fastlane/metadata/android/en-US/full_description.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
<p>A comic reader that support reading local and network comics.</p>
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li>Read local comics</li>
|
||||
<li>Use javascript to create comic sources</li>
|
||||
<li>Read comics from network sources</li>
|
||||
<li>Manage favorite comics</li>
|
||||
<li>Download comics</li>
|
||||
<li>View comments, tags, and other information of comics if the source supports</li>
|
||||
<li>Login to comment, rate, and other operations if the source supports</li>
|
||||
</ul>
|
||||
<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>
|
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
After Width: | Height: | Size: 2.1 MiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
After Width: | Height: | Size: 264 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
After Width: | Height: | Size: 137 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
Normal file
After Width: | Height: | Size: 102 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
Normal file
After Width: | Height: | Size: 752 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
Normal file
After Width: | Height: | Size: 145 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
Normal file
After Width: | Height: | Size: 55 KiB |
1
fastlane/metadata/android/en-US/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
A comic reader that support reading local and network comics.
|
1
fastlane/metadata/android/en-US/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
venera
|
@@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '14.0'
|
||||
platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
@@ -1,12 +1,14 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class Appbar extends StatefulWidget implements PreferredSizeWidget {
|
||||
const Appbar(
|
||||
{required this.title,
|
||||
const Appbar({
|
||||
required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
super.key});
|
||||
this.style = AppbarStyle.blur,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget title;
|
||||
|
||||
@@ -16,6 +18,8 @@ class Appbar extends StatefulWidget implements PreferredSizeWidget {
|
||||
|
||||
final Color? backgroundColor;
|
||||
|
||||
final AppbarStyle style;
|
||||
|
||||
@override
|
||||
State<Appbar> createState() => _AppbarState();
|
||||
|
||||
@@ -108,12 +112,20 @@ class _AppbarState extends State<Appbar> {
|
||||
],
|
||||
).paddingTop(context.padding.top),
|
||||
);
|
||||
if (widget.style == AppbarStyle.shadow) {
|
||||
return Material(
|
||||
color: context.colorScheme.surface,
|
||||
elevation: _scrolledUnder ? 2 : 0,
|
||||
child: content,
|
||||
);
|
||||
} else {
|
||||
return BlurEffect(
|
||||
blur: _scrolledUnder ? 15 : 0,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppbarStyle {
|
||||
blur,
|
||||
@@ -256,18 +268,25 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
class FilledTabBar extends StatefulWidget {
|
||||
const FilledTabBar({super.key, this.controller, required this.tabs});
|
||||
class AppTabBar extends StatefulWidget {
|
||||
const AppTabBar({
|
||||
super.key,
|
||||
this.controller,
|
||||
required this.tabs,
|
||||
this.actionButton,
|
||||
});
|
||||
|
||||
final TabController? controller;
|
||||
|
||||
final List<Tab> tabs;
|
||||
|
||||
final Widget? actionButton;
|
||||
|
||||
@override
|
||||
State<FilledTabBar> createState() => _FilledTabBarState();
|
||||
State<AppTabBar> createState() => _AppTabBarState();
|
||||
}
|
||||
|
||||
class _FilledTabBarState extends State<FilledTabBar> {
|
||||
class _AppTabBarState extends State<AppTabBar> {
|
||||
late TabController _controller;
|
||||
|
||||
late List<GlobalKey> keys;
|
||||
@@ -315,7 +334,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FilledTabBar oldWidget) {
|
||||
void didUpdateWidget(covariant AppTabBar oldWidget) {
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
@@ -366,7 +385,8 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
painter: painter,
|
||||
child: _TabRow(
|
||||
callback: _tabLayoutCallback,
|
||||
children: List.generate(widget.tabs.length, buildTab),
|
||||
children: List.generate(widget.tabs.length, buildTab)
|
||||
..addIfNotNull(widget.actionButton?.padding(tabPadding)),
|
||||
),
|
||||
).paddingHorizontal(4),
|
||||
);
|
||||
@@ -384,7 +404,8 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
child: widget.tabs.isEmpty ? const SizedBox() : child);
|
||||
child: widget.tabs.isEmpty ? const SizedBox() : child,
|
||||
);
|
||||
}
|
||||
|
||||
int? previousIndex;
|
||||
@@ -544,7 +565,7 @@ class _IndicatorPainter extends CustomPainter {
|
||||
|
||||
var rect = Rect.fromLTWH(
|
||||
tabLeft + padding.left + horizontalPadding,
|
||||
_FilledTabBarState._kTabHeight - 3.6,
|
||||
_AppTabBarState._kTabHeight - 3.6,
|
||||
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
|
||||
3,
|
||||
);
|
||||
@@ -577,6 +598,50 @@ 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;
|
||||
|
||||
@@ -849,3 +914,42 @@ class _SearchBarState extends State<AppSearchBar> with _SearchBarMixin {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TabActionButton extends StatelessWidget {
|
||||
const TabActionButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final Icon icon;
|
||||
|
||||
final String text;
|
||||
|
||||
final void Function() onPressed;
|
||||
|
||||
static const _kTabHeight = 46.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
height: _kTabHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(size: 20, color: context.colorScheme.primary),
|
||||
child: Row(
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 8),
|
||||
Text(text, style: ts.withColor(context.colorScheme.primary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
383
lib/components/code.dart
Normal file
@@ -0,0 +1,383 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class CodeEditor extends StatefulWidget {
|
||||
const CodeEditor({super.key, this.initialValue, this.onChanged});
|
||||
|
||||
final String? initialValue;
|
||||
|
||||
final void Function(String value)? onChanged;
|
||||
|
||||
@override
|
||||
State<CodeEditor> createState() => _CodeEditorState();
|
||||
}
|
||||
|
||||
class _CodeEditorState extends State<CodeEditor> {
|
||||
late _CodeTextEditingController _controller;
|
||||
late FocusNode _focusNode;
|
||||
var horizontalScrollController = ScrollController();
|
||||
var verticalScrollController = ScrollController();
|
||||
int lineCount = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = _CodeTextEditingController(text: widget.initialValue);
|
||||
_focusNode = FocusNode()
|
||||
..onKeyEvent = (node, event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.tab) {
|
||||
if (event is KeyDownEvent) {
|
||||
handleTab();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
lineCount = calculateLineCount(widget.initialValue ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
future = _controller.init(context.brightness);
|
||||
}
|
||||
|
||||
void handleTab() {
|
||||
var text = _controller.text;
|
||||
var start = _controller.selection.start;
|
||||
var end = _controller.selection.end;
|
||||
_controller.text = '${text.substring(0, start)} ${text.substring(end)}';
|
||||
_controller.selection = TextSelection.collapsed(offset: start + 4);
|
||||
}
|
||||
|
||||
int calculateLineCount(String text) {
|
||||
return text.split('\n').length;
|
||||
}
|
||||
|
||||
Widget buildLineNumbers() {
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 1; i <= lineCount; i++)
|
||||
SizedBox(
|
||||
height: 14 * 1.5,
|
||||
child: Center(
|
||||
child: Text(
|
||||
i.toString(),
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.outline,
|
||||
fontSize: 13,
|
||||
height: 1.0,
|
||||
fontFamily: 'Consolas',
|
||||
fontFamilyFallback: ['Courier New', 'monospace'],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).paddingVertical(8);
|
||||
}
|
||||
|
||||
late Future future;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: future,
|
||||
builder: (context, value) {
|
||||
if (value.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_controller.selection = TextSelection.collapsed(
|
||||
offset: _controller.text.length,
|
||||
);
|
||||
_focusNode.requestFocus();
|
||||
},
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
controller: verticalScrollController,
|
||||
notificationPredicate: (notif) =>
|
||||
notif.metrics.axis == Axis.vertical,
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
controller: horizontalScrollController,
|
||||
notificationPredicate: (notif) =>
|
||||
notif.metrics.axis == Axis.horizontal,
|
||||
child: SizedBox.expand(
|
||||
child: ScrollConfiguration(
|
||||
behavior: _CustomScrollBehavior(),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
controller: horizontalScrollController,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: verticalScrollController,
|
||||
child: Row(
|
||||
children: [
|
||||
buildLineNumbers(),
|
||||
IntrinsicWidth(
|
||||
stepWidth: 100,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
maxLines: null,
|
||||
cursorHeight: 1.5 * 14,
|
||||
style: TextStyle(height: 1.5, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.all(8),
|
||||
),
|
||||
onChanged: (value) {
|
||||
widget.onChanged?.call(value);
|
||||
if (lineCount != calculateLineCount(value)) {
|
||||
setState(() {
|
||||
lineCount = calculateLineCount(value);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomScrollBehavior extends MaterialScrollBehavior {
|
||||
const _CustomScrollBehavior();
|
||||
@override
|
||||
Widget buildScrollbar(
|
||||
BuildContext context, Widget child, ScrollableDetails details) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class _CodeTextEditingController extends TextEditingController {
|
||||
_CodeTextEditingController({super.text});
|
||||
|
||||
HighlighterTheme? _theme;
|
||||
|
||||
Future<void> init(Brightness brightness) async {
|
||||
Highlighter.addLanguage('js', _jsGrammer);
|
||||
_theme = await HighlighterTheme.loadForBrightness(brightness);
|
||||
}
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan(
|
||||
{required BuildContext context,
|
||||
TextStyle? style,
|
||||
required bool withComposing}) {
|
||||
var highlighter = Highlighter(
|
||||
language: 'js',
|
||||
theme: _theme!,
|
||||
);
|
||||
var result = highlighter.highlight(text);
|
||||
style = TextStyle(
|
||||
height: 1.5,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Consolas',
|
||||
fontFamilyFallback: ['Courier New', 'Roboto Mono', 'monospace'],
|
||||
);
|
||||
|
||||
return mergeTextStyle(result, style);
|
||||
}
|
||||
|
||||
TextSpan mergeTextStyle(TextSpan span, TextStyle style) {
|
||||
var result = TextSpan(
|
||||
style: style.merge(span.style),
|
||||
children: span.children
|
||||
?.whereType()
|
||||
.map((e) => mergeTextStyle(e, style))
|
||||
.toList(),
|
||||
text: span.text,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const _jsGrammer = r'''
|
||||
{
|
||||
"name": "JavaScript",
|
||||
"version": "1.0.0",
|
||||
"fileTypes": ["js", "mjs", "cjs"],
|
||||
"scopeName": "source.js",
|
||||
|
||||
"foldingStartMarker": "\\{\\s*$",
|
||||
"foldingStopMarker": "^\\s*\\}",
|
||||
|
||||
"patterns": [
|
||||
{
|
||||
"name": "meta.preprocessor.script.js",
|
||||
"match": "^(#!.*)$"
|
||||
},
|
||||
{
|
||||
"name": "meta.import-export.js",
|
||||
"begin": "\\b(import|export)\\b",
|
||||
"beginCaptures": {
|
||||
"0": {
|
||||
"name": "keyword.control.import.js"
|
||||
}
|
||||
},
|
||||
"end": ";",
|
||||
"endCaptures": {
|
||||
"0": {
|
||||
"name": "punctuation.terminator.js"
|
||||
}
|
||||
},
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#strings"
|
||||
},
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"name": "keyword.control.import.js",
|
||||
"match": "\\b(as|from)\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"include": "#keywords"
|
||||
},
|
||||
{
|
||||
"include": "#constants-and-special-vars"
|
||||
},
|
||||
{
|
||||
"include": "#operators"
|
||||
},
|
||||
{
|
||||
"include": "#strings"
|
||||
}
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"comments": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "comment.block.js",
|
||||
"begin": "/\\*",
|
||||
"end": "\\*/"
|
||||
},
|
||||
{
|
||||
"name": "comment.line.double-slash.js",
|
||||
"match": "//.*$"
|
||||
}
|
||||
]
|
||||
},
|
||||
"keywords": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "keyword.control.js",
|
||||
"match": "\\b(if|else|for|while|do|switch|case|default|break|continue|return|throw|try|catch|finally)\\b"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.js",
|
||||
"match": "\\b(instanceof|typeof|new|delete|in|void)\\b"
|
||||
},
|
||||
{
|
||||
"name": "storage.type.js",
|
||||
"match": "\\b(var|let|const|function|class|extends)\\b"
|
||||
},
|
||||
{
|
||||
"name": "keyword.declaration.js",
|
||||
"match": "\\b(export|import|default)\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
"constants-and-special-vars": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "constant.language.js",
|
||||
"match": "\\b(true|false|null|undefined|NaN|Infinity)\\b"
|
||||
},
|
||||
{
|
||||
"name": "constant.numeric.js",
|
||||
"match": "\\b(0x[0-9A-Fa-f]+|[0-9]+\\.?[0-9]*(e[+-]?[0-9]+)?)\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
"operators": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "keyword.operator.assignment.js",
|
||||
"match": "(=|\\+=|-=|\\*=|/=|%=|\\|=|&=|\\^=|<<=|>>=|>>>=)"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.comparison.js",
|
||||
"match": "(==|!=|===|!==|<|<=|>|>=)"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.logical.js",
|
||||
"match": "(&&|\\|\\||!)"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.arithmetic.js",
|
||||
"match": "(-|\\+|\\*|/|%)"
|
||||
},
|
||||
{
|
||||
"name": "keyword.operator.bitwise.js",
|
||||
"match": "(\\||&|\\^|~|<<|>>|>>>)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"strings": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "string.quoted.double.js",
|
||||
"begin": "\"",
|
||||
"end": "\"",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#string-interpolation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "string.quoted.single.js",
|
||||
"begin": "'",
|
||||
"end": "'",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#string-interpolation"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "string.template.js",
|
||||
"begin": "`",
|
||||
"end": "`",
|
||||
"patterns": [
|
||||
{
|
||||
"include": "#string-interpolation"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"string-interpolation": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "variable.parameter.js",
|
||||
"begin": "\\$\\{",
|
||||
"end": "\\}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
''';
|
@@ -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(
|
||||
@@ -77,7 +111,7 @@ class ComicTile extends StatelessWidget {
|
||||
icon: Icons.stars_outlined,
|
||||
text: 'Add to favorites'.tl,
|
||||
onClick: () {
|
||||
addFavorite(comic);
|
||||
addFavorite([comic]);
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
@@ -161,24 +195,10 @@ 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) {
|
||||
var image = _findImageProvider(comic);
|
||||
if (image == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
image = FileImage(localComic.coverFile);
|
||||
} else {
|
||||
image = CachedImageProvider(
|
||||
comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
);
|
||||
}
|
||||
return AnimatedImage(
|
||||
image: image,
|
||||
fit: BoxFit.cover,
|
||||
@@ -199,16 +219,26 @@ class ComicTile extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
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),
|
||||
),
|
||||
),
|
||||
SizedBox.fromSize(
|
||||
size: const Size(16, 5),
|
||||
),
|
||||
@@ -235,34 +265,38 @@ class ComicTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildBriefMode(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
child: LayoutBuilder(
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: _onTap,
|
||||
onLongPress:
|
||||
enableLongPressed ? () => _onLongPressed(context) : null,
|
||||
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
|
||||
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Hero(
|
||||
tag: "cover${comic.id}${comic.sourceKey}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer,
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: (() {
|
||||
@@ -270,93 +304,108 @@ class ComicTile extends StatelessWidget {
|
||||
comic.subtitle?.replaceAll('\n', '').trim();
|
||||
final text = comic.description.isNotEmpty
|
||||
? comic.description.split('|').join('\n')
|
||||
: (subtitle?.isNotEmpty == true
|
||||
? subtitle
|
||||
: null);
|
||||
final scale =
|
||||
(appdata.settings['comicTileScale'] as num)
|
||||
.toDouble();
|
||||
final fortSize = scale < 0.85
|
||||
? 8.0 // 小尺寸
|
||||
: (scale < 1.0 ? 10.0 : 12.0);
|
||||
: (subtitle?.isNotEmpty == true ? subtitle : null);
|
||||
final fortSize = constraints.maxWidth < 80
|
||||
? 8.0
|
||||
: constraints.maxWidth < 150
|
||||
? 10.0
|
||||
: 12.0;
|
||||
|
||||
if (text == null) {
|
||||
return const SizedBox
|
||||
.shrink(); // 如果没有文本,则不显示任何内容
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 2, vertical: 2),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(10.0),
|
||||
),
|
||||
child: Container(
|
||||
var children = <Widget>[];
|
||||
for (var line in text.split('\n')) {
|
||||
children.add(Container(
|
||||
margin: const EdgeInsets.fromLTRB(2, 0, 2, 2),
|
||||
padding: constraints.maxWidth < 80
|
||||
? const EdgeInsets.fromLTRB(3, 1, 3, 1)
|
||||
: constraints.maxWidth < 150
|
||||
? const EdgeInsets.fromLTRB(4, 2, 4, 2)
|
||||
: const EdgeInsets.fromLTRB(5, 2, 5, 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.black.toOpacity(0.5),
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: ConstrainedBox(
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
line,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: fortSize,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: children,
|
||||
);
|
||||
})(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
|
||||
child: Text(
|
||||
comic.title.replaceAll('\n', ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(6).paddingVertical(8),
|
||||
);
|
||||
},
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _splitText(String text) {
|
||||
// split text by space, comma. text in brackets will be kept together.
|
||||
// split text by comma, brackets
|
||||
var words = <String>[];
|
||||
var buffer = StringBuffer();
|
||||
var inBracket = false;
|
||||
String? prevBracket;
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
var c = text[i];
|
||||
if (c == '[' || c == '(') {
|
||||
inBracket = true;
|
||||
} else if (c == ']' || c == ')') {
|
||||
inBracket = false;
|
||||
} else if (c == ' ' || c == ',') {
|
||||
if (inBracket) {
|
||||
buffer.write(c);
|
||||
} else {
|
||||
words.add(buffer.toString());
|
||||
if (buffer.isNotEmpty) {
|
||||
words.add(buffer.toString().trim());
|
||||
buffer.clear();
|
||||
}
|
||||
inBracket = true;
|
||||
prevBracket = c;
|
||||
}
|
||||
} else if (c == ']' || c == ')') {
|
||||
if (prevBracket == '[' && c == ']' || prevBracket == '(' && c == ')') {
|
||||
if (buffer.isNotEmpty) {
|
||||
words.add(buffer.toString().trim());
|
||||
buffer.clear();
|
||||
}
|
||||
inBracket = false;
|
||||
} else {
|
||||
buffer.write(c);
|
||||
}
|
||||
} else if (c == ',') {
|
||||
if (inBracket) {
|
||||
buffer.write(c);
|
||||
} else {
|
||||
words.add(buffer.toString().trim());
|
||||
buffer.clear();
|
||||
}
|
||||
} else {
|
||||
@@ -364,8 +413,10 @@ class ComicTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
if (buffer.isNotEmpty) {
|
||||
words.add(buffer.toString());
|
||||
words.add(buffer.toString().trim());
|
||||
}
|
||||
words.removeWhere((element) => element == "");
|
||||
words = words.toSet().toList();
|
||||
return words;
|
||||
}
|
||||
|
||||
@@ -383,7 +434,12 @@ class ComicTile extends StatelessWidget {
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: 'Block'.tl,
|
||||
content: Wrap(
|
||||
content: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: math.min(400, context.height - 136),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: [
|
||||
@@ -402,7 +458,9 @@ class ComicTile extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16),
|
||||
),
|
||||
actions: [
|
||||
Button.filled(
|
||||
onPressed: () {
|
||||
@@ -700,9 +758,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();
|
||||
@@ -780,7 +845,10 @@ class _SliverGridComics extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.secondaryContainer.toOpacity(0.72)
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer
|
||||
.toOpacity(0.72)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -833,6 +901,7 @@ class ComicList extends StatefulWidget {
|
||||
this.menuBuilder,
|
||||
this.controller,
|
||||
this.refreshHandlerCallback,
|
||||
this.enablePageStorage = false,
|
||||
});
|
||||
|
||||
final Future<Res<List<Comic>>> Function(int page)? loadPage;
|
||||
@@ -851,6 +920,8 @@ class ComicList extends StatefulWidget {
|
||||
|
||||
final void Function(VoidCallback c)? refreshHandlerCallback;
|
||||
|
||||
final bool enablePageStorage;
|
||||
|
||||
@override
|
||||
State<ComicList> createState() => ComicListState();
|
||||
}
|
||||
@@ -868,6 +939,8 @@ class ComicListState extends State<ComicList> {
|
||||
|
||||
String? _nextUrl;
|
||||
|
||||
late bool enablePageStorage = widget.enablePageStorage;
|
||||
|
||||
Map<String, dynamic> get state => {
|
||||
'maxPage': _maxPage,
|
||||
'data': _data,
|
||||
@@ -878,7 +951,7 @@ class ComicListState extends State<ComicList> {
|
||||
};
|
||||
|
||||
void restoreState(Map<String, dynamic>? state) {
|
||||
if (state == null) {
|
||||
if (state == null || !enablePageStorage) {
|
||||
return;
|
||||
}
|
||||
_maxPage = state['maxPage'];
|
||||
@@ -892,8 +965,10 @@ class ComicListState extends State<ComicList> {
|
||||
}
|
||||
|
||||
void storeState() {
|
||||
if (enablePageStorage) {
|
||||
PageStorage.of(context).writeState(context, state);
|
||||
}
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
_data.clear();
|
||||
@@ -1122,7 +1197,7 @@ class ComicListState extends State<ComicList> {
|
||||
);
|
||||
}
|
||||
return SmoothCustomScrollView(
|
||||
key: const PageStorageKey('scroll'),
|
||||
key: enablePageStorage ? PageStorageKey('scroll$_page') : null,
|
||||
controller: widget.controller,
|
||||
slivers: [
|
||||
if (widget.leadingSliver != null) widget.leadingSliver!,
|
||||
@@ -1357,7 +1432,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,
|
||||
@@ -1406,10 +1481,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) {
|
||||
@@ -1417,7 +1492,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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:syntax_highlight/syntax_highlight.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/app_page_route.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
@@ -45,3 +46,4 @@ part 'side_bar.dart';
|
||||
part 'comic.dart';
|
||||
part 'effects.dart';
|
||||
part 'gesture.dart';
|
||||
part 'code.dart';
|
@@ -46,20 +46,27 @@ class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => isHovered = true),
|
||||
onExit: (_) => setState(() => isHovered = false),
|
||||
onEnter: (_) {
|
||||
setState(() {
|
||||
isHovered = true;
|
||||
});
|
||||
},
|
||||
onExit: (_) {
|
||||
setState(() {
|
||||
isHovered = false;
|
||||
});
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedScale(
|
||||
child: AnimatedPhysicalModel(
|
||||
duration: _fastAnimationDuration,
|
||||
scale: isHovered ? 1.1 : 1,
|
||||
elevation: isHovered ? 3 : 1,
|
||||
color: context.colorScheme.surface,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
});
|
||||
@@ -272,17 +277,19 @@ class _AnimatedImageState extends State<AnimatedImage>
|
||||
|
||||
if (_imageInfo != null) {
|
||||
if (widget.part != null) {
|
||||
return CustomPaint(
|
||||
result = CustomPaint(
|
||||
isComplex: true,
|
||||
painter: ImagePainter(
|
||||
image: _imageInfo!.image,
|
||||
part: widget.part!,
|
||||
fit: widget.fit ?? BoxFit.cover,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
result = RawImage(
|
||||
image: _imageInfo?.image,
|
||||
width: widget.width,
|
||||
@@ -301,6 +308,7 @@ class _AnimatedImageState extends State<AnimatedImage>
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
);
|
||||
}
|
||||
} else if (_lastException != null) {
|
||||
result = const Center(
|
||||
child: Icon(Icons.error),
|
||||
@@ -357,10 +365,13 @@ class ImagePainter extends CustomPainter {
|
||||
|
||||
final ImagePart part;
|
||||
|
||||
final BoxFit fit;
|
||||
|
||||
/// Render a part of the image.
|
||||
const ImagePainter({
|
||||
required this.image,
|
||||
this.part = const ImagePart(),
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -372,7 +383,8 @@ class ImagePainter extends CustomPainter {
|
||||
part.y2 ?? image.height.toDouble(),
|
||||
),
|
||||
);
|
||||
final Rect dst = Offset.zero & size;
|
||||
var fitted = applyBoxFit(fit, Size(src.width, src.height), size).destination;
|
||||
var dst = Alignment.center.inscribe(fitted, Offset.zero & size);
|
||||
canvas.drawImageRect(image, src, dst, Paint());
|
||||
}
|
||||
|
||||
|
242
lib/components/js_ui.dart
Normal 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),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
@@ -2,7 +2,10 @@ part of 'components.dart';
|
||||
|
||||
class SliverGridViewWithFixedItemHeight extends StatelessWidget {
|
||||
const SliverGridViewWithFixedItemHeight(
|
||||
{required this.delegate, required this.maxCrossAxisExtent, required this.itemHeight, super.key});
|
||||
{required this.delegate,
|
||||
required this.maxCrossAxisExtent,
|
||||
required this.itemHeight,
|
||||
super.key});
|
||||
|
||||
final SliverChildDelegate delegate;
|
||||
|
||||
@@ -62,7 +65,8 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
|
||||
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || oldDelegate.itemHeight != itemHeight) {
|
||||
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
|
||||
oldDelegate.itemHeight != itemHeight) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -70,28 +74,29 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
|
||||
}
|
||||
|
||||
class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]);
|
||||
SliverGridDelegateWithComics();
|
||||
|
||||
final bool useBriefMode;
|
||||
final bool useBriefMode = appdata.settings['comicDisplayMode'] == 'brief';
|
||||
|
||||
final double? scale;
|
||||
final double scale = (appdata.settings['comicTileScale'] as num).toDouble();
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
if (appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode) {
|
||||
if (useBriefMode) {
|
||||
return getBriefModeLayout(
|
||||
constraints,
|
||||
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(),
|
||||
scale,
|
||||
);
|
||||
} else {
|
||||
return getDetailedModeLayout(
|
||||
constraints,
|
||||
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(),
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale) {
|
||||
SliverGridLayout getDetailedModeLayout(
|
||||
SliverConstraints constraints, double scale) {
|
||||
const minCrossAxisExtent = 360;
|
||||
final itemHeight = 152 * scale;
|
||||
final width = constraints.crossAxisExtent;
|
||||
@@ -106,11 +111,14 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
reverseCrossAxis: false);
|
||||
}
|
||||
|
||||
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale) {
|
||||
SliverGridLayout getBriefModeLayout(
|
||||
SliverConstraints constraints, double scale) {
|
||||
final maxCrossAxisExtent = 192.0 * scale;
|
||||
const childAspectRatio = 0.68;
|
||||
const childAspectRatio = 0.64;
|
||||
const crossAxisSpacing = 0.0;
|
||||
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
|
||||
int crossAxisCount =
|
||||
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
|
||||
.ceil();
|
||||
// Ensure a minimum count of 1, can be zero and result in an infinite extent
|
||||
// below when the window size is 0.
|
||||
crossAxisCount = math.max(1, crossAxisCount);
|
||||
@@ -132,6 +140,11 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
if (oldDelegate is! SliverGridDelegateWithComics) return true;
|
||||
if (oldDelegate.scale != scale ||
|
||||
oldDelegate.useBriefMode != useBriefMode) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ class NetworkError extends StatelessWidget {
|
||||
required this.message,
|
||||
this.retry,
|
||||
this.withAppbar = true,
|
||||
this.buttonText,
|
||||
});
|
||||
|
||||
final String message;
|
||||
@@ -14,6 +15,8 @@ class NetworkError extends StatelessWidget {
|
||||
|
||||
final bool withAppbar;
|
||||
|
||||
final String? buttonText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var cfe = CloudflareException.fromString(message);
|
||||
@@ -54,13 +57,15 @@ class NetworkError extends StatelessWidget {
|
||||
if (cfe != null)
|
||||
FilledButton(
|
||||
onPressed: () => passCloudflare(
|
||||
CloudflareException.fromString(message)!, retry!),
|
||||
CloudflareException.fromString(message)!,
|
||||
retry!,
|
||||
),
|
||||
child: Text('Verify'.tl),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: retry,
|
||||
child: Text('Retry'.tl),
|
||||
child: Text(buttonText ?? 'Retry'.tl),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -318,21 +323,11 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
|
||||
}
|
||||
|
||||
Widget buildError(BuildContext context, String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error, maxLines: 3),
|
||||
const SizedBox(height: 12),
|
||||
Button.outlined(
|
||||
onPressed: () {
|
||||
reset();
|
||||
},
|
||||
child: const Text("Retry"),
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16);
|
||||
return NetworkError(
|
||||
withAppbar: false,
|
||||
message: error,
|
||||
retry: reset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -28,6 +28,9 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
var width = entries.first.icon == null ? 216.0 : 242.0;
|
||||
final size = MediaQuery.of(context).size;
|
||||
var left = location.dx;
|
||||
if (left < 10) {
|
||||
left = 10;
|
||||
}
|
||||
if (left + width > size.width - 10) {
|
||||
left = size.width - width - 10;
|
||||
}
|
||||
|
@@ -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(
|
||||
title != null
|
||||
? Appbar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: dismissible ? context.pop : null,
|
||||
),
|
||||
title: Text(title),
|
||||
title: Text(title!),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
this.content,
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -290,6 +294,7 @@ class ContentDialog extends StatelessWidget {
|
||||
: const EdgeInsets.symmetric(horizontal: 16),
|
||||
elevation: 2,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: Alignment.topCenter,
|
||||
@@ -397,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;
|
||||
}
|
||||
|
@@ -200,16 +200,18 @@ class NaviPaneState extends State<NaviPane>
|
||||
}
|
||||
|
||||
Widget buildMainView() {
|
||||
return Navigator(
|
||||
return HeroControllerScope(
|
||||
controller: MaterialApp.createMaterialHeroController(),
|
||||
child: Navigator(
|
||||
observers: [widget.observer],
|
||||
key: widget.navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
preventRebuild: false,
|
||||
isRootRoute: true,
|
||||
builder: (context) {
|
||||
return _NaviMainView(state: this);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,13 +364,11 @@ 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(
|
||||
child: showTitle
|
||||
? Row(
|
||||
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: icon,
|
||||
),
|
||||
@@ -395,13 +395,11 @@ 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(
|
||||
child: showTitle
|
||||
? Row(
|
||||
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: icon,
|
||||
),
|
||||
|
@@ -22,8 +22,15 @@ class PopUpWidget<T> extends PopupRoute<T> {
|
||||
Widget body = PopupIndicatorWidget(
|
||||
child: Container(
|
||||
decoration: showPopUp
|
||||
? const BoxDecoration(
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
boxShadow: context.brightness == ui.Brightness.dark ? [
|
||||
BoxShadow(
|
||||
color: Colors.white.withAlpha(50),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
] : null,
|
||||
)
|
||||
: null,
|
||||
clipBehavior: showPopUp ? Clip.antiAlias : Clip.none,
|
||||
@@ -86,7 +93,8 @@ class PopupIndicatorWidget extends InheritedWidget {
|
||||
}
|
||||
|
||||
Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async {
|
||||
return await Navigator.of(context, rootNavigator: true).push(PopUpWidget(widget));
|
||||
return await Navigator.of(context, rootNavigator: true)
|
||||
.push(PopUpWidget(widget));
|
||||
}
|
||||
|
||||
class PopUpWidgetScaffold extends StatefulWidget {
|
||||
@@ -127,9 +135,8 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_sharp),
|
||||
onPressed: () => context.canPop()
|
||||
? context.pop()
|
||||
: App.pop(),
|
||||
onPressed: () =>
|
||||
context.canPop() ? context.pop() : App.pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
@@ -148,6 +155,9 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
|
||||
),
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.axisDirection != AxisDirection.down) {
|
||||
return false;
|
||||
}
|
||||
if (notifications.metrics.pixels ==
|
||||
notifications.metrics.minScrollExtent &&
|
||||
!top) {
|
||||
|
@@ -98,10 +98,21 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
_controller.position.maxScrollExtent,
|
||||
);
|
||||
if (_futurePosition == old) return;
|
||||
_controller.animateTo(_futurePosition!,
|
||||
duration: _fastAnimationDuration, curve: Curves.linear);
|
||||
var target = _futurePosition!;
|
||||
_controller.animateTo(
|
||||
_futurePosition!,
|
||||
duration: _fastAnimationDuration,
|
||||
curve: Curves.linear,
|
||||
).then((_) {
|
||||
var current = _controller.position.pixels;
|
||||
if (current == target && current == _futurePosition) {
|
||||
_futurePosition = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ScrollControllerProvider._(
|
||||
controller: _controller,
|
||||
child: widget.builder(
|
||||
context,
|
||||
_controller,
|
||||
@@ -109,6 +120,27 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
? 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;
|
||||
}
|
||||
}
|
||||
|
@@ -60,7 +60,15 @@ class SideBarRoute<T> extends PopupRoute<T> {
|
||||
borderRadius: showSideBar
|
||||
? const BorderRadius.horizontal(left: Radius.circular(16))
|
||||
: null,
|
||||
color: Theme.of(context).colorScheme.surfaceTint),
|
||||
color: Theme.of(context).colorScheme.surfaceTint,
|
||||
boxShadow: context.brightness == ui.Brightness.dark ? [
|
||||
BoxShadow(
|
||||
color: Colors.white.withAlpha(50),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
constraints: BoxConstraints(maxWidth: sideBarWidth),
|
||||
height: MediaQuery.of(context).size.height,
|
||||
|
@@ -10,7 +10,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.1.0";
|
||||
final version = "1.2.3";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
|
@@ -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,
|
||||
|
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
class _Appdata {
|
||||
@@ -12,7 +13,7 @@ class _Appdata {
|
||||
|
||||
bool _isSavingData = false;
|
||||
|
||||
Future<void> saveData() async {
|
||||
Future<void> saveData([bool sync = true]) async {
|
||||
if (_isSavingData) {
|
||||
await Future.doWhile(() async {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
@@ -24,6 +25,9 @@ class _Appdata {
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
await file.writeAsString(data);
|
||||
_isSavingData = false;
|
||||
if (sync) {
|
||||
DataSync().uploadData();
|
||||
}
|
||||
}
|
||||
|
||||
void addSearchHistory(String keyword) {
|
||||
@@ -76,6 +80,28 @@ class _Appdata {
|
||||
};
|
||||
}
|
||||
|
||||
/// Following fields are related to device-specific data and should not be synced.
|
||||
static const _disableSync = [
|
||||
"proxy",
|
||||
"authorizationRequired",
|
||||
"customImageProcessing",
|
||||
"webdav",
|
||||
];
|
||||
|
||||
/// Sync data from another device
|
||||
void syncData(Map<String, dynamic> data) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
searchHistory = List.from(data['searchHistory'] ?? []);
|
||||
saveData();
|
||||
}
|
||||
|
||||
var implicitData = <String, dynamic>{};
|
||||
|
||||
void writeImplicitData() {
|
||||
@@ -113,15 +139,23 @@ class _Settings with ChangeNotifier {
|
||||
'cacheSize': 2048, // in MB
|
||||
'downloadThreads': 5,
|
||||
'enableLongPressToZoom': true,
|
||||
'checkUpdateOnStart': true,
|
||||
'checkUpdateOnStart': false,
|
||||
'limitImageWidth': true,
|
||||
'webdav': [], // empty means not configured
|
||||
'dataVersion': 0,
|
||||
'quickFavorite': null,
|
||||
'enableTurnPageByVolumeKey': true,
|
||||
'enableClockAndBatteryInfoInReader': true,
|
||||
'ignoreCertificateErrors': false,
|
||||
'quickCollectImage': 'No', // No, DoubleTap, Swipe
|
||||
'authorizationRequired': false,
|
||||
'onClickFavorite': 'viewDetail', // viewDetail, read
|
||||
'enableDnsOverrides': false,
|
||||
'dnsOverrides': {},
|
||||
'enableCustomImageProcessing': false,
|
||||
'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) {
|
||||
@@ -138,3 +172,21 @@ class _Settings with ChangeNotifier {
|
||||
return _data.toString();
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCustomImageProcessing = '''
|
||||
/**
|
||||
* Process an image
|
||||
* @param image {ArrayBuffer} - The image to process
|
||||
* @param cid {string} - The comic ID
|
||||
* @param eid {string} - The episode ID
|
||||
* @param page {number} - The page number
|
||||
* @param sourceKey {string} - The source key
|
||||
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
|
||||
*/
|
||||
function processImage(image, cid, eid, page, sourceKey) {
|
||||
let image = new Promise((resolve, reject) => {
|
||||
resolve(image);
|
||||
});
|
||||
return image;
|
||||
}
|
||||
''';
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -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 {
|
||||
@@ -145,7 +146,7 @@ class ComicDetails with HistoryMixin {
|
||||
|
||||
final int? likesCount;
|
||||
|
||||
final int? commentsCount;
|
||||
final int? commentCount;
|
||||
|
||||
final String? uploader;
|
||||
|
||||
@@ -189,7 +190,7 @@ class ComicDetails with HistoryMixin {
|
||||
subId = json["subId"],
|
||||
likesCount = json["likesCount"],
|
||||
isLiked = json["isLiked"],
|
||||
commentsCount = json["commentsCount"],
|
||||
commentCount = json["commentCount"],
|
||||
uploader = json["uploader"],
|
||||
uploadTime = json["uploadTime"],
|
||||
updateTime = json["updateTime"],
|
||||
@@ -216,7 +217,7 @@ class ComicDetails with HistoryMixin {
|
||||
"subId": subId,
|
||||
"isLiked": isLiked,
|
||||
"likesCount": likesCount,
|
||||
"commentsCount": commentsCount,
|
||||
"commentsCount": commentCount,
|
||||
"uploader": uploader,
|
||||
"uploadTime": uploadTime,
|
||||
"updateTime": updateTime,
|
||||
@@ -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 {
|
||||
|
@@ -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("-", ".");
|
||||
@@ -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() {
|
||||
|
@@ -28,4 +28,12 @@ class ComicType {
|
||||
}
|
||||
|
||||
static const local = ComicType(0);
|
||||
|
||||
factory ComicType.fromKey(String key) {
|
||||
if(key == "local") {
|
||||
return local;
|
||||
} else {
|
||||
return ComicType(key.hashCode);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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";
|
||||
|
||||
/// Pages for all comics is started from this value.
|
||||
const firstPage = 1;
|
||||
|
||||
/// Chapters for all comics is started from this value.
|
||||
const firstChapter = 1;
|
@@ -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);
|
||||
}
|
||||
|
@@ -73,6 +73,7 @@ class FavoriteItem implements Comic {
|
||||
|
||||
@override
|
||||
String get description {
|
||||
var time = this.time.substring(0, 10);
|
||||
return appdata.settings['comicDisplayMode'] == 'detailed'
|
||||
? "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}"
|
||||
: "${type.comicSource?.name ?? "Unknown"} | $time";
|
||||
@@ -593,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("""
|
||||
|
@@ -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;
|
||||
|
||||
@@ -201,9 +212,12 @@ class HistoryManager with ChangeNotifier {
|
||||
|
||||
Map<String, bool>? _cachedHistory;
|
||||
|
||||
static const _kMaxHistoryLength = 200;
|
||||
bool isInitialized = false;
|
||||
|
||||
Future<void> init() async {
|
||||
if (isInitialized) {
|
||||
return;
|
||||
}
|
||||
_db = sqlite3.open("${App.dataPath}/history.db");
|
||||
|
||||
_db.execute("""
|
||||
@@ -222,18 +236,14 @@ class HistoryManager with ChangeNotifier {
|
||||
""");
|
||||
|
||||
notifyListeners();
|
||||
ImageFavoriteManager().init();
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
/// add history. if exists, update time.
|
||||
///
|
||||
/// This function would be called when user start reading.
|
||||
Future<void> addHistory(History newItem) async {
|
||||
while(count() >= _kMaxHistoryLength) {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where time == (select min(time) from history);
|
||||
""");
|
||||
}
|
||||
_db.execute("""
|
||||
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
@@ -327,6 +337,7 @@ class HistoryManager with ChangeNotifier {
|
||||
}
|
||||
|
||||
void close() {
|
||||
isInitialized = false;
|
||||
_db.dispose();
|
||||
}
|
||||
}
|
||||
|
535
lib/foundation/image_favorites.dart
Normal 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;
|
||||
}
|
@@ -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> {
|
||||
@@ -27,10 +28,8 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
screen.size.height * _normalComicImageRatio,
|
||||
);
|
||||
} else {
|
||||
_effectiveScreenWidth = max(
|
||||
_effectiveScreenWidth ?? 0,
|
||||
screen.size.width
|
||||
);
|
||||
_effectiveScreenWidth =
|
||||
max(_effectiveScreenWidth ?? 0, screen.size.width);
|
||||
}
|
||||
}
|
||||
if (_effectiveScreenWidth! < _minComicImageWidth) {
|
||||
@@ -79,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;
|
||||
@@ -101,7 +106,7 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
}
|
||||
|
||||
if (stop) {
|
||||
throw Exception("Image loading is stopped");
|
||||
throw const _ImageLoadingStopException();
|
||||
}
|
||||
|
||||
if (data!.isEmpty) {
|
||||
@@ -110,7 +115,10 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromUint8List(data);
|
||||
return await decode(buffer, getTargetSize: _getTargetSize);
|
||||
return await decode(
|
||||
buffer,
|
||||
getTargetSize: enableResize ? _getTargetSize : null,
|
||||
);
|
||||
} catch (e) {
|
||||
await CacheManager().delete(this.key);
|
||||
if (data.length < 2 * 1024) {
|
||||
@@ -125,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;
|
||||
|
||||
@@ -151,6 +165,12 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
String toString() {
|
||||
return "$runtimeType($key)";
|
||||
}
|
||||
|
||||
bool get enableResize => false;
|
||||
}
|
||||
|
||||
typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List);
|
||||
|
||||
class _ImageLoadingStopException implements Exception {
|
||||
const _ImageLoadingStopException();
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
155
lib/foundation/image_provider/image_favorites_provider.dart
Normal 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}";
|
||||
}
|
@@ -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}).";
|
||||
|
@@ -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';
|
||||
@@ -28,7 +28,7 @@ class LocalFavoriteImageProvider
|
||||
}
|
||||
|
||||
@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,7 +37,9 @@ 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,
|
||||
@@ -52,7 +54,8 @@ class LocalFavoriteImageProvider
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
Future<LocalFavoriteImageProvider> obtainKey(
|
||||
ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
|
@@ -1,15 +1,18 @@
|
||||
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';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'reader_image.dart' as image_provider;
|
||||
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;
|
||||
|
||||
@@ -19,28 +22,96 @@ 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);
|
||||
if (await file.exists()) {
|
||||
return file.readAsBytes();
|
||||
}
|
||||
imageBytes = await file.readAsBytes();
|
||||
} else {
|
||||
throw "Error: File not found.";
|
||||
}
|
||||
|
||||
} else {
|
||||
await for (var event
|
||||
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
|
||||
checkStop();
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: event.currentBytes,
|
||||
expectedTotalBytes: event.totalBytes,
|
||||
));
|
||||
if (event.imageBytes != null) {
|
||||
return event.imageBytes!;
|
||||
imageBytes = event.imageBytes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imageBytes == null) {
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
if (appdata.settings['enableCustomImageProcessing']) {
|
||||
var script = appdata.settings['customImageProcessing'].toString();
|
||||
if (!script.contains('async function processImage')) {
|
||||
return imageBytes;
|
||||
}
|
||||
var func = JsEngine().runCode('''
|
||||
(() => {
|
||||
$script
|
||||
return processImage;
|
||||
})()
|
||||
''');
|
||||
if (func is JSInvokable) {
|
||||
var autoFreeFunc = JSAutoFreeFunction(func);
|
||||
var result = autoFreeFunc([imageBytes, cid, eid, page, sourceKey]);
|
||||
if (result is Uint8List) {
|
||||
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!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ReaderImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -49,4 +120,7 @@ class ReaderImageProvider
|
||||
|
||||
@override
|
||||
String get key => "$imageKey@$sourceKey@$cid@$eid";
|
||||
|
||||
@override
|
||||
bool get enableResize => true;
|
||||
}
|
||||
|
@@ -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;
|
||||
@@ -58,6 +59,11 @@ class JsEngine with _JSEngineApi {
|
||||
JsEngine().init();
|
||||
}
|
||||
|
||||
void resetDio() {
|
||||
_dio = AppDio(BaseOptions(
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
if (!_closed) {
|
||||
return;
|
||||
@@ -88,7 +94,6 @@ class JsEngine with _JSEngineApi {
|
||||
String method = message["method"] as String;
|
||||
switch (method) {
|
||||
case "log":
|
||||
{
|
||||
String level = message["level"];
|
||||
Log.addLog(
|
||||
switch (level) {
|
||||
@@ -99,15 +104,11 @@ class JsEngine with _JSEngineApi {
|
||||
},
|
||||
message["title"],
|
||||
message["content"].toString());
|
||||
}
|
||||
case 'load_data':
|
||||
{
|
||||
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') {
|
||||
@@ -117,56 +118,47 @@ class JsEngine with _JSEngineApi {
|
||||
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();
|
||||
}
|
||||
case 'http':
|
||||
{
|
||||
return _http(Map.from(message));
|
||||
}
|
||||
case 'html':
|
||||
{
|
||||
return handleHtmlCallback(Map.from(message));
|
||||
}
|
||||
case 'convert':
|
||||
{
|
||||
return _convert(Map.from(message));
|
||||
}
|
||||
case "random":
|
||||
{
|
||||
return _random(
|
||||
message["min"] ?? 0,
|
||||
message["max"] ?? 1,
|
||||
message["type"],
|
||||
);
|
||||
}
|
||||
case "cookie":
|
||||
{
|
||||
return handleCookieCallback(Map.from(message));
|
||||
}
|
||||
case "uuid":
|
||||
{
|
||||
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'] ??
|
||||
source.settings?[settingKey]!['default'] ??
|
||||
(throw "Setting not found: $settingKey");
|
||||
}
|
||||
case "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;
|
||||
@@ -198,7 +190,8 @@ class JsEngine with _JSEngineApi {
|
||||
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
|
||||
},
|
||||
);
|
||||
dio.interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
dio.interceptors
|
||||
.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
dio.interceptors.add(LogInterceptor());
|
||||
}
|
||||
response = await dio!.request(req["url"],
|
||||
@@ -682,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();
|
||||
});
|
||||
}
|
||||
|
@@ -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;
|
||||
@@ -76,15 +78,16 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
cover,
|
||||
));
|
||||
|
||||
String get baseDir => (directory.contains('/') || directory.contains('\\')) ? directory : FilePath.join(LocalManager().path, directory);
|
||||
String get baseDir => (directory.contains('/') || directory.contains('\\'))
|
||||
? directory
|
||||
: FilePath.join(LocalManager().path, directory);
|
||||
|
||||
@override
|
||||
String get description => "";
|
||||
|
||||
@override
|
||||
String get sourceKey => comicType == ComicType.local
|
||||
? "local"
|
||||
: comicType.sourceKey;
|
||||
String get sourceKey =>
|
||||
comicType == ComicType.local ? "local" : comicType.sourceKey;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -112,11 +115,14 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
chapters: chapters,
|
||||
initialChapter: history?.ep,
|
||||
initialPage: history?.page,
|
||||
history: history ?? History.fromModel(
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: this,
|
||||
ep: 0,
|
||||
page: 0,
|
||||
),
|
||||
author: subtitle,
|
||||
tags: tags,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -153,6 +159,15 @@ class LocalManager with ChangeNotifier {
|
||||
|
||||
Directory get directory => Directory(path);
|
||||
|
||||
void _checkNoMedia() {
|
||||
if (App.isAndroid) {
|
||||
var file = File(FilePath.join(path, '.nomedia'));
|
||||
if (!file.existsSync()) {
|
||||
file.createSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return error message if failed
|
||||
Future<String?> setNewPath(String newPath) async {
|
||||
var newDir = Directory(newPath);
|
||||
@@ -167,13 +182,15 @@ class LocalManager with ChangeNotifier {
|
||||
directory,
|
||||
newDir,
|
||||
);
|
||||
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(newPath);
|
||||
await File(FilePath.join(App.dataPath, 'local_path'))
|
||||
.writeAsString(newPath);
|
||||
} catch (e, s) {
|
||||
Log.error("IO", e, s);
|
||||
return e.toString();
|
||||
}
|
||||
await directory.deleteContents(recursive: true);
|
||||
path = newPath;
|
||||
_checkNoMedia();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -187,7 +204,8 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
} else if (App.isIOS) {
|
||||
var oldPath = FilePath.join(App.dataPath, 'local');
|
||||
if (Directory(oldPath).existsSync() && Directory(oldPath).listSync().isNotEmpty) {
|
||||
if (Directory(oldPath).existsSync() &&
|
||||
Directory(oldPath).listSync().isNotEmpty) {
|
||||
return oldPath;
|
||||
} else {
|
||||
var directory = await getApplicationDocumentsDirectory();
|
||||
@@ -198,6 +216,18 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkPathValidation() async {
|
||||
var testFile = File(FilePath.join(path, 'venera_test'));
|
||||
try {
|
||||
testFile.createSync();
|
||||
testFile.deleteSync();
|
||||
} catch (e) {
|
||||
Log.error("IO",
|
||||
"Failed to create test file in local path: $e\nUsing default path instead.");
|
||||
path = await findDefaultPath();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open(
|
||||
'${App.dataPath}/local.db',
|
||||
@@ -229,10 +259,11 @@ class LocalManager with ChangeNotifier {
|
||||
if (!directory.existsSync()) {
|
||||
await directory.create();
|
||||
}
|
||||
}
|
||||
catch(e, s) {
|
||||
} catch (e, s) {
|
||||
Log.error("IO", "Failed to create local folder: $e", s);
|
||||
}
|
||||
_checkPathValidation();
|
||||
_checkNoMedia();
|
||||
restoreDownloadingTasks();
|
||||
}
|
||||
|
||||
@@ -242,7 +273,8 @@ class LocalManager with ChangeNotifier {
|
||||
SELECT id FROM comics WHERE comic_type = ?
|
||||
ORDER BY CAST(id AS INTEGER) DESC
|
||||
LIMIT 1;
|
||||
''', [type.value],
|
||||
''',
|
||||
[type.value],
|
||||
);
|
||||
if (res.isEmpty) {
|
||||
return '1';
|
||||
@@ -357,10 +389,9 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
var comic = find(id, type) ?? (throw "Comic Not Found");
|
||||
var directory = Directory(comic.baseDir);
|
||||
if (comic.chapters != null) {
|
||||
var cid = ep is int
|
||||
? comic.chapters!.keys.elementAt(ep - 1)
|
||||
: (ep as String);
|
||||
if (comic.hasChapters) {
|
||||
var cid =
|
||||
ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
|
||||
directory = Directory(FilePath.join(directory.path, cid));
|
||||
}
|
||||
var files = <File>[];
|
||||
@@ -451,6 +482,7 @@ class LocalManager with ChangeNotifier {
|
||||
void restoreDownloadingTasks() {
|
||||
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
|
||||
if (file.existsSync()) {
|
||||
try {
|
||||
var tasks = jsonDecode(file.readAsStringSync());
|
||||
for (var e in tasks) {
|
||||
var task = DownloadTask.fromJson(e);
|
||||
@@ -458,6 +490,10 @@ class LocalManager with ChangeNotifier {
|
||||
downloadingTasks.add(task);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
file.delete();
|
||||
Log.error("LocalManager", "Failed to restore downloading tasks: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +510,7 @@ class LocalManager with ChangeNotifier {
|
||||
dir.deleteIgnoreError(recursive: true);
|
||||
}
|
||||
// Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
|
||||
if (c.comicType == ComicType.local) {
|
||||
if (HistoryManager().findSync(c.id, c.comicType) != null) {
|
||||
HistoryManager().remove(c.id, c.comicType);
|
||||
}
|
||||
@@ -481,6 +518,7 @@ class LocalManager with ChangeNotifier {
|
||||
for (var f in folders) {
|
||||
LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType);
|
||||
}
|
||||
}
|
||||
remove(c.id, c.comicType);
|
||||
notifyListeners();
|
||||
}
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:rhttp/rhttp.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
@@ -6,23 +8,45 @@ import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/tags_translation.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'foundation/appdata.dart';
|
||||
|
||||
Future<void> init() async {
|
||||
await SAFTaskWorker().init();
|
||||
await AppTranslation.init();
|
||||
await appdata.init();
|
||||
await App.init();
|
||||
await HistoryManager().init();
|
||||
await TagsTranslation.readData();
|
||||
await LocalFavoritesManager().init();
|
||||
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
|
||||
await JsEngine().init();
|
||||
await ComicSource.init();
|
||||
await LocalManager().init();
|
||||
CacheManager().setLimitSize(appdata.settings['cacheSize']);
|
||||
extension _FutureInit<T> on Future<T> {
|
||||
/// Prevent unhandled exception
|
||||
///
|
||||
/// A unhandled exception occurred in init() will cause the app to crash.
|
||||
Future<void> wait() async {
|
||||
try {
|
||||
await this;
|
||||
} catch (e, s) {
|
||||
Log.error("init", "$e\n$s");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await Rhttp.init();
|
||||
await SAFTaskWorker().init().wait();
|
||||
await AppTranslation.init().wait();
|
||||
await appdata.init().wait();
|
||||
await App.init().wait();
|
||||
await HistoryManager().init().wait();
|
||||
await TagsTranslation.readData().wait();
|
||||
await LocalFavoritesManager().init().wait();
|
||||
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
|
||||
await JsEngine().init().wait();
|
||||
await ComicSource.init().wait();
|
||||
await LocalManager().init().wait();
|
||||
CacheManager().setLimitSize(appdata.settings['cacheSize']);
|
||||
if (App.isAndroid) {
|
||||
handleLinks();
|
||||
}
|
||||
FlutterError.onError = (details) {
|
||||
Log.error(
|
||||
"Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
};
|
||||
}
|
@@ -1,14 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'package:desktop_webview_window/desktop_webview_window.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flex_seed_scheme/flex_seed_scheme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:rhttp/rhttp.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/auth_page.dart';
|
||||
import 'package:venera/pages/main_page.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'components/components.dart';
|
||||
@@ -18,21 +17,11 @@ import 'foundation/appdata.dart';
|
||||
import 'init.dart';
|
||||
|
||||
void main(List<String> args) {
|
||||
if (runWebViewTitleBarWidget(args)) {
|
||||
return;
|
||||
}
|
||||
if (runWebViewTitleBarWidget(args)) return;
|
||||
overrideIO(() {
|
||||
runZonedGuarded(() async {
|
||||
await Rhttp.init();
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await init();
|
||||
if (App.isAndroid) {
|
||||
handleLinks();
|
||||
}
|
||||
FlutterError.onError = (details) {
|
||||
Log.error(
|
||||
"Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
};
|
||||
runApp(const MyApp());
|
||||
if (App.isDesktop) {
|
||||
await windowManager.ensureInitialized();
|
||||
@@ -55,7 +44,7 @@ void main(List<String> args) {
|
||||
});
|
||||
}
|
||||
}, (error, stack) {
|
||||
Log.error("Unhandled Exception", "$error\n$stack");
|
||||
Log.error("Unhandled Exception", error, stack);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -156,40 +145,44 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
home = const MainPage();
|
||||
}
|
||||
return DynamicColorBuilder(builder: (light, dark) {
|
||||
if (appdata.settings['color'] != 'system' || light == null || dark == null) {
|
||||
var color = translateColorSetting();
|
||||
light = ColorScheme.fromSeed(
|
||||
seedColor: color,
|
||||
);
|
||||
dark = ColorScheme.fromSeed(
|
||||
seedColor: color,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
Color? primary, secondary, tertiary;
|
||||
if (appdata.settings['color'] != 'system' ||
|
||||
light == null ||
|
||||
dark == null) {
|
||||
primary = translateColorSetting();
|
||||
} else {
|
||||
primary = light.primary;
|
||||
secondary = light.secondary;
|
||||
tertiary = light.tertiary;
|
||||
}
|
||||
return MaterialApp(
|
||||
home: home,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: light.copyWith(
|
||||
surface: Colors.white,
|
||||
colorScheme: SeedColorScheme.fromSeeds(
|
||||
primaryKey: primary,
|
||||
secondaryKey: secondary,
|
||||
tertiaryKey: tertiary,
|
||||
tones: FlexTones.vividBackground(Brightness.light),
|
||||
),
|
||||
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
|
||||
),
|
||||
navigatorKey: App.rootNavigatorKey,
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: dark.copyWith(
|
||||
surface: Colors.black,
|
||||
colorScheme: SeedColorScheme.fromSeeds(
|
||||
primaryKey: primary,
|
||||
secondaryKey: secondary,
|
||||
tertiaryKey: tertiary,
|
||||
brightness: Brightness.dark,
|
||||
tones: FlexTones.vividBackground(Brightness.dark),
|
||||
),
|
||||
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
|
||||
),
|
||||
themeMode: switch (appdata.settings['theme_mode']) {
|
||||
'light' => ThemeMode.light,
|
||||
'dark' => ThemeMode.dark,
|
||||
_ => ThemeMode.system
|
||||
},
|
||||
localizationsDelegates: const [
|
||||
localizationsDelegates: [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
locale: () {
|
||||
@@ -205,14 +198,14 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
};
|
||||
}(),
|
||||
supportedLocales: const [
|
||||
Locale('en'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
Locale('en'),
|
||||
],
|
||||
builder: (context, widget) {
|
||||
ErrorWidget.builder = (details) {
|
||||
Log.error(
|
||||
"Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
Log.error("Unhandled Exception",
|
||||
"${details.exception}\n${details.stack}");
|
||||
return Material(
|
||||
child: Center(
|
||||
child: Text(details.exception.toString()),
|
||||
|
@@ -108,7 +108,6 @@ class MyLogInterceptor implements Interceptor {
|
||||
|
||||
class AppDio with DioMixin {
|
||||
String? _proxy = proxy;
|
||||
static bool get ignoreCertificateErrors => appdata.settings['ignoreCertificateErrors'] == true;
|
||||
|
||||
AppDio([BaseOptions? options]) {
|
||||
this.options = options ?? BaseOptions();
|
||||
@@ -116,9 +115,6 @@ class AppDio with DioMixin {
|
||||
proxySettings: proxy == null
|
||||
? const rhttp.ProxySettings.noProxy()
|
||||
: rhttp.ProxySettings.proxy(proxy!),
|
||||
tlsSettings: rhttp.TlsSettings(
|
||||
verifyCertificates: !ignoreCertificateErrors,
|
||||
),
|
||||
));
|
||||
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
interceptors.add(NetworkCacheManager());
|
||||
@@ -196,9 +192,6 @@ class AppDio with DioMixin {
|
||||
proxySettings: proxy == null
|
||||
? const rhttp.ProxySettings.noProxy()
|
||||
: rhttp.ProxySettings.proxy(proxy!),
|
||||
tlsSettings: rhttp.TlsSettings(
|
||||
verifyCertificates: !ignoreCertificateErrors,
|
||||
),
|
||||
));
|
||||
}
|
||||
try {
|
||||
@@ -222,6 +215,22 @@ class AppDio with DioMixin {
|
||||
class RHttpAdapter implements HttpClientAdapter {
|
||||
rhttp.ClientSettings settings;
|
||||
|
||||
static Map<String, List<String>> _getOverrides() {
|
||||
if (!appdata.settings['enableDnsOverrides'] == true) {
|
||||
return {};
|
||||
}
|
||||
var config = appdata.settings["dnsOverrides"];
|
||||
var result = <String, List<String>>{};
|
||||
if (config is Map) {
|
||||
for (var entry in config.entries) {
|
||||
if (entry.key is String && entry.value is String) {
|
||||
result[entry.key] = [entry.value];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
|
||||
settings = settings.copyWith(
|
||||
redirectSettings: const rhttp.RedirectSettings.limited(5),
|
||||
@@ -231,8 +240,9 @@ class RHttpAdapter implements HttpClientAdapter {
|
||||
keepAlivePing: Duration(seconds: 30),
|
||||
),
|
||||
throwOnStatusCode: false,
|
||||
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
|
||||
tlsSettings: rhttp.TlsSettings(
|
||||
verifyCertificates: !AppDio.ignoreCertificateErrors,
|
||||
sni: appdata.settings['sni'] != false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
|
||||
class NetworkCache {
|
||||
final Uri uri;
|
||||
@@ -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,11 +113,11 @@ 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",
|
||||
);
|
||||
var dio = Dio();
|
||||
var dio = AppDio();
|
||||
var response = await dio.fetch(o);
|
||||
if (response.statusCode == 200 &&
|
||||
compareHeaders(cache.responseHeaders, response.headers.map)) {
|
||||
@@ -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,
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/webview.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
@@ -120,9 +122,18 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
|
||||
var webview = DesktopWebview(
|
||||
initialUrl: url,
|
||||
onTitleChange: (title, controller) async {
|
||||
var res = await controller.evaluateJavascript(
|
||||
"document.head.innerHTML.includes('#challenge-success-text')");
|
||||
if (res == 'false') {
|
||||
var head =
|
||||
await controller.evaluateJavascript("document.head.innerHTML") ??
|
||||
"";
|
||||
Log.info("Cloudflare", "Checking head: $head");
|
||||
var isChallenging = head.contains('#challenge-success-text') ||
|
||||
head.contains("#challenge-error-text") ||
|
||||
head.contains("#challenge-form");
|
||||
if (!isChallenging) {
|
||||
Log.info(
|
||||
"Cloudflare",
|
||||
"Cloudflare is passed due to there is no challenge css",
|
||||
);
|
||||
var ua = controller.userAgent;
|
||||
if (ua != null) {
|
||||
appdata.implicitData['ua'] = ua;
|
||||
@@ -137,30 +148,47 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
|
||||
onFinished();
|
||||
}
|
||||
},
|
||||
onClose: onFinished,
|
||||
);
|
||||
webview.open();
|
||||
} else {
|
||||
await App.rootContext.to(
|
||||
() => AppWebview(
|
||||
initialUrl: url,
|
||||
singlePage: true,
|
||||
onLoadStop: (controller) async {
|
||||
var res = await controller.platform.evaluateJavascript(
|
||||
source:
|
||||
"document.head.innerHTML.includes('#challenge-success-text')");
|
||||
if (res == false) {
|
||||
void check(InAppWebViewController controller) async {
|
||||
var head = await controller.evaluateJavascript(
|
||||
source: "document.head.innerHTML") as String;
|
||||
Log.info("Cloudflare", "Checking head: $head");
|
||||
var isChallenging = head.contains('#challenge-success-text') ||
|
||||
head.contains("#challenge-error-text") ||
|
||||
head.contains("#challenge-form");
|
||||
if (!isChallenging) {
|
||||
Log.info(
|
||||
"Cloudflare",
|
||||
"Cloudflare is passed due to there is no challenge css",
|
||||
);
|
||||
var ua = await controller.getUA();
|
||||
if (ua != null) {
|
||||
appdata.implicitData['ua'] = ua;
|
||||
appdata.writeImplicitData();
|
||||
}
|
||||
var cookies = await controller.getCookies(url) ?? [];
|
||||
if(cookies.firstWhereOrNull((element) => element.name == 'cf_clearance') == null) {
|
||||
if (cookies.firstWhereOrNull(
|
||||
(element) => element.name == 'cf_clearance') ==
|
||||
null) {
|
||||
return;
|
||||
}
|
||||
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
|
||||
App.rootPop();
|
||||
}
|
||||
}
|
||||
|
||||
await App.rootContext.to(
|
||||
() => AppWebview(
|
||||
initialUrl: url,
|
||||
singlePage: true,
|
||||
onTitleChange: (title, controller) async {
|
||||
check(controller);
|
||||
},
|
||||
onLoadStop: (controller) async {
|
||||
check(controller);
|
||||
},
|
||||
onStarted: (controller) async {
|
||||
var ua = await controller.getUA();
|
||||
|
@@ -59,6 +59,16 @@ abstract class DownloadTask with ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is DownloadTask &&
|
||||
other.id == id &&
|
||||
other.comicType == comicType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, comicType);
|
||||
}
|
||||
|
||||
class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
@@ -146,14 +156,19 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
|
||||
String? _cover;
|
||||
|
||||
/// All images to download, key is chapter name
|
||||
Map<String, List<String>>? _images;
|
||||
|
||||
/// Downloaded image count
|
||||
int _downloadedCount = 0;
|
||||
|
||||
/// Total image count
|
||||
int _totalCount = 0;
|
||||
|
||||
/// Current downloading image index
|
||||
int _index = 0;
|
||||
|
||||
/// Current downloading chapter, index of [_images]
|
||||
int _chapter = 0;
|
||||
|
||||
var tasks = <int, _ImageDownloadWrapper>{};
|
||||
@@ -180,10 +195,10 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
if (comic!.chapters != null) {
|
||||
saveTo = Directory(FilePath.join(
|
||||
path!,
|
||||
comic!.chapters!.keys.elementAt(_chapter),
|
||||
_images!.keys.elementAt(_chapter),
|
||||
));
|
||||
if (!saveTo.existsSync()) {
|
||||
saveTo.createSync();
|
||||
saveTo.createSync(recursive: true);
|
||||
}
|
||||
} else {
|
||||
saveTo = Directory(path!);
|
||||
@@ -215,7 +230,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
runRecorder();
|
||||
|
||||
if (comic == null) {
|
||||
var res = await runWithRetry(() async {
|
||||
_message = "Fetching comic info...";
|
||||
notifyListeners();
|
||||
var res = await _runWithRetry(() async {
|
||||
var r = await source.loadComicInfo!(comicId);
|
||||
if (r.error) {
|
||||
throw r.errorMessage!;
|
||||
@@ -255,7 +272,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
await LocalManager().saveCurrentDownloadingTasks();
|
||||
|
||||
if (_cover == null) {
|
||||
var res = await runWithRetry(() async {
|
||||
_message = "Downloading cover...";
|
||||
notifyListeners();
|
||||
var res = await _runWithRetry(() async {
|
||||
Uint8List? data;
|
||||
await for (var progress
|
||||
in ImageDownloader.loadThumbnail(comic!.cover, source.key)) {
|
||||
@@ -267,8 +286,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
throw "Failed to download cover";
|
||||
}
|
||||
var fileType = detectFileType(data);
|
||||
var file =
|
||||
File(FilePath.join(path!, "cover${fileType.ext}"));
|
||||
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
|
||||
file.writeAsBytesSync(data);
|
||||
return "file://${file.path}";
|
||||
});
|
||||
@@ -285,7 +303,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
|
||||
if (_images == null) {
|
||||
if (comic!.chapters == null) {
|
||||
var res = await runWithRetry(() async {
|
||||
_message = "Fetching image list...";
|
||||
notifyListeners();
|
||||
var res = await _runWithRetry(() async {
|
||||
var r = await source.loadComicPages!(comicId, null);
|
||||
if (r.error) {
|
||||
throw r.errorMessage!;
|
||||
@@ -307,6 +327,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
} else {
|
||||
_images = {};
|
||||
_totalCount = 0;
|
||||
int cpCount = 0;
|
||||
int totalCpCount = chapters?.length ?? comic!.chapters!.length;
|
||||
for (var i in comic!.chapters!.keys) {
|
||||
if (chapters != null && !chapters!.contains(i)) {
|
||||
continue;
|
||||
@@ -315,7 +337,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
_totalCount += _images![i]!.length;
|
||||
continue;
|
||||
}
|
||||
var res = await runWithRetry(() async {
|
||||
_message = "Fetching image list ($cpCount/$totalCpCount)...";
|
||||
notifyListeners();
|
||||
var res = await _runWithRetry(() async {
|
||||
var r = await source.loadComicPages!(comicId, i);
|
||||
if (r.error) {
|
||||
throw r.errorMessage!;
|
||||
@@ -453,8 +477,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
}).toList(),
|
||||
directory: Directory(path!).name,
|
||||
chapters: comic!.chapters,
|
||||
cover:
|
||||
File(_cover!.split("file://").last).name,
|
||||
cover: File(_cover!.split("file://").last).name,
|
||||
comicType: ComicType(source.key.hashCode),
|
||||
downloadedChapters: chapters ?? [],
|
||||
createdAt: DateTime.now(),
|
||||
@@ -473,7 +496,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
int get hashCode => Object.hash(comicId, source.key);
|
||||
}
|
||||
|
||||
Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
|
||||
Future<Res<T>> _runWithRetry<T>(Future<T> Function() task,
|
||||
{int retry = 3}) async {
|
||||
for (var i = 0; i < retry; i++) {
|
||||
try {
|
||||
@@ -482,6 +505,7 @@ Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
|
||||
if (i == retry - 1) {
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
await Future.delayed(Duration(seconds: i + 1));
|
||||
}
|
||||
}
|
||||
throw UnimplementedError();
|
||||
|
@@ -1,349 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.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/state_controller.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/pages/webview.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
class AccountsPageLogic extends StateController {
|
||||
final _reLogin = <String, bool>{};
|
||||
}
|
||||
|
||||
class AccountsPage extends StatelessWidget {
|
||||
const AccountsPage({super.key});
|
||||
|
||||
AccountsPageLogic get logic => StateController.find<AccountsPageLogic>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var body = StateBuilder<AccountsPageLogic>(
|
||||
init: AccountsPageLogic(),
|
||||
builder: (logic) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(title: Text("Accounts".tl)),
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate(
|
||||
buildContent(context).toList(),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Widget> buildContent(BuildContext context) sync* {
|
||||
var sources = ComicSource.all().where((element) => element.account != null);
|
||||
if (sources.isEmpty) return;
|
||||
|
||||
for (var element in sources) {
|
||||
final bool logged = element.isLogged;
|
||||
yield Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
element.name,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
);
|
||||
if (!logged) {
|
||||
yield ListTile(
|
||||
title: Text("Log in".tl),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () async {
|
||||
await context.to(
|
||||
() => _LoginPage(
|
||||
config: element.account!,
|
||||
source: element,
|
||||
),
|
||||
);
|
||||
element.saveData();
|
||||
ComicSource.notifyListeners();
|
||||
logic.update();
|
||||
},
|
||||
);
|
||||
}
|
||||
if (logged) {
|
||||
for (var item in element.account!.infoItems) {
|
||||
if (item.builder != null) {
|
||||
yield item.builder!(context);
|
||||
} else {
|
||||
yield ListTile(
|
||||
title: Text(item.title.tl),
|
||||
subtitle: item.data == null ? null : Text(item.data!()),
|
||||
onTap: item.onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (element.data["account"] is List) {
|
||||
bool loading = logic._reLogin[element.key] == true;
|
||||
yield ListTile(
|
||||
title: Text("Re-login".tl),
|
||||
subtitle: Text("Click if login expired".tl),
|
||||
onTap: () async {
|
||||
if (element.data["account"] == null) {
|
||||
context.showMessage(message: "No data".tl);
|
||||
return;
|
||||
}
|
||||
logic._reLogin[element.key] = true;
|
||||
logic.update();
|
||||
final List account = element.data["account"];
|
||||
var res = await element.account!.login!(account[0], account[1]);
|
||||
if (res.error) {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
} else {
|
||||
context.showMessage(message: "Success".tl);
|
||||
}
|
||||
logic._reLogin[element.key] = false;
|
||||
logic.update();
|
||||
},
|
||||
trailing: loading
|
||||
? const SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
);
|
||||
}
|
||||
yield ListTile(
|
||||
title: Text("Log out".tl),
|
||||
onTap: () {
|
||||
element.data["account"] = null;
|
||||
element.account?.logout();
|
||||
element.saveData();
|
||||
ComicSource.notifyListeners();
|
||||
logic.update();
|
||||
},
|
||||
trailing: const Icon(Icons.logout),
|
||||
);
|
||||
}
|
||||
yield const Divider(thickness: 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
void setClipboard(String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
showToast(
|
||||
message: "Copied".tl,
|
||||
icon: const Icon(Icons.check),
|
||||
context: App.rootContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginPage extends StatefulWidget {
|
||||
const _LoginPage({required this.config, required this.source});
|
||||
|
||||
final AccountConfig config;
|
||||
|
||||
final ComicSource source;
|
||||
|
||||
@override
|
||||
State<_LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<_LoginPage> {
|
||||
String username = "";
|
||||
String password = "";
|
||||
bool loading = false;
|
||||
|
||||
final Map<String, String> _cookies = {};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: const Appbar(
|
||||
title: Text(''),
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Login".tl, style: const TextStyle(fontSize: 24)),
|
||||
const SizedBox(height: 32),
|
||||
if (widget.config.cookieFields == null)
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: "Username".tl,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
enabled: widget.config.login != null,
|
||||
onChanged: (s) {
|
||||
username = s;
|
||||
},
|
||||
autofillHints: const [AutofillHints.username],
|
||||
).paddingBottom(16),
|
||||
if (widget.config.cookieFields == null)
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: "Password".tl,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
enabled: widget.config.login != null,
|
||||
onChanged: (s) {
|
||||
password = s;
|
||||
},
|
||||
onSubmitted: (s) => login(),
|
||||
autofillHints: const [AutofillHints.password],
|
||||
).paddingBottom(16),
|
||||
for (var field in widget.config.cookieFields ?? <String>[])
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: field,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
enabled: widget.config.validateCookies != null,
|
||||
onChanged: (s) {
|
||||
_cookies[field] = s;
|
||||
},
|
||||
).paddingBottom(16),
|
||||
if (widget.config.login == null &&
|
||||
widget.config.cookieFields == null)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline),
|
||||
const SizedBox(width: 8),
|
||||
Text("Login with password is disabled".tl),
|
||||
],
|
||||
)
|
||||
else
|
||||
Button.filled(
|
||||
isLoading: loading,
|
||||
onPressed: login,
|
||||
child: Text("Continue".tl),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (widget.config.loginWebsite != null)
|
||||
TextButton(
|
||||
onPressed: loginWithWebview,
|
||||
child: Text("Login with webview".tl),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (widget.config.registerWebsite != null)
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
launchUrlString(widget.config.registerWebsite!),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.link),
|
||||
const SizedBox(width: 8),
|
||||
Text("Create Account".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void login() {
|
||||
if (widget.config.login != null) {
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
showToast(
|
||||
message: "Cannot be empty".tl,
|
||||
icon: const Icon(Icons.error_outline),
|
||||
context: context,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
widget.config.login!(username, password).then((value) {
|
||||
if (value.error) {
|
||||
context.showMessage(message: value.errorMessage!);
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
} else {
|
||||
if (mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (widget.config.validateCookies != null) {
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
var cookies =
|
||||
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
|
||||
widget.config.validateCookies!(cookies).then((value) {
|
||||
if (value) {
|
||||
widget.source.data['account'] = 'ok';
|
||||
widget.source.saveData();
|
||||
context.pop();
|
||||
} else {
|
||||
context.showMessage(message: "Invalid cookies".tl);
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loginWithWebview() async {
|
||||
var url = widget.config.loginWebsite!;
|
||||
var title = '';
|
||||
bool success = false;
|
||||
|
||||
void validate(InAppWebViewController c) async {
|
||||
if (widget.config.checkLoginStatus != null
|
||||
&& widget.config.checkLoginStatus!(url, title)) {
|
||||
var cookies = (await c.getCookies(url)) ?? [];
|
||||
SingleInstanceCookieJar.instance?.saveFromResponse(
|
||||
Uri.parse(url),
|
||||
cookies,
|
||||
);
|
||||
success = true;
|
||||
widget.config.onLoginWithWebviewSuccess?.call();
|
||||
App.mainNavigatorKey?.currentContext?.pop();
|
||||
}
|
||||
}
|
||||
|
||||
await context.to(
|
||||
() => AppWebview(
|
||||
initialUrl: widget.config.loginWebsite!,
|
||||
onNavigation: (u, c) {
|
||||
url = u;
|
||||
validate(c);
|
||||
return false;
|
||||
},
|
||||
onTitleChange: (t, c) {
|
||||
title = t;
|
||||
validate(c);
|
||||
},
|
||||
),
|
||||
);
|
||||
if (success) {
|
||||
widget.source.data['account'] = 'ok';
|
||||
widget.source.saveData();
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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();
|
||||
|
@@ -3,56 +3,101 @@ 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/state_controller.dart';
|
||||
import 'package:venera/pages/ranking_page.dart';
|
||||
import 'package:venera/pages/search_result_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'category_comics_page.dart';
|
||||
import 'comic_source_page.dart';
|
||||
|
||||
class CategoriesPage extends StatelessWidget {
|
||||
class CategoriesPage extends StatefulWidget {
|
||||
const CategoriesPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StateBuilder<SimpleController>(
|
||||
tag: "category",
|
||||
init: SimpleController(),
|
||||
builder: (controller) {
|
||||
var categories = List.from(appdata.settings["categories"]);
|
||||
State<CategoriesPage> createState() => _CategoriesPageState();
|
||||
}
|
||||
|
||||
class _CategoriesPageState extends State<CategoriesPage> {
|
||||
var categories = <String>[];
|
||||
|
||||
void onSettingsChanged() {
|
||||
var categories =
|
||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||
var allCategories = ComicSource.all()
|
||||
.map((e) => e.categoryData?.key)
|
||||
.where((element) => element != null)
|
||||
.map((e) => e!)
|
||||
.toList();
|
||||
categories = categories
|
||||
.where((element) => allCategories.contains(element))
|
||||
.toList();
|
||||
categories =
|
||||
categories.where((element) => allCategories.contains(element)).toList();
|
||||
if (!categories.isEqualsTo(this.categories)) {
|
||||
setState(() {
|
||||
this.categories = categories;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if(categories.isEmpty) {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
var categories =
|
||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||
var allCategories = ComicSource.all()
|
||||
.map((e) => e.categoryData?.key)
|
||||
.where((element) => element != null)
|
||||
.map((e) => e!)
|
||||
.toList();
|
||||
this.categories =
|
||||
categories.where((element) => allCategories.contains(element)).toList();
|
||||
appdata.settings.addListener(onSettingsChanged);
|
||||
}
|
||||
|
||||
void addPage() {
|
||||
showPopUpWidget(App.rootContext, setCategoryPagesWidget());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
appdata.settings.removeListener(onSettingsChanged);
|
||||
}
|
||||
|
||||
Widget buildEmpty() {
|
||||
var msg = "No Category Pages".tl;
|
||||
msg += '\n';
|
||||
VoidCallback onTap;
|
||||
if (ComicSource.isEmpty) {
|
||||
msg += "Add a comic source in home page".tl;
|
||||
msg += "Please add some sources".tl;
|
||||
onTap = () {
|
||||
context.to(() => ComicSourcePage());
|
||||
};
|
||||
} else {
|
||||
msg += "Please check your settings".tl;
|
||||
onTap = addPage;
|
||||
}
|
||||
return NetworkError(
|
||||
message: msg,
|
||||
retry: () {
|
||||
controller.update();
|
||||
},
|
||||
retry: onTap,
|
||||
withAppbar: false,
|
||||
buttonText: "Manage".tl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (categories.isEmpty) {
|
||||
return buildEmpty();
|
||||
}
|
||||
|
||||
return Material(
|
||||
child: DefaultTabController(
|
||||
length: categories.length,
|
||||
key: Key(categories.toString()),
|
||||
child: Column(
|
||||
children: [
|
||||
FilledTabBar(
|
||||
AppTabBar(
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
@@ -66,18 +111,21 @@ class CategoriesPage extends StatelessWidget {
|
||||
key: Key(e),
|
||||
);
|
||||
}).toList(),
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children:
|
||||
categories.map((e) => _CategoryPage(e)).toList()),
|
||||
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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();
|
||||
@@ -172,7 +183,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
isLiked = comic.isLiked ?? false;
|
||||
isFavorite = comic.isFavorite ?? false;
|
||||
if (comic.chapters == null) {
|
||||
isDownloaded = await LocalManager().isDownloaded(
|
||||
isDownloaded = LocalManager().isDownloaded(
|
||||
comic.id,
|
||||
comic.comicType,
|
||||
0,
|
||||
@@ -199,23 +210,34 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
Container(
|
||||
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(
|
||||
comic.cover,
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -292,7 +314,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
if (comicSource.commentsLoader != null)
|
||||
_ActionButton(
|
||||
icon: const Icon(Icons.comment),
|
||||
text: (comic.commentsCount ?? 'Comments'.tl).toString(),
|
||||
text: (comic.commentCount ?? 'Comments'.tl).toString(),
|
||||
onPressed: showComments,
|
||||
iconColor: context.useTextColor(Colors.green),
|
||||
),
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -679,7 +703,7 @@ abstract mixin class _ComicPageActions {
|
||||
return;
|
||||
}
|
||||
if (comic.chapters == null &&
|
||||
await LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
|
||||
LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
|
||||
App.rootContext.showMessage(message: "The comic is downloaded".tl);
|
||||
return;
|
||||
}
|
||||
@@ -1217,10 +1241,12 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
} else {
|
||||
error = res.errorMessage;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1257,7 +1283,9 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
y2 = double.parse(r.split('-')[1]);
|
||||
}
|
||||
}
|
||||
} finally {}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2);
|
||||
}
|
||||
return Padding(
|
||||
@@ -1271,20 +1299,20 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
child: InkWell(
|
||||
onTap: () => state.read(null, index + 1),
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
url,
|
||||
@@ -1298,7 +1326,6 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
@@ -1310,7 +1337,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 200,
|
||||
childAspectRatio: 0.65,
|
||||
childAspectRatio: 0.68,
|
||||
),
|
||||
),
|
||||
if (error != null)
|
||||
@@ -1942,3 +1969,125 @@ 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(
|
||||
color: context.isDarkMode ? Colors.grey.shade700 : Colors.white,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -73,6 +73,7 @@ class _CommentsPageState extends State<CommentsPage> {
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: Appbar(
|
||||
title: Text("Comments".tl),
|
||||
style: AppbarStyle.shadow,
|
||||
),
|
||||
body: buildBody(context),
|
||||
);
|
||||
@@ -529,6 +530,7 @@ class _Tag {
|
||||
'u' => style.underline,
|
||||
's' => style.lineThrough,
|
||||
'a' => style.withColor(context.colorScheme.primary),
|
||||
'strong' => style.bold,
|
||||
'span' => () {
|
||||
if (attributes.containsKey('style')) {
|
||||
var s = attributes['style']!;
|
||||
@@ -622,10 +624,14 @@ class RichCommentContent extends StatefulWidget {
|
||||
class _RichCommentContentState extends State<RichCommentContent> {
|
||||
var textSpan = <InlineSpan>[];
|
||||
var images = <_CommentImage>[];
|
||||
bool isRendered = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
if (!isRendered) {
|
||||
render();
|
||||
isRendered = true;
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@@ -670,7 +676,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
|
||||
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', '');
|
||||
}
|
||||
}
|
||||
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span'];
|
||||
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span', 'strong'];
|
||||
if (acceptedTags.contains(tagName)) {
|
||||
writeBuffer();
|
||||
if (tagName == 'img') {
|
||||
|
@@ -46,6 +46,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
|
||||
i--;
|
||||
|
||||
return _DownloadTaskTile(
|
||||
key: ValueKey(LocalManager().downloadingTasks[i]),
|
||||
task: LocalManager().downloadingTasks[i],
|
||||
);
|
||||
},
|
||||
@@ -120,7 +121,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
|
||||
}
|
||||
|
||||
class _DownloadTaskTile extends StatefulWidget {
|
||||
const _DownloadTaskTile({required this.task});
|
||||
const _DownloadTaskTile({required this.task, super.key});
|
||||
|
||||
final DownloadTask task;
|
||||
|
||||
@@ -129,20 +130,33 @@ class _DownloadTaskTile extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadTaskTileState extends State<_DownloadTaskTile> {
|
||||
late DownloadTask task;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
widget.task.addListener(update);
|
||||
task = widget.task;
|
||||
task.addListener(update);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.task.removeListener(update);
|
||||
task.removeListener(update);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _DownloadTaskTile oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.task != widget.task) {
|
||||
task.removeListener(update);
|
||||
task = widget.task;
|
||||
task.addListener(update);
|
||||
}
|
||||
}
|
||||
|
||||
void update() {
|
||||
context.findAncestorStateOfType<_DownloadingPageState>()?.update();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -5,7 +5,9 @@ import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:venera/pages/comic_source_page.dart';
|
||||
import 'package:venera/pages/search_result_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
@@ -56,6 +58,10 @@ class _ExplorePageState extends State<ExplorePage>
|
||||
}
|
||||
}
|
||||
|
||||
void addPage() {
|
||||
showPopUpWidget(App.rootContext, setExplorePagesWidget());
|
||||
}
|
||||
|
||||
NaviPaneState? naviPane;
|
||||
|
||||
@override
|
||||
@@ -117,26 +123,21 @@ class _ExplorePageState extends State<ExplorePage>
|
||||
Widget buildEmpty() {
|
||||
var msg = "No Explore Pages".tl;
|
||||
msg += '\n';
|
||||
VoidCallback onTap;
|
||||
if (ComicSource.isEmpty) {
|
||||
msg += "Add a comic source in home page".tl;
|
||||
msg += "Please add some sources".tl;
|
||||
onTap = () {
|
||||
context.to(() => ComicSourcePage());
|
||||
};
|
||||
} else {
|
||||
msg += "Please check your settings".tl;
|
||||
onTap = addPage;
|
||||
}
|
||||
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: onTap,
|
||||
withAppbar: false,
|
||||
buttonText: "Manage".tl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,10 +149,15 @@ class _ExplorePageState extends State<ExplorePage>
|
||||
}
|
||||
|
||||
Widget tabBar = Material(
|
||||
child: FilledTabBar(
|
||||
child: AppTabBar(
|
||||
key: PageStorageKey(pages.toString()),
|
||||
tabs: pages.map((e) => buildTab(e)).toList(),
|
||||
controller: controller,
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
),
|
||||
).paddingTop(context.padding.top);
|
||||
|
||||
@@ -295,6 +301,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
|
||||
);
|
||||
} else if (data.loadPage != null || data.loadNext != null) {
|
||||
return ComicList(
|
||||
enablePageStorage: true,
|
||||
loadPage: data.loadPage,
|
||||
loadNext: data.loadNext,
|
||||
key: const PageStorageKey("comic_list"),
|
||||
|
@@ -77,7 +77,7 @@ String? validateFolderName(String newFolderName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
void addFavorite(Comic comic) {
|
||||
void addFavorite(List<Comic> comics) {
|
||||
var folders = LocalFavoritesManager().folderNames;
|
||||
|
||||
showDialog(
|
||||
@@ -105,6 +105,7 @@ void addFavorite(Comic comic) {
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (selectedFolder != null) {
|
||||
for (var comic in comics) {
|
||||
LocalFavoritesManager().addComic(
|
||||
selectedFolder!,
|
||||
FavoriteItem(
|
||||
@@ -118,6 +119,7 @@ void addFavorite(Comic comic) {
|
||||
tags: comic.tags ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
@@ -144,6 +146,18 @@ 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) {
|
||||
const shouldIgnore = ['author', 'artist', 'time'];
|
||||
var namespace = entry.key;
|
||||
if (shouldIgnore.contains(namespace.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
for (var tag in entry.value) {
|
||||
newTags.add("$namespace:$tag");
|
||||
}
|
||||
}
|
||||
|
||||
comics[index] = FavoriteItem(
|
||||
id: c.id,
|
||||
name: newInfo.title,
|
||||
@@ -152,7 +166,7 @@ Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
|
||||
newInfo.tags['author']?.firstOrNull ??
|
||||
c.author,
|
||||
type: c.type,
|
||||
tags: c.tags,
|
||||
tags: newTags,
|
||||
);
|
||||
|
||||
LocalFavoritesManager().updateInfo(folder, comics[index]);
|
||||
@@ -291,6 +305,7 @@ Future<void> sortFolders() async {
|
||||
|
||||
Future<void> importNetworkFolder(
|
||||
String source,
|
||||
int updatePageNum,
|
||||
String? folder,
|
||||
String? folderID,
|
||||
) async {
|
||||
@@ -318,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;
|
||||
@@ -359,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;
|
||||
@@ -394,6 +418,8 @@ Future<void> importNetworkFolder(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 跳出循环, 表示已经完成, 强制为 true, 避免死循环
|
||||
isFinished = true;
|
||||
}
|
||||
|
||||
bool isCanceled = false;
|
||||
@@ -401,6 +427,7 @@ Future<void> importNetworkFolder(
|
||||
bool isErrored() => errorMsg != null;
|
||||
|
||||
void Function()? updateDialog;
|
||||
void Function()? closeDialog;
|
||||
|
||||
showDialog(
|
||||
context: App.rootContext,
|
||||
@@ -408,6 +435,7 @@ Future<void> importNetworkFolder(
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
updateDialog = () => setState(() {});
|
||||
closeDialog = () => Navigator.pop(context);
|
||||
return ContentDialog(
|
||||
title: isFinished
|
||||
? "Finished".tl
|
||||
@@ -423,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"),
|
||||
@@ -462,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);
|
||||
}
|
||||
}
|
||||
|
@@ -11,9 +11,11 @@ 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';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
@@ -152,14 +154,16 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
);
|
||||
}
|
||||
if (!isNetwork) {
|
||||
return _LocalFavoritesPage(folder: folder!, key: PageStorageKey(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(folder!));
|
||||
return NetworkFavoritePage(favoriteData,
|
||||
key: PageStorageKey("network_$folder"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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));
|
||||
@@ -102,10 +109,13 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
}
|
||||
}
|
||||
|
||||
var scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var body = Scaffold(
|
||||
body: SmoothCustomScrollView(slivers: [
|
||||
Widget body = SmoothCustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
if (!searchMode && !multiSelectMode)
|
||||
SliverAppbar(
|
||||
style: context.width < changePoint
|
||||
@@ -133,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),
|
||||
@@ -151,6 +161,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
context.pop();
|
||||
importNetworkFolder(
|
||||
networkSource!,
|
||||
selectUpdatePageNumKey
|
||||
.currentState!.updatePageNum,
|
||||
widget.folder,
|
||||
networkFolder!,
|
||||
).then(
|
||||
@@ -377,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,
|
||||
@@ -387,10 +428,23 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (appdata.settings["onClickFavorite"] == "viewDetail")
|
||||
MenuEntry(
|
||||
icon: Icons.menu_book_outlined,
|
||||
text: "Read".tl,
|
||||
onClick: () {
|
||||
App.mainNavigatorKey?.currentContext?.to(
|
||||
() => ReaderWithLoading(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
onTap: multiSelectMode
|
||||
? (c) {
|
||||
onTap: (c) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as FavoriteItem)) {
|
||||
selectedComics.remove(c);
|
||||
@@ -400,10 +454,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
}
|
||||
lastSelectedIndex = comics.indexOf(c);
|
||||
});
|
||||
}
|
||||
: (c) {
|
||||
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
|
||||
App.mainNavigatorKey?.currentContext
|
||||
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
|
||||
} else {
|
||||
App.mainNavigatorKey?.currentContext?.to(
|
||||
() => ReaderWithLoading(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPressed: (c) {
|
||||
setState(() {
|
||||
@@ -440,7 +501,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
});
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
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: body,
|
||||
),
|
||||
);
|
||||
return PopScope(
|
||||
canPop: !multiSelectMode && !searchMode,
|
||||
@@ -622,7 +693,6 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
(c as FavoriteItem).type,
|
||||
);
|
||||
}
|
||||
updateComics();
|
||||
_cancel();
|
||||
}
|
||||
}
|
||||
@@ -708,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>(
|
||||
@@ -743,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();
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
},
|
||||
)
|
||||
]),
|
||||
@@ -166,6 +164,7 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
|
||||
),
|
||||
];
|
||||
},
|
||||
enablePageStorage: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -214,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
|
||||
@@ -430,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,
|
||||
@@ -548,6 +545,7 @@ class _FavoriteFolder extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return ComicList(
|
||||
key: comicListKey,
|
||||
enablePageStorage: true,
|
||||
leadingSliver: SliverAppbar(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
@@ -556,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);
|
||||
},
|
||||
)
|
||||
]),
|
||||
|
@@ -1,22 +1,23 @@
|
||||
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/pages/accounts_page.dart';
|
||||
import 'package:venera/foundation/log.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';
|
||||
@@ -34,7 +35,7 @@ class HomePage extends StatelessWidget {
|
||||
const _History(),
|
||||
const _Local(),
|
||||
const _ComicSourceWidget(),
|
||||
const _AccountsWidget(),
|
||||
const ImageFavorites(),
|
||||
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
|
||||
],
|
||||
);
|
||||
@@ -83,7 +84,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();
|
||||
@@ -162,14 +164,12 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
|
||||
icon: const Icon(Icons.cloud_upload_outlined),
|
||||
onPressed: () async {
|
||||
DataSync().uploadData();
|
||||
}
|
||||
),
|
||||
}),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_download_outlined),
|
||||
onPressed: () async {
|
||||
DataSync().downloadData();
|
||||
}
|
||||
),
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -264,7 +264,8 @@ class _HistoryState extends State<_History> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: history.length,
|
||||
itemBuilder: (context, index) {
|
||||
return InkWell(
|
||||
return SimpleComicTile(
|
||||
comic: history[index],
|
||||
onTap: () {
|
||||
context.to(
|
||||
() => ComicPage(
|
||||
@@ -273,27 +274,7 @@ class _HistoryState extends State<_History> {
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 92,
|
||||
height: 114,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
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).paddingVertical(2);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8).paddingBottom(16),
|
||||
@@ -386,33 +367,8 @@ class _LocalState extends State<_Local> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: local.length,
|
||||
itemBuilder: (context, index) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
local[index].read();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 92,
|
||||
height: 114,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
return SimpleComicTile(comic: local[index])
|
||||
.paddingHorizontal(8);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
@@ -496,13 +452,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 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,
|
||||
"An archive file".tl,
|
||||
"Multiple archive files".tl,
|
||||
"EhViewer downloads".tl
|
||||
];
|
||||
|
||||
@@ -534,7 +492,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
},
|
||||
);
|
||||
}),
|
||||
if(type != 3)
|
||||
if (type != 4)
|
||||
ListTile(
|
||||
title: Text("Add to favorites".tl),
|
||||
trailing: Select(
|
||||
@@ -548,7 +506,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
if(!App.isIOS && !App.isMacOS)
|
||||
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
||||
CheckboxListTile(
|
||||
enabled: true,
|
||||
title: Text("Copy to app local path".tl),
|
||||
@@ -576,36 +534,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,13 +553,13 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
loading = true;
|
||||
});
|
||||
var importer = ImportComic(
|
||||
selectedFolder: selectedFolder,
|
||||
copyToLocal: copyToLocalFolder);
|
||||
selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder);
|
||||
var result = switch (type) {
|
||||
0 => await importer.directory(true),
|
||||
1 => await importer.directory(false),
|
||||
2 => await importer.cbz(),
|
||||
3 => await importer.ehViewer(),
|
||||
3 => await importer.multipleCbz(),
|
||||
4 => await importer.ehViewer(),
|
||||
int() => true,
|
||||
};
|
||||
if (result) {
|
||||
@@ -735,115 +664,30 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
|
||||
}).toList(),
|
||||
).paddingHorizontal(16).paddingBottom(16),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountsWidget extends StatefulWidget {
|
||||
const _AccountsWidget();
|
||||
|
||||
@override
|
||||
State<_AccountsWidget> createState() => _AccountsWidgetState();
|
||||
}
|
||||
|
||||
class _AccountsWidgetState extends State<_AccountsWidget> {
|
||||
late List<String> accounts;
|
||||
|
||||
void onComicSourceChange() {
|
||||
setState(() {
|
||||
accounts.clear();
|
||||
for (var c in ComicSource.all()) {
|
||||
if (c.isLogged) {
|
||||
accounts.add(c.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
accounts = [];
|
||||
for (var c in ComicSource.all()) {
|
||||
if (c.isLogged) {
|
||||
accounts.add(c.name);
|
||||
}
|
||||
}
|
||||
ComicSource.addListener(onComicSourceChange);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ComicSource.removeListener(onComicSourceChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 AccountsPage());
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
Center(
|
||||
child: Text('Accounts'.tl, style: ts.s18),
|
||||
),
|
||||
if (ComicSource.availableUpdates.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(accounts.length.toString(), style: ts.s12),
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(Icons.arrow_right),
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: accounts.map((e) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
child: Text(e),
|
||||
);
|
||||
}).toList(),
|
||||
).paddingHorizontal(16).paddingBottom(16),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -910,3 +754,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);
|
||||
}
|
||||
}
|
||||
|
287
lib/pages/image_favorites_page/image_favorites_item.dart
Normal 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);
|
||||
}
|
||||
}
|
539
lib/pages/image_favorites_page/image_favorites_page.dart
Normal 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: AppTabBar(
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
253
lib/pages/image_favorites_page/image_favorites_photo_view.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
101
lib/pages/image_favorites_page/type.dart
Normal 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);
|
||||
}
|
@@ -2,10 +2,11 @@ import 'package:flutter/material.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/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/comic_page.dart';
|
||||
import 'package:venera/pages/downloading_page.dart';
|
||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||
import 'package:venera/utils/cbz.dart';
|
||||
import 'package:venera/utils/epub.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
@@ -30,7 +31,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
|
||||
bool multiSelectMode = false;
|
||||
|
||||
Map<Comic, bool> selectedComics = {};
|
||||
Map<LocalComic, bool> selectedComics = {};
|
||||
|
||||
void update() {
|
||||
if (keyword.isEmpty) {
|
||||
@@ -117,8 +118,45 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget buildMultiSelectMenu() {
|
||||
return MenuButton(entries: [
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete".tl,
|
||||
onClick: () {
|
||||
deleteComics(selectedComics.keys.toList()).then((value) {
|
||||
if (value) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.favorite_border,
|
||||
text: "Add to favorites".tl,
|
||||
onClick: () {
|
||||
addFavorite(selectedComics.keys.toList());
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
MenuEntry(
|
||||
icon: Icons.chrome_reader_mode_outlined,
|
||||
text: "View Detail".tl,
|
||||
onClick: () {
|
||||
context.to(() => ComicPage(
|
||||
id: selectedComics.keys.first.id,
|
||||
sourceKey: selectedComics.keys.first.sourceKey,
|
||||
));
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
...exportActions(selectedComics.keys.first),
|
||||
]);
|
||||
}
|
||||
|
||||
void selectAll() {
|
||||
setState(() {
|
||||
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
|
||||
@@ -140,25 +178,8 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
});
|
||||
}
|
||||
|
||||
void selectRange() {
|
||||
setState(() {
|
||||
List<int> l = [];
|
||||
selectedComics.forEach((k, v) {
|
||||
l.add(comics.indexOf(k as LocalComic));
|
||||
});
|
||||
if (l.isEmpty) {
|
||||
return;
|
||||
}
|
||||
l.sort();
|
||||
int start = l.first;
|
||||
int end = l.last;
|
||||
selectedComics.clear();
|
||||
selectedComics.addEntries(List.generate(end - start + 1, (i) {
|
||||
return MapEntry(comics[start + i], true);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<Widget> selectActions = [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.select_all),
|
||||
@@ -172,19 +193,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
icon: const Icon(Icons.flip),
|
||||
tooltip: "Invert Selection".tl,
|
||||
onPressed: invertSelection),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.border_horizontal_outlined),
|
||||
tooltip: "Select in range".tl,
|
||||
onPressed: selectRange),
|
||||
buildMultiSelectMenu(),
|
||||
];
|
||||
|
||||
var body = Scaffold(
|
||||
body: SmoothCustomScrollView(
|
||||
slivers: [
|
||||
if (!searchMode && !multiSelectMode)
|
||||
SliverAppbar(
|
||||
title: Text("Local".tl),
|
||||
actions: [
|
||||
List<Widget> normalActions = [
|
||||
Tooltip(
|
||||
message: "Search".tl,
|
||||
child: IconButton(
|
||||
@@ -212,38 +224,35 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: multiSelectMode
|
||||
? "Exit Multi-Select".tl
|
||||
: "Multi-Select".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.checklist),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
multiSelectMode = !multiSelectMode;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (multiSelectMode)
|
||||
];
|
||||
|
||||
var body = Scaffold(
|
||||
body: SmoothCustomScrollView(
|
||||
slivers: [
|
||||
if (!searchMode)
|
||||
SliverAppbar(
|
||||
leading: Tooltip(
|
||||
message: "Cancel".tl,
|
||||
message: multiSelectMode ? "Cancel".tl : "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
} else {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
icon: multiSelectMode
|
||||
? const Icon(Icons.close)
|
||||
: const Icon(Icons.arrow_back),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"Selected @c comics".tlParams({"c": selectedComics.length})),
|
||||
actions: selectActions,
|
||||
title: multiSelectMode
|
||||
? Text(selectedComics.length.toString())
|
||||
: Text("Local".tl),
|
||||
actions: multiSelectMode ? selectActions : normalActions,
|
||||
)
|
||||
else if (searchMode)
|
||||
SliverAppbar(
|
||||
@@ -275,18 +284,27 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
selections: selectedComics,
|
||||
onTap: multiSelectMode
|
||||
? (c) {
|
||||
onLongPressed: (c) {
|
||||
setState(() {
|
||||
multiSelectMode = true;
|
||||
selectedComics[c as LocalComic] = true;
|
||||
});
|
||||
},
|
||||
onTap: (c) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as LocalComic)) {
|
||||
selectedComics.remove(c);
|
||||
} else {
|
||||
selectedComics[c] = true;
|
||||
}
|
||||
});
|
||||
if (selectedComics.isEmpty) {
|
||||
multiSelectMode = false;
|
||||
}
|
||||
: (c) {
|
||||
});
|
||||
} else {
|
||||
(c as LocalComic).read();
|
||||
}
|
||||
},
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
@@ -294,130 +312,17 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
icon: Icons.delete,
|
||||
text: "Delete".tl,
|
||||
onClick: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
bool removeComicFile = true;
|
||||
return StatefulBuilder(builder: (context, state) {
|
||||
return ContentDialog(
|
||||
title: "Delete".tl,
|
||||
content: CheckboxListTile(
|
||||
title: Text("Also remove files on disk".tl),
|
||||
value: removeComicFile,
|
||||
onChanged: (v) {
|
||||
state(() {
|
||||
removeComicFile = !removeComicFile;
|
||||
});
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
if (multiSelectMode) {
|
||||
for (var comic in selectedComics.keys) {
|
||||
LocalManager().deleteComic(
|
||||
comic as LocalComic,
|
||||
removeComicFile);
|
||||
}
|
||||
deleteComics([c as LocalComic]).then((value) {
|
||||
if (value && multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
} else {
|
||||
LocalManager().deleteComic(
|
||||
c as LocalComic, removeComicFile);
|
||||
}
|
||||
},
|
||||
child: Text("Confirm".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
}),
|
||||
MenuEntry(
|
||||
icon: Icons.outbox_outlined,
|
||||
text: "Export as cbz".tl,
|
||||
onClick: () async {
|
||||
var controller = showLoadingDialog(
|
||||
context,
|
||||
allowCancel: false,
|
||||
);
|
||||
try {
|
||||
if (multiSelectMode) {
|
||||
for (var comic in selectedComics.keys) {
|
||||
var file = await CBZ.export(comic as LocalComic);
|
||||
await saveFile(filename: file.name, file: file);
|
||||
await file.delete();
|
||||
}
|
||||
setState(() {
|
||||
selectedComics.clear();
|
||||
});
|
||||
} else {
|
||||
var file = await CBZ.export(c as LocalComic);
|
||||
await saveFile(filename: file.name, file: file);
|
||||
await file.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
context.showMessage(message: e.toString());
|
||||
}
|
||||
controller.close();
|
||||
}),
|
||||
if (!multiSelectMode)
|
||||
MenuEntry(
|
||||
icon: Icons.picture_as_pdf_outlined,
|
||||
text: "Export as pdf".tl,
|
||||
onClick: () async {
|
||||
var cache = FilePath.join(App.cachePath, 'temp.pdf');
|
||||
var controller = showLoadingDialog(
|
||||
context,
|
||||
allowCancel: false,
|
||||
);
|
||||
try {
|
||||
await createPdfFromComicIsolate(
|
||||
comic: c as LocalComic,
|
||||
savePath: cache,
|
||||
);
|
||||
await saveFile(
|
||||
file: File(cache),
|
||||
filename: "${c.title}.pdf",
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("PDF Export", e, s);
|
||||
context.showMessage(message: e.toString());
|
||||
} finally {
|
||||
controller.close();
|
||||
File(cache).deleteIgnoreError();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (!multiSelectMode)
|
||||
MenuEntry(
|
||||
icon: Icons.import_contacts_outlined,
|
||||
text: "Export as epub".tl,
|
||||
onClick: () async {
|
||||
var controller = showLoadingDialog(
|
||||
context,
|
||||
allowCancel: false,
|
||||
);
|
||||
File? file;
|
||||
try {
|
||||
file = await createEpubWithLocalComic(
|
||||
c as LocalComic,
|
||||
);
|
||||
await saveFile(
|
||||
file: file,
|
||||
filename: "${c.title}.epub",
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("EPUB Export", e, s);
|
||||
context.showMessage(message: e.toString());
|
||||
} finally {
|
||||
controller.close();
|
||||
file?.deleteIgnoreError();
|
||||
}
|
||||
},
|
||||
)
|
||||
...exportActions(c as LocalComic),
|
||||
];
|
||||
},
|
||||
),
|
||||
@@ -444,4 +349,120 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> deleteComics(List<LocalComic> comics) async {
|
||||
bool isDeleted = false;
|
||||
await showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
bool removeComicFile = true;
|
||||
return StatefulBuilder(builder: (context, state) {
|
||||
return ContentDialog(
|
||||
title: "Delete".tl,
|
||||
content: CheckboxListTile(
|
||||
title: Text("Also remove files on disk".tl),
|
||||
value: removeComicFile,
|
||||
onChanged: (v) {
|
||||
state(() {
|
||||
removeComicFile = !removeComicFile;
|
||||
});
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
for (var comic in comics) {
|
||||
LocalManager().deleteComic(
|
||||
comic,
|
||||
removeComicFile,
|
||||
);
|
||||
}
|
||||
isDeleted = true;
|
||||
},
|
||||
child: Text("Confirm".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
return isDeleted;
|
||||
}
|
||||
|
||||
List<MenuEntry> exportActions(LocalComic c) {
|
||||
return [
|
||||
MenuEntry(
|
||||
icon: Icons.outbox_outlined,
|
||||
text: "Export as cbz".tl,
|
||||
onClick: () async {
|
||||
var controller = showLoadingDialog(
|
||||
context,
|
||||
allowCancel: false,
|
||||
);
|
||||
try {
|
||||
var file = await CBZ.export(c);
|
||||
await saveFile(filename: file.name, file: file);
|
||||
await file.delete();
|
||||
} catch (e, s) {
|
||||
context.showMessage(message: e.toString());
|
||||
Log.error("CBZ Export", e, s);
|
||||
}
|
||||
controller.close();
|
||||
}),
|
||||
MenuEntry(
|
||||
icon: Icons.picture_as_pdf_outlined,
|
||||
text: "Export as pdf".tl,
|
||||
onClick: () async {
|
||||
var cache = FilePath.join(App.cachePath, 'temp.pdf');
|
||||
var controller = showLoadingDialog(
|
||||
context,
|
||||
allowCancel: false,
|
||||
);
|
||||
try {
|
||||
await createPdfFromComicIsolate(
|
||||
comic: c,
|
||||
savePath: cache,
|
||||
);
|
||||
await saveFile(
|
||||
file: File(cache),
|
||||
filename: "${c.title}.pdf",
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("PDF Export", e, s);
|
||||
context.showMessage(message: e.toString());
|
||||
} finally {
|
||||
controller.close();
|
||||
File(cache).deleteIgnoreError();
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.import_contacts_outlined,
|
||||
text: "Export as epub".tl,
|
||||
onClick: () async {
|
||||
var controller = showLoadingDialog(
|
||||
context,
|
||||
allowCancel: false,
|
||||
);
|
||||
File? file;
|
||||
try {
|
||||
file = await createEpubWithLocalComic(
|
||||
c,
|
||||
);
|
||||
await saveFile(
|
||||
file: file,
|
||||
filename: "${c.title}.epub",
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("EPUB Export", e, s);
|
||||
context.showMessage(message: e.toString());
|
||||
} finally {
|
||||
controller.close();
|
||||
file?.deleteIgnoreError();
|
||||
}
|
||||
},
|
||||
)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@@ -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();
|
||||
ComicSourcePage.checkComicSourceUpdate();
|
||||
if (appdata.settings['checkUpdateOnStart']) {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
await checkUpdateUi(false);
|
||||
await ComicSourcePage.checkComicSourceUpdate(true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -62,9 +61,7 @@ class _MainPageState extends State<MainPage> {
|
||||
}
|
||||
|
||||
final _pages = [
|
||||
const HomePage(
|
||||
key: PageStorageKey('home'),
|
||||
),
|
||||
const HomePage(),
|
||||
const FavoritesPage(
|
||||
key: PageStorageKey('favorites'),
|
||||
),
|
||||
|
@@ -20,7 +20,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
|
||||
static const _kTapToTurnPagePercent = 0.3;
|
||||
|
||||
_DragListener? dragListener;
|
||||
final _dragListeners = <_DragListener>[];
|
||||
|
||||
int fingers = 0;
|
||||
|
||||
@@ -45,7 +45,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
_lastTapMoveDistance = Offset.zero;
|
||||
_tapGestureRecognizer.addPointer(event);
|
||||
if (_dragInProgress) {
|
||||
dragListener?.onEnd?.call();
|
||||
for (var dragListener in _dragListeners) {
|
||||
dragListener.onStart?.call(event.position);
|
||||
}
|
||||
_dragInProgress = false;
|
||||
}
|
||||
Future.delayed(_kLongPressMinTime, () {
|
||||
@@ -55,8 +57,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
_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!);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -66,7 +70,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
_lastTapMoveDistance = event.delta + _lastTapMoveDistance!;
|
||||
}
|
||||
if (_dragInProgress) {
|
||||
dragListener?.onMove?.call(event.delta);
|
||||
for (var dragListener in _dragListeners) {
|
||||
dragListener.onMove?.call(event.delta);
|
||||
}
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
@@ -75,7 +81,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
onLongPressedUp(event.position);
|
||||
}
|
||||
if (_dragInProgress) {
|
||||
dragListener?.onEnd?.call();
|
||||
for (var dragListener in _dragListeners) {
|
||||
dragListener.onEnd?.call();
|
||||
}
|
||||
_dragInProgress = false;
|
||||
}
|
||||
_lastTapPointer = null;
|
||||
@@ -87,7 +95,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
|
||||
onLongPressedUp(event.position);
|
||||
}
|
||||
if (_dragInProgress) {
|
||||
dragListener?.onEnd?.call();
|
||||
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 {
|
||||
|
@@ -25,7 +25,7 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
||||
if (inProgress) return;
|
||||
inProgress = true;
|
||||
if (reader.type == ComicType.local ||
|
||||
(await LocalManager()
|
||||
(LocalManager()
|
||||
.isDownloaded(reader.cid, reader.type, reader.chapter))) {
|
||||
try {
|
||||
var images = await LocalManager()
|
||||
@@ -111,9 +111,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
late _ReaderState reader;
|
||||
|
||||
int get totalPages => ((reader.images!.length + reader.imagesPerPage - 1) /
|
||||
reader.imagesPerPage)
|
||||
.ceil();
|
||||
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -228,6 +226,8 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
? Axis.vertical
|
||||
: Axis.horizontal;
|
||||
|
||||
bool reverse = reader.mode == ReaderMode.galleryRightToLeft;
|
||||
|
||||
List<Widget> imageWidgets = images.map((imageKey) {
|
||||
ImageProvider imageProvider =
|
||||
_createImageProviderFromKey(imageKey, context);
|
||||
@@ -239,6 +239,10 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
);
|
||||
}).toList();
|
||||
|
||||
if (reverse) {
|
||||
imageWidgets = imageWidgets.reversed.toList();
|
||||
}
|
||||
|
||||
return axis == Axis.vertical
|
||||
? Column(children: imageWidgets)
|
||||
: Row(children: imageWidgets);
|
||||
@@ -263,6 +267,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();
|
||||
}
|
||||
@@ -356,6 +364,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
var isCTRLPressed = false;
|
||||
static var _isMouseScrolling = false;
|
||||
var fingers = 0;
|
||||
bool disableScroll = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -426,7 +435,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
? Axis.vertical
|
||||
: Axis.horizontal,
|
||||
reverse: reader.mode == ReaderMode.continuousRightToLeft,
|
||||
physics: isCTRLPressed || _isMouseScrolling
|
||||
physics: isCTRLPressed || _isMouseScrolling || disableScroll
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const ClampingScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -460,6 +469,11 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
widget = Listener(
|
||||
onPointerDown: (event) {
|
||||
fingers++;
|
||||
if (fingers > 1 && !disableScroll) {
|
||||
setState(() {
|
||||
disableScroll = true;
|
||||
});
|
||||
}
|
||||
futurePosition = null;
|
||||
if (_isMouseScrolling) {
|
||||
setState(() {
|
||||
@@ -469,6 +483,11 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
fingers--;
|
||||
if (fingers <= 1 && disableScroll) {
|
||||
setState(() {
|
||||
disableScroll = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onPointerPanZoomUpdate: (event) {
|
||||
if (event.scale == 1.0) {
|
||||
@@ -553,6 +572,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()) {
|
||||
@@ -654,6 +677,7 @@ ImageProvider _createImageProviderFromKey(
|
||||
reader.type.comicSource?.key,
|
||||
reader.cid,
|
||||
reader.eid,
|
||||
reader.page,
|
||||
);
|
||||
}
|
||||
|
||||
|
121
lib/pages/reader/loading.dart
Normal file
@@ -0,0 +1,121 @@
|
||||
part of 'reader.dart';
|
||||
|
||||
class ReaderWithLoading extends StatefulWidget {
|
||||
const ReaderWithLoading({
|
||||
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();
|
||||
}
|
||||
|
||||
class _ReaderWithLoadingState
|
||||
extends LoadingState<ReaderWithLoading, ReaderProps> {
|
||||
@override
|
||||
Widget buildContent(BuildContext context, ReaderProps data) {
|
||||
return Reader(
|
||||
type: data.type,
|
||||
cid: data.cid,
|
||||
name: data.name,
|
||||
chapters: data.chapters,
|
||||
history: data.history,
|
||||
initialChapter: widget.initialEp ?? data.history.ep,
|
||||
initialPage: widget.initialPage ?? data.history.page,
|
||||
author: data.author,
|
||||
tags: data.tags,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Res<ReaderProps>> loadData() async {
|
||||
var comicSource = ComicSource.find(widget.sourceKey);
|
||||
var history = HistoryManager().findSync(
|
||||
widget.id,
|
||||
ComicType.fromKey(widget.sourceKey),
|
||||
);
|
||||
if (comicSource == null) {
|
||||
var localComic = LocalManager().find(
|
||||
widget.id,
|
||||
ComicType.fromKey(widget.sourceKey),
|
||||
);
|
||||
if (localComic == null) {
|
||||
return Res.error("comic not found");
|
||||
}
|
||||
return Res(
|
||||
ReaderProps(
|
||||
type: ComicType.fromKey(widget.sourceKey),
|
||||
cid: widget.id,
|
||||
name: localComic.title,
|
||||
chapters: localComic.chapters,
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: localComic,
|
||||
ep: 0,
|
||||
page: 0,
|
||||
),
|
||||
author: localComic.subtitle,
|
||||
tags: localComic.tags,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
var comic = await comicSource.loadComicInfo!(widget.id);
|
||||
if (comic.error) {
|
||||
return Res.fromErrorRes(comic);
|
||||
}
|
||||
return Res(
|
||||
ReaderProps(
|
||||
type: ComicType.fromKey(widget.sourceKey),
|
||||
cid: widget.id,
|
||||
name: comic.data.title,
|
||||
chapters: comic.data.chapters,
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: comic.data,
|
||||
ep: 0,
|
||||
page: 0,
|
||||
),
|
||||
author: comic.data.findAuthor() ?? "",
|
||||
tags: comic.data.plainTags,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReaderProps {
|
||||
final ComicType type;
|
||||
|
||||
final String cid;
|
||||
|
||||
final String name;
|
||||
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
@@ -18,15 +18,21 @@ import 'package:venera/components/custom_slider.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
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';
|
||||
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';
|
||||
@@ -36,6 +42,7 @@ part 'scaffold.dart';
|
||||
part 'images.dart';
|
||||
part 'gesture.dart';
|
||||
part 'comic_image.dart';
|
||||
part 'loading.dart';
|
||||
|
||||
extension _ReaderContext on BuildContext {
|
||||
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
|
||||
@@ -54,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;
|
||||
@@ -85,8 +98,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
}
|
||||
|
||||
@override
|
||||
int get maxPage =>
|
||||
((images?.length ?? 1) + imagesPerPage - 1) ~/ imagesPerPage;
|
||||
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
|
||||
|
||||
ComicType get type => widget.type;
|
||||
|
||||
@@ -111,12 +123,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;
|
||||
@@ -135,6 +149,12 @@ 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(() {
|
||||
@@ -145,6 +165,9 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
handleVolumeEvent();
|
||||
}
|
||||
setImageCacheSize();
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
LocalFavoritesManager().onRead(cid, type);
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@@ -161,7 +184,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 +233,11 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
if (history != null) {
|
||||
history!.page = page;
|
||||
history!.ep = chapter;
|
||||
if (maxPage > 1) {
|
||||
history!.maxPage = maxPage;
|
||||
}
|
||||
history!.readEpisode.add(chapter);
|
||||
history!.time = DateTime.now();
|
||||
HistoryManager().addHistory(history!);
|
||||
}
|
||||
}
|
||||
@@ -293,7 +321,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;
|
||||
}
|
||||
}
|
||||
|
@@ -18,7 +18,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
|
||||
bool get isOpen => _isOpen;
|
||||
|
||||
bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft ||
|
||||
bool get isReversed =>
|
||||
context.reader.mode == ReaderMode.galleryRightToLeft ||
|
||||
context.reader.mode == ReaderMode.continuousRightToLeft;
|
||||
|
||||
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) {
|
||||
@@ -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(
|
||||
border: isOpen
|
||||
? Border(
|
||||
top: BorderSide(
|
||||
color: Colors.grey.toOpacity(0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom),
|
||||
child: child,
|
||||
@@ -456,9 +628,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
var imagesOnScreen =
|
||||
continuesState.itemPositionsListener.itemPositions.value;
|
||||
var images = imagesOnScreen
|
||||
.map((e) => context.reader.images![e.index - 1])
|
||||
.map((e) => context.reader.images!.elementAtOrNull(e.index - 1))
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
String? selected;
|
||||
if (images.length > 1) {
|
||||
await showPopUpWidget(
|
||||
context,
|
||||
PopUpWidgetScaffold(
|
||||
@@ -476,6 +650,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
reader.type.comicSource!.key,
|
||||
reader.cid,
|
||||
reader.eid,
|
||||
reader.page,
|
||||
);
|
||||
}
|
||||
return InkWell(
|
||||
@@ -508,6 +683,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected = images.first;
|
||||
}
|
||||
if (selected == null) {
|
||||
return null;
|
||||
} else {
|
||||
@@ -554,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]) {
|
||||
@@ -563,6 +740,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
context.reader.stopVolumeEvent();
|
||||
}
|
||||
}
|
||||
if (key == "quickCollectImage") {
|
||||
addDragListener();
|
||||
}
|
||||
context.reader.update();
|
||||
},
|
||||
),
|
||||
@@ -665,6 +845,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
|
||||
late int _batteryLevel = 100;
|
||||
Timer? _timer;
|
||||
bool _hasBattery = false;
|
||||
BatteryState state = BatteryState.unknown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -676,29 +857,23 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
|
||||
void _checkBatteryAvailability() async {
|
||||
try {
|
||||
_batteryLevel = await _battery.batteryLevel;
|
||||
if (_batteryLevel != -1) {
|
||||
state = await _battery.batteryState;
|
||||
if (_batteryLevel > 0 && state != BatteryState.unknown) {
|
||||
setState(() {
|
||||
_hasBattery = true;
|
||||
});
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_battery.batteryLevel.then((level) => {
|
||||
if (_batteryLevel != level)
|
||||
{
|
||||
_battery.batteryLevel.then((level) {
|
||||
if (_batteryLevel != level) {
|
||||
setState(() {
|
||||
_batteryLevel = level;
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_hasBattery = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_hasBattery = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,7 +895,9 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
|
||||
IconData batteryIcon;
|
||||
Color batteryColor = context.colorScheme.onSurface;
|
||||
|
||||
if (batteryLevel >= 96) {
|
||||
if (state == BatteryState.charging) {
|
||||
batteryIcon = Icons.battery_charging_full;
|
||||
} else if (batteryLevel >= 96) {
|
||||
batteryIcon = Icons.battery_full_sharp;
|
||||
} else if (batteryLevel >= 84) {
|
||||
batteryIcon = Icons.battery_6_bar_sharp;
|
||||
|
@@ -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';
|
||||
@@ -139,7 +140,9 @@ class _SearchPageState extends State<SearchPage> {
|
||||
@override
|
||||
void initState() {
|
||||
var defaultSearchTarget = appdata.settings['defaultSearchTarget'];
|
||||
if (defaultSearchTarget != null &&
|
||||
if (defaultSearchTarget == "_aggregated_") {
|
||||
aggregatedSearch = true;
|
||||
} else if (defaultSearchTarget != null &&
|
||||
ComicSource.find(defaultSearchTarget) != null) {
|
||||
searchTarget = defaultSearchTarget;
|
||||
} else {
|
||||
@@ -182,7 +185,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: buildSearchOptions(),
|
||||
);
|
||||
yield buildSearchHistory();
|
||||
yield _SearchHistory(search);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +229,11 @@ class _SearchPageState extends State<SearchPage> {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
aggregatedSearch = value ?? false;
|
||||
if (!aggregatedSearch &&
|
||||
appdata.settings['defaultSearchTarget'] ==
|
||||
"_aggregated_") {
|
||||
searchTarget = sources.first.key;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -284,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 == "") {
|
||||
@@ -575,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -45,8 +45,9 @@ class _SearchResultPageState extends State<SearchResultPage> {
|
||||
if (suggestionsController.entry != null) {
|
||||
suggestionsController.remove();
|
||||
}
|
||||
text = checkAutoLanguage(text);
|
||||
setState(() {
|
||||
this.text = text;
|
||||
this.text = text!;
|
||||
});
|
||||
appdata.addSearchHistory(text);
|
||||
controller.currentText = text;
|
||||
@@ -92,13 +93,33 @@ class _SearchResultPageState extends State<SearchResultPage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String checkAutoLanguage(String text) {
|
||||
var setting = appdata.settings["autoAddLanguageFilter"] ?? 'none';
|
||||
if (setting == 'none') {
|
||||
return text;
|
||||
}
|
||||
var searchSource = sourceKey;
|
||||
// TODO: Move it to a better place
|
||||
const enabledSources = [
|
||||
'nhentai',
|
||||
'ehentai',
|
||||
];
|
||||
if (!enabledSources.contains(searchSource)) {
|
||||
return text;
|
||||
}
|
||||
if (!text.contains('language:')) {
|
||||
return '$text language:$setting';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
sourceKey = widget.sourceKey;
|
||||
controller = SearchBarController(
|
||||
currentText: widget.text,
|
||||
currentText: checkAutoLanguage(widget.text),
|
||||
onSearch: search,
|
||||
);
|
||||
sourceKey = widget.sourceKey;
|
||||
options = widget.options ?? const [];
|
||||
validateOptions();
|
||||
text = widget.text;
|
||||
@@ -162,6 +183,12 @@ class _SearchResultPageState extends State<SearchResultPage> {
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
onPressed: () async {
|
||||
if (suggestionOverlay != null) {
|
||||
suggestionsController.remove();
|
||||
}
|
||||
|
||||
var previousOptions = options;
|
||||
var previousSourceKey = sourceKey;
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
@@ -169,7 +196,11 @@ class _SearchResultPageState extends State<SearchResultPage> {
|
||||
return _SearchSettingsDialog(state: this);
|
||||
},
|
||||
);
|
||||
if (previousOptions != options || previousSourceKey != sourceKey) {
|
||||
text = checkAutoLanguage(controller.text);
|
||||
controller.currentText = text;
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@@ -61,6 +61,10 @@ class _AboutSettingsState extends State<AboutSettings> {
|
||||
},
|
||||
).fixHeight(32),
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Check for updates on startup".tl,
|
||||
settingKey: "checkUpdateOnStart",
|
||||
).toSliver(),
|
||||
ListTile(
|
||||
title: const Text("Github"),
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
@@ -102,7 +106,9 @@ Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
|
||||
return ContentDialog(
|
||||
title: "New version available".tl,
|
||||
content: Text(
|
||||
"A new version is available. Do you want to update now?".tl),
|
||||
"A new version is available. Do you want to update now?"
|
||||
.tl)
|
||||
.paddingHorizontal(8),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () {
|
||||
|
@@ -112,7 +112,8 @@ class _AppSettingsState extends State<AppSettings> {
|
||||
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')) {
|
||||
@@ -123,9 +124,9 @@ 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();
|
||||
App.forceRebuild();
|
||||
}
|
||||
}
|
||||
controller.close();
|
||||
|
@@ -30,35 +30,11 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
||||
).toSliver(),
|
||||
_PopupWindowSetting(
|
||||
title: "Explore Pages".tl,
|
||||
builder: () {
|
||||
var pages = <String, String>{};
|
||||
for (var c in ComicSource.all()) {
|
||||
for (var page in c.explorePages) {
|
||||
pages[page.title] = page.title;
|
||||
}
|
||||
}
|
||||
return _MultiPagesFilter(
|
||||
title: "Explore Pages".tl,
|
||||
settingsIndex: "explore_pages",
|
||||
pages: pages,
|
||||
);
|
||||
},
|
||||
builder: setExplorePagesWidget,
|
||||
).toSliver(),
|
||||
_PopupWindowSetting(
|
||||
title: "Category Pages".tl,
|
||||
builder: () {
|
||||
var pages = <String, String>{};
|
||||
for (var c in ComicSource.all()) {
|
||||
if (c.categoryData != null) {
|
||||
pages[c.categoryData!.key] = c.categoryData!.title;
|
||||
}
|
||||
}
|
||||
return _MultiPagesFilter(
|
||||
title: "Category Pages".tl,
|
||||
settingsIndex: "categories",
|
||||
pages: pages,
|
||||
);
|
||||
},
|
||||
builder: setCategoryPagesWidget,
|
||||
).toSliver(),
|
||||
_PopupWindowSetting(
|
||||
title: "Network Favorite Pages".tl,
|
||||
@@ -88,6 +64,30 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
||||
title: "Keyword blocking".tl,
|
||||
builder: () => const _ManageBlockingWordView(),
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Default Search Target".tl,
|
||||
settingKey: "defaultSearchTarget",
|
||||
optionTranslation: {
|
||||
'_aggregated_': "Aggregated".tl,
|
||||
...((){
|
||||
var map = <String, String>{};
|
||||
for (var c in ComicSource.all()) {
|
||||
map[c.key] = c.name;
|
||||
}
|
||||
return map;
|
||||
}()),
|
||||
},
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Auto Language Filters".tl,
|
||||
settingKey: "autoAddLanguageFilter",
|
||||
optionTranslation: {
|
||||
'none': "None".tl,
|
||||
'chinese': "Chinese",
|
||||
'english': "English",
|
||||
'japanese': "Japanese",
|
||||
},
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -108,8 +108,9 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
|
||||
return PopUpWidgetScaffold(
|
||||
title: "Keyword blocking".tl,
|
||||
tailing: [
|
||||
IconButton(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text("Add".tl),
|
||||
onPressed: add,
|
||||
),
|
||||
],
|
||||
@@ -135,7 +136,6 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
|
||||
void add() {
|
||||
showDialog(
|
||||
context: App.rootContext,
|
||||
barrierColor: Colors.black.toOpacity(0.1),
|
||||
builder: (context) {
|
||||
var controller = TextEditingController();
|
||||
String? error;
|
||||
@@ -160,7 +160,8 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
|
||||
actions: [
|
||||
Button.filled(
|
||||
onPressed: () {
|
||||
if(appdata.settings["blockedWords"].contains(controller.text)){
|
||||
if (appdata.settings["blockedWords"]
|
||||
.contains(controller.text)) {
|
||||
setState(() {
|
||||
error = "Keyword already exists".tl;
|
||||
});
|
||||
@@ -180,3 +181,31 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget setExplorePagesWidget() {
|
||||
var pages = <String, String>{};
|
||||
for (var c in ComicSource.all()) {
|
||||
for (var page in c.explorePages) {
|
||||
pages[page.title] = page.title.ts(c.key);
|
||||
}
|
||||
}
|
||||
return _MultiPagesFilter(
|
||||
title: "Explore Pages".tl,
|
||||
settingsIndex: "explore_pages",
|
||||
pages: pages,
|
||||
);
|
||||
}
|
||||
|
||||
Widget setCategoryPagesWidget() {
|
||||
var pages = <String, String>{};
|
||||
for (var c in ComicSource.all()) {
|
||||
if (c.categoryData != null) {
|
||||
pages[c.categoryData!.key] = c.categoryData!.title;
|
||||
}
|
||||
}
|
||||
return _MultiPagesFilter(
|
||||
title: "Category Pages".tl,
|
||||
settingsIndex: "categories",
|
||||
pages: pages,
|
||||
);
|
||||
}
|
@@ -16,24 +16,26 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
|
||||
SelectSetting(
|
||||
title: "Add new favorite to".tl,
|
||||
settingKey: "newFavoriteAddTo",
|
||||
optionTranslation: const {
|
||||
"start": "Start",
|
||||
"end": "End",
|
||||
optionTranslation: {
|
||||
"start": "Start".tl,
|
||||
"end": "End".tl,
|
||||
},
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Move favorite after reading".tl,
|
||||
settingKey: "moveFavoriteAfterRead",
|
||||
optionTranslation: const {
|
||||
"none": "None",
|
||||
"end": "End",
|
||||
"start": "Start",
|
||||
optionTranslation: {
|
||||
"none": "None".tl,
|
||||
"end": "End".tl,
|
||||
"start": "Start".tl,
|
||||
},
|
||||
).toSliver(),
|
||||
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,10 +46,19 @@ 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(),
|
||||
SelectSetting(
|
||||
title: "Click favorite".tl,
|
||||
settingKey: "onClickFavorite",
|
||||
optionTranslation: {
|
||||
"viewDetail": "View Detail".tl,
|
||||
"read": "Read".tl,
|
||||
},
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|