mirror of
https://github.com/venera-app/venera.git
synced 2025-12-15 06:41:14 +00:00
Compare commits
97 Commits
v1.4.7-dev
...
fix/local-
| Author | SHA1 | Date | |
|---|---|---|---|
| 40ef8a63b0 | |||
| 053293839e | |||
|
|
f0be40c6d7 | ||
|
|
da5b64abb0 | ||
|
|
7e3addf7a6 | ||
|
|
b9c06779ad | ||
|
|
7e928d2c9c | ||
|
|
b3239757a8 | ||
|
|
bdaa10fa06 | ||
|
|
4296768c8d | ||
|
|
49abf92724 | ||
|
|
38376c5b2e | ||
|
|
4053faa186 | ||
|
|
17fd9b3606 | ||
|
|
792c41fdc3 | ||
|
|
05e661b101 | ||
|
|
46131fcf41 | ||
|
|
59750332cd | ||
|
|
fd017a35f9 | ||
|
|
3834d0211f | ||
|
|
10bec09c80 | ||
|
|
62dd742280 | ||
|
|
03603a53e1 | ||
|
|
2847af91ff | ||
|
|
0bc01f718a | ||
|
|
b60119170a | ||
|
|
f4af6f3954 | ||
|
|
9e9d1ac3b1 | ||
|
|
b3b9199cc3 | ||
| dd00ba11c8 | |||
| e87fb535b8 | |||
|
|
df1649def6 | ||
|
|
99559eaff8 | ||
|
|
39a834815d | ||
|
|
a9e76201f3 | ||
|
|
0044d95e97 | ||
|
|
9636cf62cb | ||
| 5ccf0eea43 | |||
| e8d98e8274 | |||
|
|
d22501198a | ||
|
|
be23c4fe68 | ||
|
|
a8422780a0 | ||
|
|
75c2a3a417 | ||
|
|
3d194d7f6a | ||
| 09a1d2821c | |||
|
|
7842b5a1ac | ||
|
|
079f574e2f | ||
|
|
b08f11f6ac | ||
|
|
cd925df125 | ||
|
|
8c87c4a906 | ||
|
|
c234a53518 | ||
| 49fd64358c | |||
| 3426d707fe | |||
| ebc106d45b | |||
| 0cda9a2921 | |||
| 0eb5d76687 | |||
| 29d25f7fcd | |||
| 7d60e78f27 | |||
|
|
e93b56a008 | ||
|
|
d10873a903 | ||
|
|
2d27f7d650 | ||
| e1fbdfbd50 | |||
| 0a5b70b161 | |||
|
|
5a76a10fb2 | ||
| 9173665afe | |||
|
|
f09e766a8a | ||
| e0ea449c17 | |||
| c438a84537 | |||
| 8c625e212a | |||
| ab786ed2ab | |||
| d9303aab2e | |||
|
|
b7f79476c8 | ||
|
|
44bcce4385 | ||
|
|
6ce6066de2 | ||
|
|
7fa48cec29 | ||
| e549a18dbf | |||
| c17c4abb5b | |||
| af57bc31b1 | |||
| 16449a1440 | |||
| a7c1983f35 | |||
| 4c257d7178 | |||
| 3a9d634edf | |||
|
|
e179c8f67f | ||
|
|
c4b85471c1 | ||
|
|
a898b57d96 | ||
| 50c6bec4cd | |||
|
|
8c44f83d6c | ||
|
|
103b6b2832 | ||
| 4129349c70 | |||
| 77a9aa5457 | |||
| 97940b9492 | |||
| 7945c0e54f | |||
| dfee65c3af | |||
| fa2dbd79f6 | |||
| 9a9f539906 | |||
| d7331f36e9 | |||
|
|
d0b76de465 |
13
.github/workflows/main.yml
vendored
13
.github/workflows/main.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
|
||||
- run: flutter pub get
|
||||
# Step 1: Decode and install the certificate
|
||||
- name: Decode and install certificate
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
|
||||
- run: flutter pub get
|
||||
- run: flutter build ios --release --no-codesign
|
||||
- run: |
|
||||
@@ -116,6 +116,8 @@ jobs:
|
||||
run: |
|
||||
choco install yq -y
|
||||
pip install httpx
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup --no-progress
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
@@ -141,7 +143,7 @@ jobs:
|
||||
- run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
dart pub global activate -s git https://github.com/venera-app/flutter_to_debian.git
|
||||
- run: python3 debian/build.py x64
|
||||
- run: dart run flutter_to_arch
|
||||
- run: |
|
||||
@@ -169,7 +171,10 @@ jobs:
|
||||
flutter pub get
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
dart pub global activate -s git https://github.com/venera-app/flutter_to_debian.git
|
||||
- name: "Patch font"
|
||||
run: |
|
||||
dart run patch/font.dart
|
||||
- run: python3 debian/build.py arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
13
.github/workflows/update_alt_store.yml
vendored
13
.github/workflows/update_alt_store.yml
vendored
@@ -40,8 +40,19 @@ jobs:
|
||||
if git diff --staged --quiet; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# Create a new branch for the PR
|
||||
branch_name="update-altstore-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -m "Updated source with latest release"
|
||||
git push
|
||||
git push -u origin "$branch_name"
|
||||
|
||||
# Create PR using GitHub CLI
|
||||
gh pr create \
|
||||
--title "Update AltStore source with latest release" \
|
||||
--body "This PR updates the alt_store.json file with the latest release information." \
|
||||
--head "$branch_name" \
|
||||
--base master
|
||||
|
||||
echo "changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
# venera
|
||||
[](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://t.me/venera_release)
|
||||
|
||||
A comic reader that support reading local and network comics.
|
||||
[](https://github.com/venera-app/venera/releases)
|
||||
[](https://aur.archlinux.org/packages/venera-bin)
|
||||
[](https://f-droid.org/packages/com.github.wgh136.venera/)
|
||||
|
||||
[<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/)
|
||||
A comic reader that support reading local and network comics.
|
||||
|
||||
## Features
|
||||
- Read local comics
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
"bundleIdentifier": "com.github.wgh136.venera",
|
||||
"developerName": "wgh136",
|
||||
"subtitle": "A comic reader that supports reading local and network comics",
|
||||
"version": "1.4.5",
|
||||
"versionDate": "2025-06-18",
|
||||
"versionDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||
"version": "1.6.0",
|
||||
"versionDate": "2025-11-01",
|
||||
"versionDescription": "What's Changed\r\n* Update AltStore source with latest release by @github-actions[bot] in https://github.com/venera-app/venera/pull/559\r\n* \u8c03\u6574\u591a\u6536\u85cf\u5939\u6f2b\u753b\u6e90\u7684\u6536\u85cf\u72b6\u6001\u663e\u793a\u903b\u8f91 by @Ftbom in https://github.com/venera-app/venera/pull/571\r\n* Enhance onResponse to support Future and validate result type by @wgh136 in https://github.com/venera-app/venera/pull/574\r\n* [iOS] Enable full screen swipe back gesture by @liulifox233 in https://github.com/venera-app/venera/pull/575\r\n* [linux] Fix linux nhentai cover image by @4b1tQu4ntN3k0 in https://github.com/venera-app/venera/pull/578\r\n* feat: \u652f\u6301\u8fc7\u6ee4\u9605\u8bfb\u5b8c\u6210\u60c5\u51b5 by @luckyray-fan in https://github.com/venera-app/venera/pull/582\r\n* Fix chinese character issue when compressing files. Close 565 by @ynyx631 in https://github.com/venera-app/venera/pull/583\r\n* Add support for ArrayBuffer to showInputDialog. by @wgh136 in https://github.com/venera-app/venera/pull/585\r\n* Added support for localstorage when logging in via webview. by @wgh136 in https://github.com/venera-app/venera/pull/586\r\n* Fix the issue of the comic list loading infinitely. Close 584 by @ynyx631 in https://github.com/venera-app/venera/pull/588\r\n* Save data when mark all as read by @lings03 in https://github.com/venera-app/venera/pull/592\r\n* Chapter comments. by @lings03 in https://github.com/venera-app/venera/pull/593\r\n* Optimize favorite page and home page. by @lings03 in https://github.com/venera-app/venera/pull/594\r\n* Update version code by @wgh136 in https://github.com/venera-app/venera/pull/596\r\n* Fix missing depends in deb package. Close 587 by @wgh136 in https://github.com/venera-app/venera/pull/597\r\n* feat: \u672c\u5730\u6536\u85cf\u641c\u7d22\u652f\u6301\u8f6c\u5c0f\u5199\u5339\u914d by @luckyray-fan in https://github.com/venera-app/venera/pull/598\r\n* Fix editor page gesture confict by @liulifox233 in https://github.com/venera-app/venera/pull/600\r \nNew Contributors\r\n* @github-actions[bot] made their first contribution in https://github.com/venera-app/venera/pull/559\r\n* @Ftbom made their first contribution in https://github.com/venera-app/venera/pull/571\r\n* @liulifox233 made their first contribution in https://github.com/venera-app/venera/pull/575\r\n* @4b1tQu4ntN3k0 made their first contribution in https://github.com/venera-app/venera/pull/578\r\n* @ynyx631 made their first contribution in https://github.com/venera-app/venera/pull/583\r \nFull Changelog: https://github.com/venera-app/venera/compare/v1.5.3...v1.6.0",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.6.0/venera-ios-1.6.0%2B160.ipa",
|
||||
"localizedDescription": "A comic reader that supports reading local and network comics",
|
||||
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||
"tintColor": "#0784FC",
|
||||
"category": "utilities",
|
||||
"size": 14960268,
|
||||
"size": 15064741,
|
||||
"appPermissions": {
|
||||
"entitlements": [
|
||||
"application-identifier",
|
||||
@@ -39,6 +39,20 @@
|
||||
}
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.6.0",
|
||||
"date": "2025-11-01",
|
||||
"localizedDescription": "What's Changed\r\n* Update AltStore source with latest release by @github-actions[bot] in https://github.com/venera-app/venera/pull/559\r\n* \u8c03\u6574\u591a\u6536\u85cf\u5939\u6f2b\u753b\u6e90\u7684\u6536\u85cf\u72b6\u6001\u663e\u793a\u903b\u8f91 by @Ftbom in https://github.com/venera-app/venera/pull/571\r\n* Enhance onResponse to support Future and validate result type by @wgh136 in https://github.com/venera-app/venera/pull/574\r\n* [iOS] Enable full screen swipe back gesture by @liulifox233 in https://github.com/venera-app/venera/pull/575\r\n* [linux] Fix linux nhentai cover image by @4b1tQu4ntN3k0 in https://github.com/venera-app/venera/pull/578\r\n* feat: \u652f\u6301\u8fc7\u6ee4\u9605\u8bfb\u5b8c\u6210\u60c5\u51b5 by @luckyray-fan in https://github.com/venera-app/venera/pull/582\r\n* Fix chinese character issue when compressing files. Close 565 by @ynyx631 in https://github.com/venera-app/venera/pull/583\r\n* Add support for ArrayBuffer to showInputDialog. by @wgh136 in https://github.com/venera-app/venera/pull/585\r\n* Added support for localstorage when logging in via webview. by @wgh136 in https://github.com/venera-app/venera/pull/586\r\n* Fix the issue of the comic list loading infinitely. Close 584 by @ynyx631 in https://github.com/venera-app/venera/pull/588\r\n* Save data when mark all as read by @lings03 in https://github.com/venera-app/venera/pull/592\r\n* Chapter comments. by @lings03 in https://github.com/venera-app/venera/pull/593\r\n* Optimize favorite page and home page. by @lings03 in https://github.com/venera-app/venera/pull/594\r\n* Update version code by @wgh136 in https://github.com/venera-app/venera/pull/596\r\n* Fix missing depends in deb package. Close 587 by @wgh136 in https://github.com/venera-app/venera/pull/597\r\n* feat: \u672c\u5730\u6536\u85cf\u641c\u7d22\u652f\u6301\u8f6c\u5c0f\u5199\u5339\u914d by @luckyray-fan in https://github.com/venera-app/venera/pull/598\r\n* Fix editor page gesture confict by @liulifox233 in https://github.com/venera-app/venera/pull/600\r \nNew Contributors\r\n* @github-actions[bot] made their first contribution in https://github.com/venera-app/venera/pull/559\r\n* @Ftbom made their first contribution in https://github.com/venera-app/venera/pull/571\r\n* @liulifox233 made their first contribution in https://github.com/venera-app/venera/pull/575\r\n* @4b1tQu4ntN3k0 made their first contribution in https://github.com/venera-app/venera/pull/578\r\n* @ynyx631 made their first contribution in https://github.com/venera-app/venera/pull/583\r \nFull Changelog: https://github.com/venera-app/venera/compare/v1.5.3...v1.6.0",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.6.0/venera-ios-1.6.0%2B160.ipa",
|
||||
"size": 15064741
|
||||
},
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"date": "2025-10-13",
|
||||
"localizedDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"size": 15047841
|
||||
},
|
||||
{
|
||||
"version": "1.4.5",
|
||||
"date": "2025-06-18",
|
||||
@@ -59,6 +73,26 @@
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.4.5 - Venera 18/06/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
|
||||
},
|
||||
{
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": "Update of Venera just got released!",
|
||||
"date": "2025-10-13T12:47:27Z",
|
||||
"identifier": "release-v1.5.3",
|
||||
"notify": true,
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.5.3 - Venera 13/10/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.5.3"
|
||||
},
|
||||
{
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": "Update of Venera just got released!",
|
||||
"date": "2025-11-01T07:31:38Z",
|
||||
"identifier": "release-v1.6.0",
|
||||
"notify": true,
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.6.0 - Venera 01/11/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.6.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,7 +23,7 @@ linter:
|
||||
rules:
|
||||
collection_methods_unrelated_type: false
|
||||
use_build_context_synchronously: false
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
avoid_print: false
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
|
||||
@@ -34,6 +34,12 @@ android {
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion "28.0.13004108"
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging true
|
||||
}
|
||||
}
|
||||
|
||||
splits{
|
||||
abi {
|
||||
reset()
|
||||
@@ -78,6 +84,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
@@ -58,8 +59,6 @@
|
||||
<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
|
||||
|
||||
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.9.0' apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
134
assets/init.js
134
assets/init.js
@@ -4,6 +4,18 @@ Venera JavaScript Library
|
||||
This library provides a set of APIs for interacting with the Venera app.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @function sendMessage
|
||||
* @global
|
||||
* @param {Object} message
|
||||
* @returns {any}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set a timeout to execute a callback function after a specified delay.
|
||||
* @param callback {Function}
|
||||
* @param delay {number} - delay in milliseconds
|
||||
*/
|
||||
function setTimeout(callback, delay) {
|
||||
sendMessage({
|
||||
method: 'delay',
|
||||
@@ -178,6 +190,21 @@ let Convert = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @param {ArrayBuffer} key
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
encryptAesEcb: (value, key) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "aes-ecb",
|
||||
value: value,
|
||||
key: key,
|
||||
isEncode: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @param {ArrayBuffer} key
|
||||
@@ -193,6 +220,23 @@ let Convert = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @param {ArrayBuffer} key
|
||||
* @param {ArrayBuffer} iv
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
encryptAesCbc: (value, key, iv) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "aes-cbc",
|
||||
value: value,
|
||||
key: key,
|
||||
iv: iv,
|
||||
isEncode: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @param {ArrayBuffer} key
|
||||
@@ -213,20 +257,58 @@ let Convert = {
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @param {ArrayBuffer} key
|
||||
* @param {ArrayBuffer} iv
|
||||
* @param {number} blockSize
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
decryptAesCfb: (value, key, blockSize) => {
|
||||
encryptAesCfb: (value, key, iv, blockSize) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "aes-cfb",
|
||||
value: value,
|
||||
key: key,
|
||||
iv: iv,
|
||||
blockSize: blockSize,
|
||||
isEncode: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @param {ArrayBuffer} key
|
||||
* @param {ArrayBuffer} iv
|
||||
* @param {number} blockSize
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
decryptAesCfb: (value, key, iv, blockSize) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "aes-cfb",
|
||||
value: value,
|
||||
key: key,
|
||||
iv: iv,
|
||||
blockSize: blockSize,
|
||||
isEncode: false
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @param {ArrayBuffer} key
|
||||
* @param {number} blockSize
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
encryptAesOfb: (value, key, blockSize) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "aes-ofb",
|
||||
value: value,
|
||||
key: key,
|
||||
blockSize: blockSize,
|
||||
isEncode: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @param {ArrayBuffer} key
|
||||
@@ -383,9 +465,10 @@ let Network = {
|
||||
* @param {string} url - The URL to send the request to.
|
||||
* @param {Object} headers - The headers to include in the request.
|
||||
* @param data - The data to send with the request.
|
||||
* @param {Object} extra - Extra options to pass to the interceptor.
|
||||
* @returns {Promise<{status: number, headers: {}, body: ArrayBuffer}>} The response from the request.
|
||||
*/
|
||||
async fetchBytes(method, url, headers, data) {
|
||||
async fetchBytes(method, url, headers, data, extra) {
|
||||
let result = await sendMessage({
|
||||
method: 'http',
|
||||
http_method: method,
|
||||
@@ -393,6 +476,7 @@ let Network = {
|
||||
url: url,
|
||||
headers: headers,
|
||||
data: data,
|
||||
extra: extra,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -408,15 +492,17 @@ let Network = {
|
||||
* @param {string} url - The URL to send the request to.
|
||||
* @param {Object} headers - The headers to include in the request.
|
||||
* @param data - The data to send with the request.
|
||||
* @param {Object} extra - Extra options to pass to the interceptor.
|
||||
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
|
||||
*/
|
||||
async sendRequest(method, url, headers, data) {
|
||||
async sendRequest(method, url, headers, data, extra) {
|
||||
let result = await sendMessage({
|
||||
method: 'http',
|
||||
http_method: method,
|
||||
url: url,
|
||||
headers: headers,
|
||||
data: data,
|
||||
extra: extra,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
@@ -430,10 +516,11 @@ let Network = {
|
||||
* Sends an HTTP GET request.
|
||||
* @param {string} url - The URL to send the request to.
|
||||
* @param {Object} headers - The headers to include in the request.
|
||||
* @param {Object} extra - Extra options to pass to the interceptor.
|
||||
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
|
||||
*/
|
||||
async get(url, headers) {
|
||||
return this.sendRequest('GET', url, headers);
|
||||
async get(url, headers, extra) {
|
||||
return this.sendRequest('GET', url, headers, extra);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -441,10 +528,11 @@ let Network = {
|
||||
* @param {string} url - The URL to send the request to.
|
||||
* @param {Object} headers - The headers to include in the request.
|
||||
* @param data - The data to send with the request.
|
||||
* @param {Object} extra - Extra options to pass to the interceptor.
|
||||
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
|
||||
*/
|
||||
async post(url, headers, data) {
|
||||
return this.sendRequest('POST', url, headers, data);
|
||||
async post(url, headers, data, extra) {
|
||||
return this.sendRequest('POST', url, headers, data, extra);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -452,10 +540,11 @@ let Network = {
|
||||
* @param {string} url - The URL to send the request to.
|
||||
* @param {Object} headers - The headers to include in the request.
|
||||
* @param data - The data to send with the request.
|
||||
* @param {Object} extra - Extra options to pass to the interceptor.
|
||||
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
|
||||
*/
|
||||
async put(url, headers, data) {
|
||||
return this.sendRequest('PUT', url, headers, data);
|
||||
async put(url, headers, data, extra) {
|
||||
return this.sendRequest('PUT', url, headers, data, extra);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -463,20 +552,22 @@ let Network = {
|
||||
* @param {string} url - The URL to send the request to.
|
||||
* @param {Object} headers - The headers to include in the request.
|
||||
* @param data - The data to send with the request.
|
||||
* @param {Object} extra - Extra options to pass to the interceptor.
|
||||
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
|
||||
*/
|
||||
async patch(url, headers, data) {
|
||||
return this.sendRequest('PATCH', url, headers, data);
|
||||
async patch(url, headers, data, extra) {
|
||||
return this.sendRequest('PATCH', url, headers, data, extra);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends an HTTP DELETE request.
|
||||
* @param {string} url - The URL to send the request to.
|
||||
* @param {Object} headers - The headers to include in the request.
|
||||
* @param {Object} extra - Extra options to pass to the interceptor.
|
||||
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
|
||||
*/
|
||||
async delete(url, headers) {
|
||||
return this.sendRequest('DELETE', url, headers);
|
||||
async delete(url, headers, extra) {
|
||||
return this.sendRequest('DELETE', url, headers, extra);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1322,7 +1413,7 @@ let UI = {
|
||||
* 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.
|
||||
* @param image {string?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
|
||||
* @param image {string | ArrayBuffer | null | undefined} - Since 1.4.6, you can pass an image url to show an image in the dialog. Since 1.5.3, you can also pass an ArrayBuffer to show a custom image.
|
||||
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
|
||||
*/
|
||||
showInputDialog: (title, validator, image) => {
|
||||
@@ -1411,4 +1502,19 @@ function getClipboard() {
|
||||
return sendMessage({
|
||||
method: 'getClipboard'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a function with arguments. The function will be executed in the engine pool which is not in the main thread.
|
||||
* @param func {string} - A js code string which can be evaluated to a function. The function will receive the args as its only argument.
|
||||
* @param args {any[]} - The arguments to pass to the function.
|
||||
* @returns {Promise<any>} - The result of the function.
|
||||
* @since 1.5.0
|
||||
*/
|
||||
function compute(func, ...args) {
|
||||
return sendMessage({
|
||||
method: 'compute',
|
||||
function: func,
|
||||
args: args
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,7 @@
|
||||
"Move to folder": "移动到文件夹",
|
||||
"Copy to folder": "复制到文件夹",
|
||||
"Delete Comic": "删除漫画",
|
||||
"Jump to Detail": "跳转详情",
|
||||
"Delete @c comics?": "删除 @c 本漫画?",
|
||||
"Add comic source": "添加漫画源",
|
||||
"Delete comic source '@n' ?": "删除漫画源 '@n' ?",
|
||||
@@ -69,6 +70,9 @@
|
||||
"Next": "前进",
|
||||
"Login with webview": "通过网页登录",
|
||||
"Read": "阅读",
|
||||
"Completed": "已完成",
|
||||
"UnCompleted": "未完成",
|
||||
"Filter reading status": "过滤阅读状态",
|
||||
"Download": "下载",
|
||||
"Favorite": "收藏",
|
||||
"Comments": "评论",
|
||||
@@ -83,7 +87,10 @@
|
||||
"New Folder": "新建文件夹",
|
||||
"Reading": "阅读中",
|
||||
"Appearance": "外观",
|
||||
"Network Favorites": "网络收藏",
|
||||
"Local Favorites": "本地收藏",
|
||||
"Show local favorites before network favorites": "在网络收藏之前显示本地收藏",
|
||||
"Auto close favorite panel after operation": "自动关闭收藏面板",
|
||||
"APP": "应用",
|
||||
"About": "关于",
|
||||
"Display mode of comic tile": "漫画缩略图的显示模式",
|
||||
@@ -194,6 +201,10 @@
|
||||
"Sync Data": "同步数据",
|
||||
"Syncing Data": "正在同步数据",
|
||||
"Data Sync": "数据同步",
|
||||
"Skip Setting Fields": "跳过设置项",
|
||||
"Skip Setting Fields (Optional)": "跳过设置项(可选)",
|
||||
"When sync data, skip certain setting fields, which means these won't be uploaded / override.": "同步时跳过指定设置项,这些项不会被上传或覆盖。",
|
||||
"See source code for available fields.": "可用的设置项名称详见源码。",
|
||||
"Quick Favorite": "快速收藏",
|
||||
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
|
||||
"Added": "已添加",
|
||||
@@ -376,6 +387,8 @@
|
||||
"Continuous": "连续",
|
||||
"Display mode of comic list": "漫画列表的显示模式",
|
||||
"Show Page Number": "显示页码",
|
||||
"Show Chapter Comments": "显示章节评论",
|
||||
"Chapter Comments": "章节评论",
|
||||
"Jump to page": "跳转到页面",
|
||||
"Page": "页面",
|
||||
"Jump": "跳转",
|
||||
@@ -461,6 +474,7 @@
|
||||
"Move": "移動",
|
||||
"Move to folder": "移動到資料夾",
|
||||
"Copy to folder": "複製到資料夾",
|
||||
"Jump to Detail": "跳轉詳情",
|
||||
"Delete Comic": "刪除漫畫",
|
||||
"Delete @c comics?": "刪除 @c 本漫畫?",
|
||||
"Add comic source": "添加漫畫源",
|
||||
@@ -484,6 +498,9 @@
|
||||
"Next": "前進",
|
||||
"Login with webview": "透過網頁登入",
|
||||
"Read": "閱讀",
|
||||
"Completed": "已完成",
|
||||
"UnCompleted": "未完成",
|
||||
"Filter reading status": "過濾閱讀狀態",
|
||||
"Download": "下載",
|
||||
"Favorite": "收藏",
|
||||
"Comments": "評論",
|
||||
@@ -497,7 +514,10 @@
|
||||
"New Folder": "建立資料夾",
|
||||
"Reading": "閱讀中",
|
||||
"Appearance": "外觀",
|
||||
"Network Favorites": "網路收藏",
|
||||
"Local Favorites": "本機收藏",
|
||||
"Show local favorites before network favorites": "在網路收藏之前顯示本機收藏",
|
||||
"Auto close favorite panel after operation": "自動關閉收藏面板",
|
||||
"APP": "應用",
|
||||
"About": "關於",
|
||||
"Display mode of comic tile": "漫畫縮圖的顯示模式",
|
||||
@@ -608,6 +628,10 @@
|
||||
"Sync Data": "同步資料",
|
||||
"Syncing Data": "正在同步資料",
|
||||
"Data Sync": "資料同步",
|
||||
"Skip Setting Fields": "跳過設定項",
|
||||
"Skip Setting Fields (Optional)": "跳過設定項(可選)",
|
||||
"When sync data, skip certain setting fields, which means these won't be uploaded / override.": "同步時跳過指定設定項,這些項不會被上傳或覆寫。",
|
||||
"See source code for available fields.": "可用的設定項名稱詳見源碼。",
|
||||
"Quick Favorite": "快速收藏",
|
||||
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
|
||||
"Added": "已添加",
|
||||
@@ -790,6 +814,8 @@
|
||||
"Continuous": "連續",
|
||||
"Display mode of comic list": "漫畫列表的顯示模式",
|
||||
"Show Page Number": "顯示頁碼",
|
||||
"Show Chapter Comments": "顯示章節評論",
|
||||
"Chapter Comments": "章節評論",
|
||||
"Jump to page": "跳轉到頁面",
|
||||
"Page": "頁面",
|
||||
"Jump": "跳轉",
|
||||
|
||||
@@ -553,6 +553,51 @@ If `load` function is implemented, `loadNext` function will be ignored.
|
||||
*/
|
||||
sendComment: async (comicId, subId, content, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] load chapter comments
|
||||
*
|
||||
* Chapter comments are displayed in the reader.
|
||||
* Same rich text support as loadComments.
|
||||
*
|
||||
* Note: To control reply functionality:
|
||||
* - If a comment does not support replies, set its `id` to null/undefined
|
||||
* - Or set its `replyCount` to null/undefined
|
||||
* - The reply button will only show when both `id` and `replyCount` are present
|
||||
*
|
||||
* @param comicId {string}
|
||||
* @param epId {string} - chapter id
|
||||
* @param page {number}
|
||||
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
|
||||
* @returns {Promise<{comments: Comment[], maxPage: number?}>}
|
||||
*
|
||||
* @example
|
||||
* // Example for comments without reply support:
|
||||
* return {
|
||||
* comments: data.list.map(e => ({
|
||||
* userName: e.user_name,
|
||||
* avatar: e.user_avatar,
|
||||
* content: e.comment,
|
||||
* time: e.create_at,
|
||||
* replyCount: null, // or undefined - no reply support
|
||||
* id: null, // or undefined - no reply support
|
||||
* })),
|
||||
* maxPage: Math.ceil(total / 20)
|
||||
* }
|
||||
*/
|
||||
loadChapterComments: async (comicId, epId, page, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] send a chapter comment, return any value to indicate success
|
||||
* @param comicId {string}
|
||||
* @param epId {string} - chapter id
|
||||
* @param content {string}
|
||||
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
sendChapterComment: async (comicId, epId, content, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] like or unlike a comment
|
||||
|
||||
@@ -17,6 +17,7 @@ ImageProvider? _findImageProvider(Comic comic) {
|
||||
comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
fallbackToLocalCover: comic is FavoriteItem,
|
||||
);
|
||||
}
|
||||
return image;
|
||||
@@ -752,9 +753,9 @@ class SliverGridComics extends StatefulWidget {
|
||||
|
||||
final List<MenuEntry> Function(Comic)? menuBuilder;
|
||||
|
||||
final void Function(Comic)? onTap;
|
||||
final void Function(Comic, int heroID)? onTap;
|
||||
|
||||
final void Function(Comic)? onLongPressed;
|
||||
final void Function(Comic, int heroID)? onLongPressed;
|
||||
|
||||
@override
|
||||
State<SliverGridComics> createState() => _SliverGridComicsState();
|
||||
@@ -855,52 +856,51 @@ class _SliverGridComics extends StatelessWidget {
|
||||
|
||||
final List<MenuEntry> Function(Comic)? menuBuilder;
|
||||
|
||||
final void Function(Comic)? onTap;
|
||||
final void Function(Comic, int heroID)? onTap;
|
||||
|
||||
final void Function(Comic)? onLongPressed;
|
||||
final void Function(Comic, int heroID)? onLongPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == comics.length - 1) {
|
||||
onLastItemBuild?.call();
|
||||
}
|
||||
var badge = badgeBuilder?.call(comics[index]);
|
||||
var isSelected =
|
||||
selection == null ? false : selection![comics[index]] ?? false;
|
||||
var comic = ComicTile(
|
||||
comic: comics[index],
|
||||
badge: badge,
|
||||
menuOptions: menuBuilder?.call(comics[index]),
|
||||
onTap: onTap != null ? () => onTap!(comics[index]) : null,
|
||||
onLongPressed: onLongPressed != null
|
||||
? () => onLongPressed!(comics[index])
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == comics.length - 1) {
|
||||
onLastItemBuild?.call();
|
||||
}
|
||||
var badge = badgeBuilder?.call(comics[index]);
|
||||
var isSelected = selection == null
|
||||
? false
|
||||
: selection![comics[index]] ?? false;
|
||||
var comic = ComicTile(
|
||||
comic: comics[index],
|
||||
badge: badge,
|
||||
menuOptions: menuBuilder?.call(comics[index]),
|
||||
onTap: onTap != null
|
||||
? () => onTap!(comics[index], heroIDs[index])
|
||||
: null,
|
||||
onLongPressed: onLongPressed != null
|
||||
? () => onLongPressed!(comics[index], heroIDs[index])
|
||||
: null,
|
||||
heroID: heroIDs[index],
|
||||
);
|
||||
if (selection == null) {
|
||||
return comic;
|
||||
}
|
||||
return AnimatedContainer(
|
||||
key: ValueKey(comics[index].id),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.secondaryContainer.toOpacity(0.72)
|
||||
: null,
|
||||
heroID: heroIDs[index],
|
||||
);
|
||||
if (selection == null) {
|
||||
return comic;
|
||||
}
|
||||
return AnimatedContainer(
|
||||
key: ValueKey(comics[index].id),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer
|
||||
.toOpacity(0.72)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: comic,
|
||||
);
|
||||
},
|
||||
childCount: comics.length,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: comic,
|
||||
);
|
||||
}, childCount: comics.length),
|
||||
gridDelegate: SliverGridDelegateWithComics(),
|
||||
);
|
||||
}
|
||||
@@ -1158,7 +1158,7 @@ class ComicListState extends State<ComicList> {
|
||||
if (res.data.isEmpty) {
|
||||
setState(() {
|
||||
_data[page] = const [];
|
||||
_maxPage = page;
|
||||
_maxPage ??= page;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
@@ -1281,8 +1281,8 @@ class ComicListState extends State<ComicList> {
|
||||
],
|
||||
);
|
||||
}
|
||||
if (_data[_page] == null) {
|
||||
_loadPage(_page);
|
||||
if (_data[1] == null) {
|
||||
_loadPage(1);
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.errorLeading != null) widget.errorLeading!,
|
||||
@@ -1303,7 +1303,7 @@ class ComicListState extends State<ComicList> {
|
||||
comics: _data.values.expand((element) => element).toList(),
|
||||
menuBuilder: widget.menuBuilder,
|
||||
onLastItemBuild: () {
|
||||
if (_error == null && (_maxPage == null || _page < _maxPage!)) {
|
||||
if (_error == null && (_maxPage == null || _data.length < _maxPage!)) {
|
||||
_loadPage(_data.length + 1);
|
||||
}
|
||||
},
|
||||
@@ -1333,7 +1333,7 @@ class ComicListState extends State<ComicList> {
|
||||
],
|
||||
).paddingHorizontal(16).paddingVertical(8),
|
||||
)
|
||||
else if (_maxPage == null || _page < _maxPage!)
|
||||
else if (_maxPage == null || _data.length < _maxPage!)
|
||||
const SliverListLoadingIndicator(),
|
||||
if (widget.trailingSliver != null) widget.trailingSliver!,
|
||||
],
|
||||
@@ -1626,7 +1626,7 @@ class _SMClipper extends CustomClipper<Rect> {
|
||||
|
||||
class SimpleComicTile extends StatelessWidget {
|
||||
const SimpleComicTile(
|
||||
{super.key, required this.comic, this.onTap, this.withTitle = false});
|
||||
{super.key, required this.comic, this.onTap, this.withTitle = false, this.heroID});
|
||||
|
||||
final Comic comic;
|
||||
|
||||
@@ -1634,6 +1634,8 @@ class SimpleComicTile extends StatelessWidget {
|
||||
|
||||
final bool withTitle;
|
||||
|
||||
final int? heroID;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var image = _findImageProvider(comic);
|
||||
@@ -1659,6 +1661,13 @@ class SimpleComicTile extends StatelessWidget {
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (heroID != null) {
|
||||
child = Hero(
|
||||
tag: "cover$heroID",
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = AnimatedTapRegion(
|
||||
borderRadius: 8,
|
||||
onTap: onTap ??
|
||||
@@ -1667,6 +1676,9 @@ class SimpleComicTile extends StatelessWidget {
|
||||
() => ComicPage(
|
||||
id: comic.id,
|
||||
sourceKey: comic.sourceKey,
|
||||
cover: comic.cover,
|
||||
title: comic.title,
|
||||
heroID: heroID,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -40,7 +42,6 @@ mixin class JsUiApi {
|
||||
var image = message['image'];
|
||||
if (title is! String) return;
|
||||
if (validator != null && validator is! JSInvokable) return;
|
||||
if (image != null && image is! String) return;
|
||||
return _showInputDialog(title, validator, image);
|
||||
case 'showSelectDialog':
|
||||
var title = message['title'];
|
||||
@@ -126,13 +127,25 @@ mixin class JsUiApi {
|
||||
controller?.close();
|
||||
}
|
||||
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async {
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, dynamic image) async {
|
||||
String? result;
|
||||
var func = validator == null ? null : JSAutoFreeFunction(validator);
|
||||
String? imageUrl;
|
||||
Uint8List? imageData;
|
||||
if (image != null) {
|
||||
if (image is String) {
|
||||
imageUrl = image;
|
||||
} else if (image is Uint8List) {
|
||||
imageData = image;
|
||||
} else if (image is List<int>) {
|
||||
imageData = Uint8List.fromList(image);
|
||||
}
|
||||
}
|
||||
await showInputDialog(
|
||||
context: App.rootContext,
|
||||
title: title,
|
||||
image: image,
|
||||
image: imageUrl,
|
||||
imageData: imageData,
|
||||
onConfirm: (v) {
|
||||
if (func != null) {
|
||||
var res = func.call([v]);
|
||||
|
||||
@@ -7,6 +7,7 @@ class NetworkError extends StatelessWidget {
|
||||
this.retry,
|
||||
this.withAppbar = true,
|
||||
this.buttonText,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String message;
|
||||
@@ -17,6 +18,8 @@ class NetworkError extends StatelessWidget {
|
||||
|
||||
final String? buttonText;
|
||||
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var cfe = CloudflareException.fromString(message);
|
||||
@@ -67,9 +70,16 @@ class NetworkError extends StatelessWidget {
|
||||
child: Text('Verify'.tl),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: retry,
|
||||
child: Text(buttonText ?? 'Retry'.tl),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (action != null)
|
||||
action!.paddingRight(8),
|
||||
FilledButton(
|
||||
onPressed: retry,
|
||||
child: Text(buttonText ?? 'Retry'.tl),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -360,6 +360,7 @@ Future<void> showInputDialog({
|
||||
String cancelText = "Cancel",
|
||||
RegExp? inputValidator,
|
||||
String? image,
|
||||
Uint8List? imageData,
|
||||
}) {
|
||||
var controller = TextEditingController(text: initialValue);
|
||||
bool isLoading = false;
|
||||
@@ -379,6 +380,11 @@ Future<void> showInputDialog({
|
||||
height: 108,
|
||||
child: Image.network(image, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
if (image == null && imageData != null)
|
||||
SizedBox(
|
||||
height: 108,
|
||||
child: Image.memory(imageData, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -7,8 +7,11 @@ class PaneItemEntry {
|
||||
|
||||
IconData activeIcon;
|
||||
|
||||
PaneItemEntry(
|
||||
{required this.label, required this.icon, required this.activeIcon});
|
||||
PaneItemEntry({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
});
|
||||
}
|
||||
|
||||
class PaneActionEntry {
|
||||
@@ -18,20 +21,24 @@ class PaneActionEntry {
|
||||
|
||||
VoidCallback onTap;
|
||||
|
||||
PaneActionEntry(
|
||||
{required this.label, required this.icon, required this.onTap});
|
||||
PaneActionEntry({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
class NaviPane extends StatefulWidget {
|
||||
const NaviPane(
|
||||
{required this.paneItems,
|
||||
required this.paneActions,
|
||||
required this.pageBuilder,
|
||||
this.initialPage = 0,
|
||||
this.onPageChanged,
|
||||
required this.observer,
|
||||
required this.navigatorKey,
|
||||
super.key});
|
||||
const NaviPane({
|
||||
required this.paneItems,
|
||||
required this.paneActions,
|
||||
required this.pageBuilder,
|
||||
this.initialPage = 0,
|
||||
this.onPageChanged,
|
||||
required this.observer,
|
||||
required this.navigatorKey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<PaneItemEntry> paneItems;
|
||||
|
||||
@@ -165,6 +172,16 @@ class NaviPaneState extends State<NaviPane>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
onRebuild(context);
|
||||
final mq = MediaQuery.of(context);
|
||||
final sideInsets =
|
||||
(App.isMobile && mq.orientation == Orientation.landscape)
|
||||
? EdgeInsets.only(
|
||||
left: math.max(
|
||||
mq.viewPadding.left, mq.systemGestureInsets.left),
|
||||
right: math.max(
|
||||
mq.viewPadding.right, mq.systemGestureInsets.right),
|
||||
)
|
||||
: EdgeInsets.zero;
|
||||
return _NaviPopScope(
|
||||
action: () {
|
||||
if (App.mainNavigatorKey!.currentState!.canPop()) {
|
||||
@@ -178,7 +195,7 @@ class NaviPaneState extends State<NaviPane>
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
final value = controller.value;
|
||||
return Stack(
|
||||
Widget content = Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: _kFoldedSideBarWidth * ((value - 2.0).clamp(-1.0, 0.0)),
|
||||
@@ -187,13 +204,21 @@ class NaviPaneState extends State<NaviPane>
|
||||
child: buildLeft(),
|
||||
),
|
||||
Positioned.fill(
|
||||
left: _kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
||||
left:
|
||||
_kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) *
|
||||
((value - 2).clamp(0, 1)),
|
||||
child: buildMainView(),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (sideInsets != EdgeInsets.zero) {
|
||||
content = Padding(
|
||||
padding: sideInsets,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
return content;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -202,14 +227,19 @@ class NaviPaneState extends State<NaviPane>
|
||||
Widget buildMainView() {
|
||||
return HeroControllerScope(
|
||||
controller: MaterialApp.createMaterialHeroController(),
|
||||
child: Navigator(
|
||||
observers: [widget.observer],
|
||||
key: widget.navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
preventRebuild: false,
|
||||
builder: (context) {
|
||||
return _NaviMainView(state: this);
|
||||
},
|
||||
child: NavigatorPopHandler(
|
||||
onPopWithResult: (result) {
|
||||
widget.navigatorKey.currentState?.maybePop(result);
|
||||
},
|
||||
child: Navigator(
|
||||
observers: [widget.observer],
|
||||
key: widget.navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
preventRebuild: false,
|
||||
builder: (context) {
|
||||
return _NaviMainView(state: this);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -239,7 +269,7 @@ class NaviPaneState extends State<NaviPane>
|
||||
icon: Icon(action.icon),
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -261,21 +291,18 @@ class NaviPaneState extends State<NaviPane>
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) {
|
||||
return Expanded(
|
||||
child: _SingleBottomNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
onTap: () {
|
||||
updatePage(index);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
children: List<Widget>.generate(widget.paneItems.length, (index) {
|
||||
return Expanded(
|
||||
child: _SingleBottomNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
onTap: () {
|
||||
updatePage(index);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -286,7 +313,8 @@ class NaviPaneState extends State<NaviPane>
|
||||
const paddingHorizontal = 12.0;
|
||||
return Material(
|
||||
child: Container(
|
||||
width: _kFoldedSideBarWidth +
|
||||
width:
|
||||
_kFoldedSideBarWidth +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)),
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal),
|
||||
@@ -323,9 +351,7 @@ class NaviPaneState extends State<NaviPane>
|
||||
key: ValueKey(index + widget.paneItems.length),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
)
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -334,12 +360,13 @@ class NaviPaneState extends State<NaviPane>
|
||||
}
|
||||
|
||||
class _SideNaviWidget extends StatelessWidget {
|
||||
const _SideNaviWidget(
|
||||
{required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
required this.showTitle,
|
||||
super.key});
|
||||
const _SideNaviWidget({
|
||||
required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
required this.showTitle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@@ -368,18 +395,18 @@ class _SideNaviWidget extends StatelessWidget {
|
||||
? Row(
|
||||
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: icon,
|
||||
),
|
||||
: Align(alignment: Alignment.centerLeft, child: icon),
|
||||
),
|
||||
).paddingVertical(4);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaneActionWidget extends StatelessWidget {
|
||||
const _PaneActionWidget(
|
||||
{required this.entry, required this.showTitle, super.key});
|
||||
const _PaneActionWidget({
|
||||
required this.entry,
|
||||
required this.showTitle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final PaneActionEntry entry;
|
||||
|
||||
@@ -399,21 +426,19 @@ class _PaneActionWidget extends StatelessWidget {
|
||||
? Row(
|
||||
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: icon,
|
||||
),
|
||||
: Align(alignment: Alignment.centerLeft, child: icon),
|
||||
),
|
||||
).paddingVertical(4);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBottomNaviWidget extends StatefulWidget {
|
||||
const _SingleBottomNaviWidget(
|
||||
{required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
super.key});
|
||||
const _SingleBottomNaviWidget({
|
||||
required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@@ -482,8 +507,9 @@ class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
|
||||
Widget buildContent() {
|
||||
final value = controller.value;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon =
|
||||
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
|
||||
final icon = Icon(
|
||||
widget.enabled ? widget.entry.activeIcon : widget.entry.icon,
|
||||
);
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
@@ -570,8 +596,11 @@ class NaviObserver extends NavigatorObserver implements Listenable {
|
||||
}
|
||||
|
||||
class _NaviPopScope extends StatelessWidget {
|
||||
const _NaviPopScope(
|
||||
{required this.child, this.popGesture = false, required this.action});
|
||||
const _NaviPopScope({
|
||||
required this.child,
|
||||
this.popGesture = false,
|
||||
required this.action,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool popGesture;
|
||||
@@ -581,32 +610,25 @@ class _NaviPopScope extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget res = App.isIOS
|
||||
? child
|
||||
: PopScope(
|
||||
canPop: App.isAndroid ? false : true,
|
||||
onPopInvokedWithResult: (value, result) {
|
||||
action();
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
Widget res = child;
|
||||
if (popGesture) {
|
||||
res = GestureDetector(
|
||||
onPanStart: (details) {
|
||||
if (details.globalPosition.dx < 64) {
|
||||
panStartAtEdge = true;
|
||||
onPanStart: (details) {
|
||||
if (details.globalPosition.dx < 64) {
|
||||
panStartAtEdge = true;
|
||||
}
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dx < 0 ||
|
||||
details.velocity.pixelsPerSecond.dx > 0) {
|
||||
if (panStartAtEdge) {
|
||||
action();
|
||||
}
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dx < 0 ||
|
||||
details.velocity.pixelsPerSecond.dx > 0) {
|
||||
if (panStartAtEdge) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
panStartAtEdge = false;
|
||||
},
|
||||
child: res);
|
||||
}
|
||||
panStartAtEdge = false;
|
||||
},
|
||||
child: res,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -237,10 +237,14 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
|
||||
double viewHeight = 0;
|
||||
|
||||
final _scrollIndicatorSize = App.isDesktop ? 42.0 : 64.0;
|
||||
final _scrollIndicatorSize = App.isDesktop ? 36.0 : 54.0;
|
||||
|
||||
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
|
||||
|
||||
bool _isVisible = false;
|
||||
Timer? _hideTimer;
|
||||
static const _hideDuration = Duration(seconds: 2);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -248,7 +252,41 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
_scrollController.addListener(onChanged);
|
||||
Future.microtask(onChanged);
|
||||
_dragGestureRecognizer = VerticalDragGestureRecognizer()
|
||||
..onUpdate = onUpdate;
|
||||
..onUpdate = onUpdate
|
||||
..onStart = (_) {
|
||||
_showScrollbar();
|
||||
}
|
||||
..onEnd = (_) {
|
||||
_scheduleHide();
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
_scrollController.removeListener(onChanged);
|
||||
_dragGestureRecognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showScrollbar() {
|
||||
if (!_isVisible && mounted) {
|
||||
setState(() {
|
||||
_isVisible = true;
|
||||
});
|
||||
}
|
||||
_hideTimer?.cancel();
|
||||
}
|
||||
|
||||
void _scheduleHide() {
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = Timer(_hideDuration, () {
|
||||
if (mounted && _isVisible) {
|
||||
setState(() {
|
||||
_isVisible = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onUpdate(DragUpdateDetails details) {
|
||||
@@ -269,14 +307,24 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
void onChanged() {
|
||||
if (_scrollController.positions.isEmpty) return;
|
||||
var position = _scrollController.position;
|
||||
|
||||
bool hasChanged = false;
|
||||
if (position.minScrollExtent != minExtent ||
|
||||
position.maxScrollExtent != maxExtent ||
|
||||
position.pixels != this.position) {
|
||||
setState(() {
|
||||
minExtent = position.minScrollExtent;
|
||||
maxExtent = position.maxScrollExtent;
|
||||
this.position = position.pixels;
|
||||
});
|
||||
hasChanged = true;
|
||||
minExtent = position.minScrollExtent;
|
||||
maxExtent = position.maxScrollExtent;
|
||||
this.position = position.pixels;
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
_showScrollbar();
|
||||
_scheduleHide();
|
||||
}
|
||||
|
||||
if (hasChanged && mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,29 +348,35 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
Positioned(
|
||||
top: top + widget.topPadding,
|
||||
right: 0,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
_dragGestureRecognizer.addPointer(event);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: _scrollIndicatorSize/2,
|
||||
height: _scrollIndicatorSize,
|
||||
child: CustomPaint(
|
||||
painter: _ScrollIndicatorPainter(
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _isVisible ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => _showScrollbar(),
|
||||
onExit: (_) => _scheduleHide(),
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
_dragGestureRecognizer.addPointer(event);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: _scrollIndicatorSize / 2,
|
||||
height: _scrollIndicatorSize,
|
||||
child: CustomPaint(
|
||||
painter: _ScrollIndicatorPainter(
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Icon(Icons.arrow_drop_up, size: 18),
|
||||
Icon(Icons.arrow_drop_down, size: 18),
|
||||
const Spacer(),
|
||||
],
|
||||
).paddingLeft(4),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Icon(Icons.arrow_drop_up, size: 18),
|
||||
Icon(Icons.arrow_drop_down, size: 18),
|
||||
const Spacer(),
|
||||
],
|
||||
).paddingLeft(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -354,7 +408,7 @@ class _ScrollIndicatorPainter extends CustomPainter {
|
||||
Offset(size.width, 0),
|
||||
radius: Radius.circular(size.width),
|
||||
);
|
||||
canvas.drawShadow(path, shadowColor, 4, true);
|
||||
canvas.drawShadow(path, shadowColor, 2, true);
|
||||
var backgroundPaint = Paint()
|
||||
..color = backgroundColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.4.6";
|
||||
final version = "1.6.0";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
@@ -30,6 +30,10 @@ class _App {
|
||||
|
||||
bool get isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
// Whether the app has been initialized.
|
||||
// If current Isolate is main Isolate, this value is always true.
|
||||
bool isInitialized = false;
|
||||
|
||||
Locale get locale {
|
||||
Locale deviceLocale = PlatformDispatcher.instance.locale;
|
||||
if (deviceLocale.languageCode == "zh" &&
|
||||
@@ -81,6 +85,7 @@ class _App {
|
||||
if (isAndroid) {
|
||||
externalStoragePath = (await getExternalStorageDirectory())!.path;
|
||||
}
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> initComponents() async {
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
|
||||
@@ -115,18 +117,26 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
||||
return SlidePageTransitionBuilder().buildTransitions(
|
||||
PageTransitionsBuilder builder;
|
||||
if (App.isAndroid) {
|
||||
builder = PredictiveBackPageTransitionsBuilder();
|
||||
} else {
|
||||
builder = SlidePageTransitionBuilder();
|
||||
}
|
||||
|
||||
return builder.buildTransitions(
|
||||
this,
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
enableIOSGesture
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
onStartPopGesture: () => _startPopGesture(this),
|
||||
child: child)
|
||||
: child);
|
||||
enableIOSGesture && App.isIOS
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
onStartPopGesture: () => _startPopGesture(this),
|
||||
child: child,
|
||||
)
|
||||
: child);
|
||||
}
|
||||
|
||||
IOSBackGestureController _startPopGesture(PageRoute<T> route) {
|
||||
@@ -193,19 +203,17 @@ class IOSBackGestureController {
|
||||
}
|
||||
|
||||
class IOSBackGestureDetector extends StatefulWidget {
|
||||
const IOSBackGestureDetector(
|
||||
{required this.enabledCallback,
|
||||
required this.child,
|
||||
required this.gestureWidth,
|
||||
required this.onStartPopGesture,
|
||||
super.key});
|
||||
const IOSBackGestureDetector({
|
||||
required this.enabledCallback,
|
||||
required this.child,
|
||||
required this.gestureWidth,
|
||||
required this.onStartPopGesture,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final double gestureWidth;
|
||||
|
||||
final bool Function() enabledCallback;
|
||||
|
||||
final IOSBackGestureController Function() onStartPopGesture;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
@@ -214,8 +222,22 @@ class IOSBackGestureDetector extends StatefulWidget {
|
||||
|
||||
class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
IOSBackGestureController? _backGestureController;
|
||||
late _BackSwipeRecognizer _recognizer;
|
||||
|
||||
late HorizontalDragGestureRecognizer _recognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recognizer = _BackSwipeRecognizer(
|
||||
debugOwner: this,
|
||||
gestureWidth: widget.gestureWidth,
|
||||
isPointerInHorizontal: _isPointerInHorizontalScrollable,
|
||||
onStart: _handleDragStart,
|
||||
onUpdate: _handleDragUpdate,
|
||||
onEnd: _handleDragEnd,
|
||||
onCancel: _handleDragCancel,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -223,81 +245,211 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
|
||||
..onStart = _handleDragStart
|
||||
..onUpdate = _handleDragUpdate
|
||||
..onEnd = _handleDragEnd
|
||||
..onCancel = _handleDragCancel;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr
|
||||
? MediaQuery.of(context).padding.left
|
||||
: MediaQuery.of(context).padding.right;
|
||||
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth);
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
widget.child,
|
||||
Positioned(
|
||||
width: dragAreaWidth,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
return RawGestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
gestures: {
|
||||
_BackSwipeRecognizer: GestureRecognizerFactoryWithHandlers<_BackSwipeRecognizer>(
|
||||
() => _recognizer,
|
||||
(instance) {
|
||||
instance.gestureWidth = widget.gestureWidth;
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (widget.enabledCallback()) _recognizer.addPointer(event);
|
||||
bool _isPointerInHorizontalScrollable(Offset globalPosition) {
|
||||
final HitTestResult result = HitTestResult();
|
||||
final binding = WidgetsBinding.instance;
|
||||
binding.hitTestInView(result, globalPosition, binding.platformDispatcher.implicitView!.viewId);
|
||||
|
||||
for (final entry in result.path) {
|
||||
final target = entry.target;
|
||||
if (target is RenderViewport) {
|
||||
if (target.axisDirection == AxisDirection.left ||
|
||||
target.axisDirection == AxisDirection.right) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (target is RenderSliver) {
|
||||
if (target.constraints.axisDirection == AxisDirection.left ||
|
||||
target.constraints.axisDirection == AxisDirection.right) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (target.runtimeType.toString() == '_RenderSingleChildViewport') {
|
||||
try {
|
||||
final dynamic renderObject = target;
|
||||
if (renderObject.axis == Axis.horizontal) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// protected
|
||||
}
|
||||
}
|
||||
else if (target is RenderEditable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _handleDragCancel() {
|
||||
assert(mounted);
|
||||
_backGestureController?.dragEnd(0.0);
|
||||
_backGestureController = null;
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
if (!widget.enabledCallback()) return;
|
||||
if (mounted && _backGestureController == null) {
|
||||
_backGestureController = widget.onStartPopGesture();
|
||||
}
|
||||
}
|
||||
|
||||
double _convertToLogical(double value) {
|
||||
switch (Directionality.of(context)) {
|
||||
case TextDirection.rtl:
|
||||
return -value;
|
||||
case TextDirection.ltr:
|
||||
return value;
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
if (mounted && _backGestureController != null) {
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragEnd(_convertToLogical(
|
||||
details.velocity.pixelsPerSecond.dx / context.size!.width));
|
||||
_backGestureController = null;
|
||||
if (mounted && _backGestureController != null) {
|
||||
_backGestureController!.dragEnd(_convertToLogical(
|
||||
details.velocity.pixelsPerSecond.dx / context.size!.width));
|
||||
_backGestureController = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController == null);
|
||||
_backGestureController = widget.onStartPopGesture();
|
||||
void _handleDragCancel() {
|
||||
if (mounted && _backGestureController != null) {
|
||||
_backGestureController?.dragEnd(0.0);
|
||||
_backGestureController = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
double _convertToLogical(double value) {
|
||||
switch (Directionality.of(context)) {
|
||||
case TextDirection.rtl: return -value;
|
||||
case TextDirection.ltr: return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BackSwipeRecognizer extends OneSequenceGestureRecognizer {
|
||||
_BackSwipeRecognizer({
|
||||
required this.isPointerInHorizontal,
|
||||
required this.gestureWidth,
|
||||
required this.onStart,
|
||||
required this.onUpdate,
|
||||
required this.onEnd,
|
||||
required this.onCancel,
|
||||
super.debugOwner,
|
||||
});
|
||||
|
||||
final bool Function(Offset globalPosition) isPointerInHorizontal;
|
||||
double gestureWidth;
|
||||
final ValueSetter<DragStartDetails> onStart;
|
||||
final ValueSetter<DragUpdateDetails> onUpdate;
|
||||
final ValueSetter<DragEndDetails> onEnd;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
Offset? _startGlobal;
|
||||
bool _accepted = false;
|
||||
bool _startedInHorizontal = false;
|
||||
bool _startedNearLeftEdge = false;
|
||||
|
||||
VelocityTracker? _velocityTracker;
|
||||
|
||||
static const double _minDistance = 5.0;
|
||||
|
||||
@override
|
||||
void addPointer(PointerDownEvent event) {
|
||||
startTrackingPointer(event.pointer);
|
||||
_startGlobal = event.position;
|
||||
_accepted = false;
|
||||
|
||||
_startedInHorizontal = isPointerInHorizontal(event.position);
|
||||
_startedNearLeftEdge = event.position.dx <= gestureWidth;
|
||||
|
||||
_velocityTracker = VelocityTracker.withKind(event.kind);
|
||||
_velocityTracker?.addPosition(event.timeStamp, event.position);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (event is PointerMoveEvent || event is PointerUpEvent) {
|
||||
_velocityTracker?.addPosition(event.timeStamp, event.position);
|
||||
}
|
||||
|
||||
if (event is PointerMoveEvent) {
|
||||
if (_startGlobal == null) return;
|
||||
final delta = event.position - _startGlobal!;
|
||||
final dx = delta.dx;
|
||||
final dy = delta.dy.abs();
|
||||
|
||||
if (!_accepted) {
|
||||
if (delta.distance < _minDistance) return;
|
||||
|
||||
final isRight = dx > 0;
|
||||
final isHorizontal = dx.abs() > dy * 1.5;
|
||||
final bool eligible = _startedNearLeftEdge || (!_startedInHorizontal);
|
||||
|
||||
if (isRight && isHorizontal && eligible) {
|
||||
_accepted = true;
|
||||
resolve(GestureDisposition.accepted);
|
||||
onStart(DragStartDetails(
|
||||
globalPosition: _startGlobal!,
|
||||
localPosition: event.localPosition
|
||||
));
|
||||
} else {
|
||||
resolve(GestureDisposition.rejected);
|
||||
stopTrackingPointer(event.pointer);
|
||||
_startGlobal = null;
|
||||
_velocityTracker = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (_accepted) {
|
||||
onUpdate(DragUpdateDetails(
|
||||
globalPosition: event.position,
|
||||
localPosition: event.localPosition,
|
||||
primaryDelta: event.delta.dx,
|
||||
delta: event.delta,
|
||||
));
|
||||
}
|
||||
} else if (event is PointerUpEvent) {
|
||||
if (_accepted) {
|
||||
final Velocity velocity = _velocityTracker?.getVelocity() ?? Velocity.zero;
|
||||
|
||||
onEnd(DragEndDetails(
|
||||
velocity: velocity,
|
||||
primaryVelocity: velocity.pixelsPerSecond.dx
|
||||
));
|
||||
}
|
||||
_reset();
|
||||
} else if (event is PointerCancelEvent) {
|
||||
if (_accepted) {
|
||||
onCancel();
|
||||
}
|
||||
_reset();
|
||||
}
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
stopTrackingPointer(0);
|
||||
_accepted = false;
|
||||
_startGlobal = null;
|
||||
_startedInHorizontal = false;
|
||||
_startedNearLeftEdge = false;
|
||||
_velocityTracker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
String get debugDescription => 'IOSBackSwipe';
|
||||
|
||||
@override
|
||||
void didStopTrackingLastPointer(int pointer) {}
|
||||
}
|
||||
|
||||
class SlidePageTransitionBuilder extends PageTransitionsBuilder {
|
||||
@override
|
||||
Widget buildTransitions<T>(
|
||||
@@ -306,30 +458,31 @@ class SlidePageTransitionBuilder extends PageTransitionsBuilder {
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child) {
|
||||
final Animation<double> primaryAnimation = App.isIOS
|
||||
? animation
|
||||
: CurvedAnimation(parent: animation, curve: Curves.ease);
|
||||
final Animation<double> secondaryCurve = App.isIOS
|
||||
? secondaryAnimation
|
||||
: CurvedAnimation(parent: secondaryAnimation, curve: Curves.ease);
|
||||
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(primaryAnimation),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.ease,
|
||||
)),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: const Offset(-0.4, 0),
|
||||
).animate(CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: Curves.ease,
|
||||
)),
|
||||
child: PhysicalModel(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.zero,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
elevation: 6,
|
||||
child: Material(child: child,),
|
||||
),
|
||||
)
|
||||
begin: Offset.zero,
|
||||
end: const Offset(-0.4, 0),
|
||||
).animate(secondaryCurve),
|
||||
child: PhysicalModel(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.zero,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
elevation: 6,
|
||||
child: Material(child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,26 @@ class Appdata with Init {
|
||||
}
|
||||
_isSavingData = true;
|
||||
try {
|
||||
var data = jsonEncode(toJson());
|
||||
var futures = <Future>[];
|
||||
var json = toJson();
|
||||
var data = jsonEncode(json);
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
await file.writeAsString(data);
|
||||
futures.add(file.writeAsString(data));
|
||||
|
||||
var disableSyncFields = json["settings"]["disableSyncFields"] as String;
|
||||
if (disableSyncFields.isNotEmpty){
|
||||
var json4sync = jsonDecode(data);
|
||||
List<String> customDisableSync = splitField(disableSyncFields);
|
||||
for (var field in customDisableSync) {
|
||||
json4sync["settings"].remove(field);
|
||||
}
|
||||
var data4sync = jsonEncode(json4sync);
|
||||
var file4sync = File(FilePath.join(App.dataPath, 'syncdata.json'));
|
||||
futures.add(file4sync.writeAsString(data4sync));
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
|
||||
} finally {
|
||||
_isSavingData = false;
|
||||
}
|
||||
@@ -59,20 +76,33 @@ class Appdata with Init {
|
||||
return {'settings': settings._data, 'searchHistory': searchHistory};
|
||||
}
|
||||
|
||||
List<String> splitField(String merged) {
|
||||
return merged
|
||||
.split(',')
|
||||
.map((field) => field.trim())
|
||||
.where((field) => field.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Following fields are related to device-specific data and should not be synced.
|
||||
static const _disableSync = [
|
||||
"proxy",
|
||||
"authorizationRequired",
|
||||
"customImageProcessing",
|
||||
"webdav",
|
||||
"disableSyncFields",
|
||||
];
|
||||
|
||||
/// Sync data from another device
|
||||
void syncData(Map<String, dynamic> data) {
|
||||
if (data['settings'] is Map) {
|
||||
var settings = data['settings'] as Map<String, dynamic>;
|
||||
|
||||
List<String> customDisableSync = splitField(this.settings["disableSyncFields"] as String);
|
||||
|
||||
for (var key in settings.keys) {
|
||||
if (!_disableSync.contains(key)) {
|
||||
if (!_disableSync.contains(key) &&
|
||||
!customDisableSync.contains(key)) {
|
||||
this.settings[key] = settings[key];
|
||||
}
|
||||
}
|
||||
@@ -166,6 +196,7 @@ class Settings with ChangeNotifier {
|
||||
'checkUpdateOnStart': false,
|
||||
'limitImageWidth': true,
|
||||
'webdav': [], // empty means not configured
|
||||
"disableSyncFields": "", // "field1, field2, ..."
|
||||
'dataVersion': 0,
|
||||
'quickFavorite': null,
|
||||
'enableTurnPageByVolumeKey': true,
|
||||
@@ -192,6 +223,9 @@ class Settings with ChangeNotifier {
|
||||
'comicSpecificSettings': <String, Map<String, dynamic>>{},
|
||||
'ignoreBadCertificate': false,
|
||||
'readerScrollSpeed': 1.0, // 0.5 - 3.0
|
||||
'localFavoritesFirst': true,
|
||||
'autoCloseFavoritePanel': false,
|
||||
'showChapterComments': true, // show chapter comments in reader
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
@@ -205,7 +239,11 @@ class Settings with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void setEnabledComicSpecificSettings(String comicId, String sourceKey, bool enabled) {
|
||||
void setEnabledComicSpecificSettings(
|
||||
String comicId,
|
||||
String sourceKey,
|
||||
bool enabled,
|
||||
) {
|
||||
setReaderSetting(comicId, sourceKey, "enabled", enabled);
|
||||
}
|
||||
|
||||
@@ -213,7 +251,8 @@ class Settings with ChangeNotifier {
|
||||
if (comicId == null || sourceKey == null) {
|
||||
return false;
|
||||
}
|
||||
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] == true;
|
||||
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] ==
|
||||
true;
|
||||
}
|
||||
|
||||
dynamic getReaderSetting(String comicId, String sourceKey, String key) {
|
||||
|
||||
@@ -61,8 +61,10 @@ class ComicSourceManager with ChangeNotifier, Init {
|
||||
await for (var entity in Directory(path).list()) {
|
||||
if (entity is File && entity.path.endsWith(".js")) {
|
||||
try {
|
||||
var source = await ComicSourceParser()
|
||||
.parse(await entity.readAsString(), entity.absolute.path);
|
||||
var source = await ComicSourceParser().parse(
|
||||
await entity.readAsString(),
|
||||
entity.absolute.path,
|
||||
);
|
||||
_sources.add(source);
|
||||
} catch (e, s) {
|
||||
Log.error("ComicSource", "$e\n$s");
|
||||
@@ -154,7 +156,7 @@ class ComicSource {
|
||||
final GetImageLoadingConfigFunc? getImageLoadingConfig;
|
||||
|
||||
final Map<String, dynamic> Function(String imageKey)?
|
||||
getThumbnailLoadingConfig;
|
||||
getThumbnailLoadingConfig;
|
||||
|
||||
var data = <String, dynamic>{};
|
||||
|
||||
@@ -170,6 +172,10 @@ class ComicSource {
|
||||
|
||||
final SendCommentFunc? sendCommentFunc;
|
||||
|
||||
final ChapterCommentsLoader? chapterCommentsLoader;
|
||||
|
||||
final SendChapterCommentFunc? sendChapterCommentFunc;
|
||||
|
||||
final RegExp? idMatcher;
|
||||
|
||||
final LikeOrUnlikeComicFunc? likeOrUnlikeComic;
|
||||
@@ -256,6 +262,8 @@ class ComicSource {
|
||||
this.version,
|
||||
this.commentsLoader,
|
||||
this.sendCommentFunc,
|
||||
this.chapterCommentsLoader,
|
||||
this.sendChapterCommentFunc,
|
||||
this.likeOrUnlikeComic,
|
||||
this.voteCommentFunc,
|
||||
this.likeCommentFunc,
|
||||
@@ -367,11 +375,19 @@ enum ExplorePageType {
|
||||
override,
|
||||
}
|
||||
|
||||
typedef SearchFunction = Future<Res<List<Comic>>> Function(
|
||||
String keyword, int page, List<String> searchOption);
|
||||
typedef SearchFunction =
|
||||
Future<Res<List<Comic>>> Function(
|
||||
String keyword,
|
||||
int page,
|
||||
List<String> searchOption,
|
||||
);
|
||||
|
||||
typedef SearchNextFunction = Future<Res<List<Comic>>> Function(
|
||||
String keyword, String? next, List<String> searchOption);
|
||||
typedef SearchNextFunction =
|
||||
Future<Res<List<Comic>>> Function(
|
||||
String keyword,
|
||||
String? next,
|
||||
List<String> searchOption,
|
||||
);
|
||||
|
||||
class SearchPageData {
|
||||
/// If this is not null, the default value of search options will be first element.
|
||||
@@ -398,12 +414,25 @@ class SearchOptions {
|
||||
String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? "";
|
||||
}
|
||||
|
||||
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
|
||||
String category, String? param, List<String> options, int page);
|
||||
typedef CategoryComicsLoader =
|
||||
Future<Res<List<Comic>>> Function(
|
||||
String category,
|
||||
String? param,
|
||||
List<String> options,
|
||||
int page,
|
||||
);
|
||||
|
||||
typedef CategoryOptionsLoader =
|
||||
Future<Res<List<CategoryComicsOptions>>> Function(
|
||||
String category,
|
||||
String? param,
|
||||
);
|
||||
|
||||
class CategoryComicsData {
|
||||
/// options
|
||||
final List<CategoryComicsOptions> options;
|
||||
final List<CategoryComicsOptions>? options;
|
||||
|
||||
final CategoryOptionsLoader? optionsLoader;
|
||||
|
||||
/// [category] is the one clicked by the user on the category page.
|
||||
///
|
||||
@@ -414,7 +443,12 @@ class CategoryComicsData {
|
||||
|
||||
final RankingData? rankingData;
|
||||
|
||||
const CategoryComicsData(this.options, this.load, {this.rankingData});
|
||||
const CategoryComicsData({
|
||||
this.options,
|
||||
this.optionsLoader,
|
||||
required this.load,
|
||||
this.rankingData,
|
||||
});
|
||||
}
|
||||
|
||||
class RankingData {
|
||||
@@ -423,12 +457,15 @@ class RankingData {
|
||||
final Future<Res<List<Comic>>> Function(String option, int page)? load;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(String option, String? next)?
|
||||
loadWithNext;
|
||||
loadWithNext;
|
||||
|
||||
const RankingData(this.options, this.load, this.loadWithNext);
|
||||
}
|
||||
|
||||
class CategoryComicsOptions {
|
||||
// The label will not be displayed if it is empty.
|
||||
final String label;
|
||||
|
||||
/// Use a [LinkedHashMap] to describe an option list.
|
||||
/// key is for loading comics, value is the name displayed on screen.
|
||||
/// Default value will be the first of the Map.
|
||||
@@ -439,7 +476,12 @@ class CategoryComicsOptions {
|
||||
|
||||
final List<String>? showWhen;
|
||||
|
||||
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
|
||||
const CategoryComicsOptions(
|
||||
this.label,
|
||||
this.options,
|
||||
this.notShowWhen,
|
||||
this.showWhen,
|
||||
);
|
||||
}
|
||||
|
||||
class LinkHandler {
|
||||
|
||||
@@ -541,7 +541,7 @@ class PageJumpTarget {
|
||||
text: attributes?["text"] ?? attributes?["keyword"] ?? "",
|
||||
sourceKey: sourceKey,
|
||||
options: List.from(attributes?["options"] ?? []),
|
||||
),
|
||||
)
|
||||
);
|
||||
} else if (page == "category") {
|
||||
var key = ComicSource.find(sourceKey)!.categoryData!.key;
|
||||
|
||||
@@ -64,8 +64,13 @@ class ComicSourceParser {
|
||||
if (file.existsSync()) {
|
||||
int i = 0;
|
||||
while (file.existsSync()) {
|
||||
file = File(FilePath.join(App.dataPath, "comic_source",
|
||||
"${fileName.split('.').first}($i).js"));
|
||||
file = File(
|
||||
FilePath.join(
|
||||
App.dataPath,
|
||||
"comic_source",
|
||||
"${fileName.split('.').first}($i).js",
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
@@ -80,8 +85,9 @@ class ComicSourceParser {
|
||||
|
||||
Future<ComicSource> parse(String js, String filePath) async {
|
||||
js = js.replaceAll("\r\n", "\n");
|
||||
var line1 =
|
||||
js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class "));
|
||||
var line1 = js
|
||||
.split('\n')
|
||||
.firstWhereOrNull((e) => e.trim().startsWith("class "));
|
||||
if (line1 == null ||
|
||||
!line1.startsWith("class ") ||
|
||||
!line1.contains("extends ComicSource")) {
|
||||
@@ -89,24 +95,27 @@ class ComicSourceParser {
|
||||
}
|
||||
var className = line1.split("class")[1].split("extends ComicSource").first;
|
||||
className = className.trim();
|
||||
JsEngine().runCode("""
|
||||
(() => { $js
|
||||
JsEngine().runCode("""(() => { $js
|
||||
this['temp'] = new $className()
|
||||
}).call()
|
||||
""", className);
|
||||
_name = JsEngine().runCode("this['temp'].name") ??
|
||||
_name =
|
||||
JsEngine().runCode("this['temp'].name") ??
|
||||
(throw ComicSourceParseException('name is required'));
|
||||
var key = JsEngine().runCode("this['temp'].key") ??
|
||||
var key =
|
||||
JsEngine().runCode("this['temp'].key") ??
|
||||
(throw ComicSourceParseException('key is required'));
|
||||
var version = JsEngine().runCode("this['temp'].version") ??
|
||||
var version =
|
||||
JsEngine().runCode("this['temp'].version") ??
|
||||
(throw ComicSourceParseException('version is required'));
|
||||
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
|
||||
var url = JsEngine().runCode("this['temp'].url");
|
||||
if (minAppVersion != null) {
|
||||
if (compareSemVer(minAppVersion, App.version.split('-').first)) {
|
||||
throw ComicSourceParseException(
|
||||
"minAppVersion @version is required"
|
||||
.tlParams({"version": minAppVersion}),
|
||||
"minAppVersion @version is required".tlParams({
|
||||
"version": minAppVersion,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -142,6 +151,8 @@ class ComicSourceParser {
|
||||
version ?? "1.0.0",
|
||||
_parseCommentsLoader(),
|
||||
_parseSendCommentFunc(),
|
||||
_parseChapterCommentsLoader(),
|
||||
_parseSendChapterCommentFunc(),
|
||||
_parseLikeFunc(),
|
||||
_parseVoteCommentFunc(),
|
||||
_parseLikeCommentFunc(),
|
||||
@@ -175,8 +186,10 @@ class ComicSourceParser {
|
||||
}
|
||||
|
||||
bool _checkExists(String index) {
|
||||
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
|
||||
"&& ComicSource.sources.$_key.$index !== undefined");
|
||||
return JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.$index !== null "
|
||||
"&& ComicSource.sources.$_key.$index !== undefined",
|
||||
);
|
||||
}
|
||||
|
||||
dynamic _getValue(String index) {
|
||||
@@ -277,16 +290,24 @@ class ComicSourceParser {
|
||||
if (type == "singlePageWithMultiPart") {
|
||||
loadMultiPart = () async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load()");
|
||||
return Res(List.from(res.keys
|
||||
.map((e) => ExplorePagePart(
|
||||
e,
|
||||
(res[e] as List)
|
||||
.map<Comic>((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
null))
|
||||
.toList()));
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load()",
|
||||
);
|
||||
return Res(
|
||||
List.from(
|
||||
res.keys
|
||||
.map(
|
||||
(e) => ExplorePagePart(
|
||||
e,
|
||||
(res[e] as List)
|
||||
.map<Comic>((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Data Analysis", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -297,11 +318,15 @@ class ComicSourceParser {
|
||||
loadPage = (int page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})",
|
||||
);
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -311,10 +336,13 @@ class ComicSourceParser {
|
||||
loadNext = (next) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})");
|
||||
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})",
|
||||
);
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -326,8 +354,9 @@ class ComicSourceParser {
|
||||
} else if (type == "multiPartPage") {
|
||||
loadMultiPart = () async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load()");
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load()",
|
||||
);
|
||||
return Res(
|
||||
List.from(
|
||||
(res as List).map((e) {
|
||||
@@ -350,19 +379,22 @@ class ComicSourceParser {
|
||||
loadMixed = (index) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})");
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})",
|
||||
);
|
||||
var list = <Object>[];
|
||||
for (var data in (res['data'] as List)) {
|
||||
if (data is List) {
|
||||
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
|
||||
} else if (data is Map) {
|
||||
list.add(ExplorePagePart(
|
||||
data['title'],
|
||||
(data['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
data['viewMore'],
|
||||
));
|
||||
list.add(
|
||||
ExplorePagePart(
|
||||
data['title'],
|
||||
(data['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
data['viewMore'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Res(list, subData: res['maxPage']);
|
||||
@@ -372,21 +404,25 @@ class ComicSourceParser {
|
||||
}
|
||||
};
|
||||
}
|
||||
pages.add(ExplorePageData(
|
||||
title,
|
||||
switch (type) {
|
||||
"singlePageWithMultiPart" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPageComicList" => ExplorePageType.multiPageComicList,
|
||||
"mixed" => ExplorePageType.mixed,
|
||||
_ =>
|
||||
throw ComicSourceParseException("Unknown explore page type $type")
|
||||
},
|
||||
loadPage,
|
||||
loadNext,
|
||||
loadMultiPart,
|
||||
loadMixed,
|
||||
));
|
||||
pages.add(
|
||||
ExplorePageData(
|
||||
title,
|
||||
switch (type) {
|
||||
"singlePageWithMultiPart" =>
|
||||
ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPageComicList" => ExplorePageType.multiPageComicList,
|
||||
"mixed" => ExplorePageType.mixed,
|
||||
_ => throw ComicSourceParseException(
|
||||
"Unknown explore page type $type",
|
||||
),
|
||||
},
|
||||
loadPage,
|
||||
loadNext,
|
||||
loadMultiPart,
|
||||
loadMixed,
|
||||
),
|
||||
);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
@@ -426,18 +462,17 @@ class ComicSourceParser {
|
||||
if (type == "fixed") {
|
||||
categoryParts.add(FixedCategoryPart(name, cs!));
|
||||
} else if (type == "random") {
|
||||
categoryParts
|
||||
.add(RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1));
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1),
|
||||
);
|
||||
} else if (type == "dynamic" && categories == null) {
|
||||
var loader = c["loader"];
|
||||
if (loader is! JSInvokable) {
|
||||
throw "DynamicCategoryPart loader must be a function";
|
||||
}
|
||||
categoryParts.add(DynamicCategoryPart(
|
||||
name,
|
||||
JSAutoFreeFunction(loader),
|
||||
_key!,
|
||||
));
|
||||
categoryParts.add(
|
||||
DynamicCategoryPart(name, JSAutoFreeFunction(loader), _key!),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// old format
|
||||
@@ -454,30 +489,16 @@ class ComicSourceParser {
|
||||
for (int i = 0; i < tags.length; i++) {
|
||||
PageJumpTarget target;
|
||||
if (itemType == 'category') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'category',
|
||||
{
|
||||
"category": tags[i],
|
||||
"param": categoryParams?.elementAtOrNull(i),
|
||||
},
|
||||
);
|
||||
target = PageJumpTarget(_key!, 'category', {
|
||||
"category": tags[i],
|
||||
"param": categoryParams?.elementAtOrNull(i),
|
||||
});
|
||||
} else if (itemType == 'search') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'search',
|
||||
{
|
||||
"keyword": tags[i],
|
||||
},
|
||||
);
|
||||
target = PageJumpTarget(_key!, 'search', {"keyword": tags[i]});
|
||||
} else if (itemType == 'search_with_namespace') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'search',
|
||||
{
|
||||
"keyword": "$name:$tags[i]",
|
||||
},
|
||||
);
|
||||
target = PageJumpTarget(_key!, 'search', {
|
||||
"keyword": "$name:$tags[i]",
|
||||
});
|
||||
} else {
|
||||
target = PageJumpTarget(_key!, itemType, null);
|
||||
}
|
||||
@@ -486,38 +507,101 @@ class ComicSourceParser {
|
||||
if (type == "fixed") {
|
||||
categoryParts.add(FixedCategoryPart(name, cs));
|
||||
} else if (type == "random") {
|
||||
categoryParts
|
||||
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1));
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, cs, c["randomNumber"] ?? 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CategoryData(
|
||||
title: title,
|
||||
categories: categoryParts,
|
||||
enableRankingPage: enableRankingPage ?? false,
|
||||
key: title);
|
||||
title: title,
|
||||
categories: categoryParts,
|
||||
enableRankingPage: enableRankingPage ?? false,
|
||||
key: title,
|
||||
);
|
||||
}
|
||||
|
||||
CategoryComicsData? _loadCategoryComicsData() {
|
||||
if (!_checkExists("categoryComics")) return null;
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in _getValue("categoryComics.optionList") ?? []) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
|
||||
List<CategoryComicsOptions>? options;
|
||||
if (_checkExists("categoryComics.optionList")) {
|
||||
options = <CategoryComicsOptions>[];
|
||||
for (var element in _getValue("categoryComics.optionList") ?? []) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
options.add(
|
||||
CategoryComicsOptions(
|
||||
element["label"] ?? "",
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
options.add(CategoryComicsOptions(
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"])));
|
||||
}
|
||||
|
||||
CategoryOptionsLoader? optionLoader;
|
||||
if (_checkExists("categoryComics.optionLoader")) {
|
||||
optionLoader = (category, param) async {
|
||||
try {
|
||||
dynamic res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.optionLoader(
|
||||
${jsonEncode(category)}, ${jsonEncode(param)})
|
||||
""");
|
||||
if (res is Future) {
|
||||
res = await res;
|
||||
}
|
||||
if (res is! List) {
|
||||
return Res.error(
|
||||
"Invalid data:\nExpected: List\nGot: ${res.runtimeType}",
|
||||
);
|
||||
}
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in res) {
|
||||
if (element is! Map) {
|
||||
return Res.error(
|
||||
"Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}",
|
||||
);
|
||||
}
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"] ?? []) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(
|
||||
CategoryComicsOptions(
|
||||
element["label"] ?? "",
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null
|
||||
? null
|
||||
: List.from(element["showWhen"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Res(options);
|
||||
} catch (e) {
|
||||
Log.error("Data Analysis", "Failed to load category options.\n$e");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
RankingData? rankingData;
|
||||
if (_checkExists("categoryComics.ranking")) {
|
||||
var options = <String, String>{};
|
||||
@@ -532,7 +616,7 @@ class ComicSourceParser {
|
||||
}
|
||||
Future<Res<List<Comic>>> Function(String option, int page)? load;
|
||||
Future<Res<List<Comic>>> Function(String option, String? next)?
|
||||
loadWithNext;
|
||||
loadWithNext;
|
||||
if (_checkExists("categoryComics.ranking.load")) {
|
||||
load = (option, page) async {
|
||||
try {
|
||||
@@ -541,9 +625,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(option)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -557,8 +644,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(option)}, ${jsonEncode(next)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -569,25 +658,38 @@ class ComicSourceParser {
|
||||
}
|
||||
rankingData = RankingData(options, load, loadWithNext);
|
||||
}
|
||||
return CategoryComicsData(options, (category, param, options, page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}, rankingData: rankingData);
|
||||
|
||||
if (options == null && optionLoader == null) {
|
||||
options = [];
|
||||
}
|
||||
|
||||
return CategoryComicsData(
|
||||
options: options,
|
||||
optionsLoader: optionLoader,
|
||||
load: (category, param, options, page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
return Res(
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
},
|
||||
rankingData: rankingData,
|
||||
);
|
||||
}
|
||||
|
||||
SearchPageData? _loadSearchData() {
|
||||
@@ -604,12 +706,14 @@ class ComicSourceParser {
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(SearchOptions(
|
||||
map,
|
||||
element["label"],
|
||||
element['type'] ?? 'select',
|
||||
element['default'] == null ? null : jsonEncode(element['default']),
|
||||
));
|
||||
options.add(
|
||||
SearchOptions(
|
||||
map,
|
||||
element["label"],
|
||||
element['type'] ?? 'select',
|
||||
element['default'] == null ? null : jsonEncode(element['default']),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
SearchFunction? loadPage;
|
||||
@@ -624,9 +728,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -640,8 +747,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -690,8 +799,9 @@ class ComicSourceParser {
|
||||
|
||||
final bool multiFolder = _getValue("favorites.multiFolder");
|
||||
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
|
||||
final bool? singleFolderForSingleComic =
|
||||
_getValue("favorites.singleFolderForSingleComic");
|
||||
final bool? singleFolderForSingleComic = _getValue(
|
||||
"favorites.singleFolderForSingleComic",
|
||||
);
|
||||
|
||||
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
|
||||
if (!ComicSource.find(_key!)!.isLogged) {
|
||||
@@ -744,9 +854,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(page)}, ${jsonEncode(folder)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -766,8 +879,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(next)}, ${jsonEncode(folder)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -858,8 +973,9 @@ class ComicSourceParser {
|
||||
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return Res(
|
||||
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
subData: res["maxPage"]);
|
||||
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -896,6 +1012,54 @@ class ComicSourceParser {
|
||||
};
|
||||
}
|
||||
|
||||
ChapterCommentsLoader? _parseChapterCommentsLoader() {
|
||||
if (!_checkExists("comic.loadChapterComments")) return null;
|
||||
return (comicId, epId, page, replyTo) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.loadChapterComments(
|
||||
${jsonEncode(comicId)}, ${jsonEncode(epId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return Res(
|
||||
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
SendChapterCommentFunc? _parseSendChapterCommentFunc() {
|
||||
if (!_checkExists("comic.sendChapterComment")) return null;
|
||||
return (comicId, epId, content, replyTo) async {
|
||||
Future<Res<bool>> func() async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.sendChapterComment(
|
||||
${jsonEncode(comicId)}, ${jsonEncode(epId)}, ${jsonEncode(content)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
var res = await func();
|
||||
if (res.error && res.errorMessage!.contains("Login expired")) {
|
||||
var reLoginRes = await ComicSource.find(_key!)!.reLogin();
|
||||
if (!reLoginRes) {
|
||||
return const Res.error("Login expired and re-login failed");
|
||||
} else {
|
||||
return func();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc() {
|
||||
if (!_checkExists("comic.onImageLoad")) {
|
||||
return null;
|
||||
@@ -1114,7 +1278,8 @@ class ComicSourceParser {
|
||||
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
|
||||
""");
|
||||
return Res(
|
||||
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList());
|
||||
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList(),
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
|
||||
@@ -4,50 +4,90 @@ part of 'comic_source.dart';
|
||||
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
|
||||
|
||||
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
|
||||
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
|
||||
String? next);
|
||||
typedef ComicListBuilderWithNext =
|
||||
Future<Res<List<Comic>>> Function(String? next);
|
||||
|
||||
typedef LoginFunction = Future<Res<bool>> Function(String, String);
|
||||
|
||||
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
|
||||
|
||||
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
|
||||
String id, String? ep);
|
||||
typedef LoadComicPagesFunc =
|
||||
Future<Res<List<String>>> Function(String id, String? ep);
|
||||
|
||||
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
|
||||
String id, String? subId, int page, String? replyTo);
|
||||
typedef CommentsLoader =
|
||||
Future<Res<List<Comment>>> Function(
|
||||
String id,
|
||||
String? subId,
|
||||
int page,
|
||||
String? replyTo,
|
||||
);
|
||||
|
||||
typedef SendCommentFunc = Future<Res<bool>> Function(
|
||||
String id, String? subId, String content, String? replyTo);
|
||||
typedef ChapterCommentsLoader =
|
||||
Future<Res<List<Comment>>> Function(
|
||||
String comicId,
|
||||
String epId,
|
||||
int page,
|
||||
String? replyTo,
|
||||
);
|
||||
|
||||
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
|
||||
String imageKey, String comicId, String epId)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
typedef SendCommentFunc =
|
||||
Future<Res<bool>> Function(
|
||||
String id,
|
||||
String? subId,
|
||||
String content,
|
||||
String? replyTo,
|
||||
);
|
||||
|
||||
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
|
||||
String comicId, String? next);
|
||||
typedef SendChapterCommentFunc =
|
||||
Future<Res<bool>> Function(
|
||||
String comicId,
|
||||
String epId,
|
||||
String content,
|
||||
String? replyTo,
|
||||
);
|
||||
|
||||
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
|
||||
String comicId, bool isLiking);
|
||||
typedef GetImageLoadingConfigFunc =
|
||||
Future<Map<String, dynamic>> Function(
|
||||
String imageKey,
|
||||
String comicId,
|
||||
String epId,
|
||||
)?;
|
||||
typedef GetThumbnailLoadingConfigFunc =
|
||||
Map<String, dynamic> Function(String imageKey)?;
|
||||
|
||||
typedef ComicThumbnailLoader =
|
||||
Future<Res<List<String>>> Function(String comicId, String? next);
|
||||
|
||||
typedef LikeOrUnlikeComicFunc =
|
||||
Future<Res<bool>> Function(String comicId, bool isLiking);
|
||||
|
||||
/// [isLiking] is true if the user is liking the comment, false if unliking.
|
||||
/// return the new likes count or null.
|
||||
typedef LikeCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isLiking);
|
||||
typedef LikeCommentFunc =
|
||||
Future<Res<int?>> Function(
|
||||
String comicId,
|
||||
String? subId,
|
||||
String commentId,
|
||||
bool isLiking,
|
||||
);
|
||||
|
||||
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
|
||||
/// return the new vote count or null.
|
||||
typedef VoteCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
|
||||
typedef VoteCommentFunc =
|
||||
Future<Res<int?>> Function(
|
||||
String comicId,
|
||||
String? subId,
|
||||
String commentId,
|
||||
bool isUp,
|
||||
bool isCancel,
|
||||
);
|
||||
|
||||
typedef HandleClickTagEvent = PageJumpTarget? Function(
|
||||
String namespace, String tag);
|
||||
typedef HandleClickTagEvent =
|
||||
PageJumpTarget? Function(String namespace, String tag);
|
||||
|
||||
/// Handle tag suggestion selection event. Should return the text to insert
|
||||
/// into the search field.
|
||||
typedef TagSuggestionSelectFunc = String Function(
|
||||
String namespace, String tag);
|
||||
typedef TagSuggestionSelectFunc = String Function(String namespace, String tag);
|
||||
|
||||
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
|
||||
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
||||
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
||||
|
||||
@@ -14,14 +14,14 @@ extension Navigation on BuildContext {
|
||||
return Navigator.of(this).canPop();
|
||||
}
|
||||
|
||||
Future<T?> to<T>(Widget Function() builder) {
|
||||
return Navigator.of(this)
|
||||
.push<T>(AppPageRoute(builder: (context) => builder()));
|
||||
Future<T?> to<T>(Widget Function() builder,) {
|
||||
return Navigator.of(this).push<T>(AppPageRoute(
|
||||
builder: (context) => builder()));
|
||||
}
|
||||
|
||||
Future<void> toReplacement<T>(Widget Function() builder) {
|
||||
return Navigator.of(this)
|
||||
.pushReplacement(AppPageRoute(builder: (context) => builder()));
|
||||
return Navigator.of(this).pushReplacement(AppPageRoute(
|
||||
builder: (context) => builder()));
|
||||
}
|
||||
|
||||
double get width => MediaQuery.of(this).size.width;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
@@ -213,12 +214,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
|
||||
late Map<String, int> counts;
|
||||
|
||||
var _hashedIds = <int, int>{};
|
||||
|
||||
int get totalComics {
|
||||
int total = 0;
|
||||
for (var t in counts.values) {
|
||||
total += t;
|
||||
}
|
||||
return total;
|
||||
return _hashedIds.length;
|
||||
}
|
||||
|
||||
int folderComics(String folder) {
|
||||
@@ -280,6 +279,48 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
for (var folder in folderNames) {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
_initHashedIds(folderNames, _db.handle).then((value) {
|
||||
_hashedIds = value;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void refreshHashedIds() {
|
||||
_initHashedIds(folderNames, _db.handle).then((value) {
|
||||
_hashedIds = value;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void reduceHashedId(String id, int type) {
|
||||
var hash = id.hashCode ^ type;
|
||||
if (_hashedIds.containsKey(hash)) {
|
||||
if (_hashedIds[hash]! > 1) {
|
||||
_hashedIds[hash] = _hashedIds[hash]! - 1;
|
||||
} else {
|
||||
_hashedIds.remove(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<int, int>> _initHashedIds(
|
||||
List<String> folders, Pointer<void> p) {
|
||||
return Isolate.run(() {
|
||||
var db = sqlite3.fromPointer(p);
|
||||
var hashedIds = <int, int>{};
|
||||
for (var folder in folders) {
|
||||
var rows = db.select("""
|
||||
select id, type from "$folder";
|
||||
""");
|
||||
for (var row in rows) {
|
||||
var id = row["id"] as String;
|
||||
var type = row["type"] as int;
|
||||
var hash = id.hashCode ^ type;
|
||||
hashedIds[hash] = (hashedIds[hash] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return hashedIds;
|
||||
});
|
||||
}
|
||||
|
||||
List<String> find(String id, ComicType type) {
|
||||
@@ -559,7 +600,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
/// return true if success, false if already exists
|
||||
bool addComic(String folder, FavoriteItem comic,
|
||||
[int? order, String? updateTime]) {
|
||||
_modifiedAfterLastCache = true;
|
||||
if (!existsFolder(folder)) {
|
||||
throw Exception("Folder does not exists");
|
||||
}
|
||||
@@ -614,14 +654,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
} else {
|
||||
counts[folder] = counts[folder]! + 1;
|
||||
}
|
||||
var hash = comic.id.hashCode ^ comic.type.value;
|
||||
_hashedIds[hash] = (_hashedIds[hash] ?? 0) + 1;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void moveFavorite(
|
||||
String sourceFolder, String targetFolder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -655,8 +695,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
|
||||
void batchMoveFavorites(
|
||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -691,25 +729,15 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
_db.execute("COMMIT");
|
||||
|
||||
// Update counts
|
||||
if (counts[targetFolder] == null) {
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
} else {
|
||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||
}
|
||||
|
||||
if (counts[sourceFolder] != null) {
|
||||
counts[sourceFolder] = counts[sourceFolder]! - items.length;
|
||||
} else {
|
||||
counts[sourceFolder] = count(sourceFolder);
|
||||
}
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
counts[sourceFolder] = count(sourceFolder);
|
||||
refreshHashedIds();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchCopyFavorites(
|
||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -740,18 +768,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
_db.execute("COMMIT");
|
||||
|
||||
// Update counts
|
||||
if (counts[targetFolder] == null) {
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
} else {
|
||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||
}
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
refreshHashedIds();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// delete a folder
|
||||
void deleteFolder(String name) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("""
|
||||
drop table "$name";
|
||||
""");
|
||||
@@ -760,11 +784,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
where folder_name == ?;
|
||||
""", [name]);
|
||||
counts.remove(name);
|
||||
refreshHashedIds();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteComicWithId(String folder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
LocalFavoriteImageProvider.delete(id, type.value);
|
||||
_db.execute("""
|
||||
delete from "$folder"
|
||||
@@ -775,11 +799,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
} else {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
reduceHashedId(id, type.value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
try {
|
||||
for (var comic in comics) {
|
||||
@@ -800,11 +824,13 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
_db.execute("COMMIT");
|
||||
for (var comic in comics) {
|
||||
reduceHashedId(comic.id, comic.type.value);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
var folderNames = _getFolderNamesWithDB();
|
||||
try {
|
||||
@@ -824,6 +850,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
}
|
||||
initCounts();
|
||||
_db.execute("COMMIT");
|
||||
for (var comic in comics) {
|
||||
var hash = comic.id.hashCode ^ comic.type.value;
|
||||
_hashedIds.remove(hash);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -908,7 +938,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
markAsRead(id, type);
|
||||
return;
|
||||
}
|
||||
_modifiedAfterLastCache = true;
|
||||
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
|
||||
for (final folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
@@ -1029,28 +1058,9 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final _cachedFavoritedIds = <String, bool>{};
|
||||
|
||||
bool isExist(String id, ComicType type) {
|
||||
if (_modifiedAfterLastCache) {
|
||||
_cacheFavoritedIds();
|
||||
}
|
||||
return _cachedFavoritedIds.containsKey("$id@${type.value}");
|
||||
}
|
||||
|
||||
bool _modifiedAfterLastCache = true;
|
||||
|
||||
void _cacheFavoritedIds() {
|
||||
_modifiedAfterLastCache = false;
|
||||
_cachedFavoritedIds.clear();
|
||||
for (var folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select id, type from "$folder";
|
||||
""");
|
||||
for (var row in rows) {
|
||||
_cachedFavoritedIds["${row["id"]}@${row["type"]}"] = true;
|
||||
}
|
||||
}
|
||||
var hash = id.hashCode ^ type.value;
|
||||
return _hashedIds.containsKey(hash);
|
||||
}
|
||||
|
||||
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/channel.dart';
|
||||
|
||||
class ComicUpdateResult {
|
||||
final bool updated;
|
||||
@@ -62,6 +63,7 @@ Future<ComicUpdateResult> updateComic(
|
||||
return ComicUpdateResult(updated, null);
|
||||
} catch (e, s) {
|
||||
Log.error("Check Updates", e, s);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
retries--;
|
||||
if (retries == 0) {
|
||||
return ComicUpdateResult(false, e.toString());
|
||||
@@ -114,23 +116,50 @@ void updateFolderBase(
|
||||
current = 0;
|
||||
stream.add(UpdateProgress(total, current, errors, updated));
|
||||
|
||||
var futures = <Future>[];
|
||||
for (var comic in comicsToUpdate) {
|
||||
var future = updateComic(comic, folder).then((result) {
|
||||
current++;
|
||||
if (result.updated) {
|
||||
updated++;
|
||||
var channel = Channel<FavoriteItemWithUpdateInfo>(10);
|
||||
|
||||
// Producer
|
||||
() async {
|
||||
var c = 0;
|
||||
for (var comic in comicsToUpdate) {
|
||||
await channel.push(comic);
|
||||
c++;
|
||||
// Throttle
|
||||
if (c % 5 == 0) {
|
||||
var delay = c % 100 + 1;
|
||||
if (delay > 10) {
|
||||
delay = 10;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: delay));
|
||||
}
|
||||
if (result.errorMessage != null) {
|
||||
errors++;
|
||||
}
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// Consumers
|
||||
var updateFutures = <Future>[];
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var f = () async {
|
||||
while (true) {
|
||||
var comic = await channel.pop();
|
||||
if (comic == null) {
|
||||
break;
|
||||
}
|
||||
var result = await updateComic(comic, folder);
|
||||
current++;
|
||||
if (result.updated) {
|
||||
updated++;
|
||||
}
|
||||
if (result.errorMessage != null) {
|
||||
errors++;
|
||||
}
|
||||
stream.add(UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
|
||||
}
|
||||
stream.add(
|
||||
UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
|
||||
});
|
||||
futures.add(future);
|
||||
}();
|
||||
updateFutures.add(f);
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
await Future.wait(updateFutures);
|
||||
|
||||
if (updated > 0) {
|
||||
LocalFavoritesManager().notifyChanges();
|
||||
|
||||
@@ -441,7 +441,7 @@ class ImageFavoriteManager with ChangeNotifier {
|
||||
for (var comic in comics) {
|
||||
count += comic.images.length;
|
||||
for (var tag in comic.tags) {
|
||||
String finalTag = tag;
|
||||
String finalTag = tag.split(":").last;
|
||||
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async' show Future;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.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 'base_image_provider.dart';
|
||||
@@ -11,7 +13,12 @@ class CachedImageProvider
|
||||
/// Image provider for normal image.
|
||||
///
|
||||
/// [url] is the url of the image. Local file path is also supported.
|
||||
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
|
||||
const CachedImageProvider(this.url, {
|
||||
this.headers,
|
||||
this.sourceKey,
|
||||
this.cid,
|
||||
this.fallbackToLocalCover = false,
|
||||
});
|
||||
|
||||
final String url;
|
||||
|
||||
@@ -21,6 +28,9 @@ class CachedImageProvider
|
||||
|
||||
final String? cid;
|
||||
|
||||
// Use local cover if network image fails to load.
|
||||
final bool fallbackToLocalCover;
|
||||
|
||||
static int loadingCount = 0;
|
||||
|
||||
static const _kMaxLoadingCount = 8;
|
||||
@@ -49,6 +59,24 @@ class CachedImageProvider
|
||||
}
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
catch(e) {
|
||||
if (fallbackToLocalCover && sourceKey != null && cid != null) {
|
||||
final localComic = LocalManager().find(
|
||||
cid!,
|
||||
ComicType.fromKey(sourceKey!),
|
||||
);
|
||||
if (localComic != null) {
|
||||
var file = localComic.coverFile;
|
||||
if (await file.exists()) {
|
||||
var data = await file.readAsBytes();
|
||||
if (data.isNotEmpty) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
finally {
|
||||
loadingCount--;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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/foundation/js_pool.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/network/proxy.dart';
|
||||
@@ -68,6 +69,12 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
}
|
||||
|
||||
static Uint8List? _jsInitCache;
|
||||
|
||||
static void cacheJsInit(Uint8List jsInit) {
|
||||
_jsInitCache = jsInit;
|
||||
}
|
||||
|
||||
@override
|
||||
@protected
|
||||
Future<void> doInit() async {
|
||||
@@ -75,9 +82,11 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (App.isInitialized) {
|
||||
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
|
||||
}
|
||||
_dio ??= AppDio(BaseOptions(
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
_cookieJar ??= SingleInstanceCookieJar.instance!;
|
||||
_closed = false;
|
||||
_engine = FlutterQjs();
|
||||
_engine!.dispatch();
|
||||
@@ -86,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
|
||||
setGlobalFunc(["appVersion", App.version]);
|
||||
setGlobalFunc.free();
|
||||
var jsInit = await rootBundle.load("assets/init.js");
|
||||
Uint8List jsInit;
|
||||
if (_jsInitCache != null) {
|
||||
jsInit = _jsInitCache!;
|
||||
} else {
|
||||
var buffer = await rootBundle.load("assets/init.js");
|
||||
jsInit = buffer.buffer.asUint8List();
|
||||
}
|
||||
_engine!
|
||||
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>");
|
||||
.evaluate(utf8.decode(jsInit), name: "<init>");
|
||||
} catch (e, s) {
|
||||
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
|
||||
}
|
||||
@@ -97,6 +112,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
Object? _messageReceiver(dynamic message) {
|
||||
try {
|
||||
if (message is Map<dynamic, dynamic>) {
|
||||
if (message["method"] == null) return null;
|
||||
String method = message["method"] as String;
|
||||
switch (method) {
|
||||
case "log":
|
||||
@@ -172,6 +188,20 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
var res = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return res?.text;
|
||||
});
|
||||
case "compute":
|
||||
final func = message["function"];
|
||||
final args = message["args"];
|
||||
if (func is JSInvokable) {
|
||||
func.free();
|
||||
throw "Function must be a string";
|
||||
}
|
||||
if (func is! String) {
|
||||
throw "Function must be a string";
|
||||
}
|
||||
if (args != null && args is! List) {
|
||||
throw "Args must be a list";
|
||||
}
|
||||
return JSPool().execute(func, args ?? []);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -187,6 +217,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
|
||||
try {
|
||||
var headers = Map<String, dynamic>.from(req["headers"] ?? {});
|
||||
var extra = Map<String, dynamic>.from(req["extra"] ?? {});
|
||||
if (headers["user-agent"] == null && headers["User-Agent"] == null) {
|
||||
headers["User-Agent"] = webUA;
|
||||
}
|
||||
@@ -214,7 +245,10 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
responseType: req["bytes"] == true
|
||||
? ResponseType.bytes
|
||||
: ResponseType.plain,
|
||||
headers: headers));
|
||||
headers: headers,
|
||||
extra: extra,
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
}
|
||||
@@ -406,83 +440,72 @@ mixin class _JSEngineApi {
|
||||
return Uint8List.fromList(hmac.convert(value).bytes);
|
||||
}
|
||||
case "aes-ecb":
|
||||
if (!isEncode) {
|
||||
var key = data["key"];
|
||||
var cipher = ECBBlockCipher(AESEngine());
|
||||
cipher.init(
|
||||
false,
|
||||
KeyParameter(key),
|
||||
var key = data["key"];
|
||||
var cipher = ECBBlockCipher(AESEngine());
|
||||
cipher.init(
|
||||
isEncode,
|
||||
KeyParameter(key),
|
||||
);
|
||||
var offset = 0;
|
||||
var result = Uint8List(value.length);
|
||||
while (offset < value.length) {
|
||||
offset += cipher.processBlock(
|
||||
value,
|
||||
offset,
|
||||
result,
|
||||
offset,
|
||||
);
|
||||
var offset = 0;
|
||||
var result = Uint8List(value.length);
|
||||
while (offset < value.length) {
|
||||
offset += cipher.processBlock(
|
||||
value,
|
||||
offset,
|
||||
result,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
return result;
|
||||
case "aes-cbc":
|
||||
if (!isEncode) {
|
||||
var key = data["key"];
|
||||
var iv = data["iv"];
|
||||
var cipher = CBCBlockCipher(AESEngine());
|
||||
cipher.init(false, ParametersWithIV(KeyParameter(key), iv));
|
||||
var offset = 0;
|
||||
var result = Uint8List(value.length);
|
||||
while (offset < value.length) {
|
||||
offset += cipher.processBlock(
|
||||
value,
|
||||
offset,
|
||||
result,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
var key = data["key"];
|
||||
var iv = data["iv"];
|
||||
var cipher = CBCBlockCipher(AESEngine());
|
||||
cipher.init(isEncode, ParametersWithIV(KeyParameter(key), iv));
|
||||
var offset = 0;
|
||||
var result = Uint8List(value.length);
|
||||
while (offset < value.length) {
|
||||
offset += cipher.processBlock(
|
||||
value,
|
||||
offset,
|
||||
result,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return result;
|
||||
case "aes-cfb":
|
||||
if (!isEncode) {
|
||||
var key = data["key"];
|
||||
var blockSize = data["blockSize"];
|
||||
var cipher = CFBBlockCipher(AESEngine(), blockSize);
|
||||
cipher.init(false, KeyParameter(key));
|
||||
var offset = 0;
|
||||
var result = Uint8List(value.length);
|
||||
while (offset < value.length) {
|
||||
offset += cipher.processBlock(
|
||||
value,
|
||||
offset,
|
||||
result,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
var key = data["key"];
|
||||
var iv = data["iv"];
|
||||
var blockSize = data["blockSize"];
|
||||
var cipher = CFBBlockCipher(AESEngine(), blockSize);
|
||||
cipher.init(isEncode, ParametersWithIV(KeyParameter(key), iv));
|
||||
var offset = 0;
|
||||
var result = Uint8List(value.length);
|
||||
while (offset < value.length) {
|
||||
offset += cipher.processBlock(
|
||||
value,
|
||||
offset,
|
||||
result,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return result;
|
||||
case "aes-ofb":
|
||||
if (!isEncode) {
|
||||
var key = data["key"];
|
||||
var blockSize = data["blockSize"];
|
||||
var cipher = OFBBlockCipher(AESEngine(), blockSize);
|
||||
cipher.init(false, KeyParameter(key));
|
||||
var offset = 0;
|
||||
var result = Uint8List(value.length);
|
||||
while (offset < value.length) {
|
||||
offset += cipher.processBlock(
|
||||
value,
|
||||
offset,
|
||||
result,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
var key = data["key"];
|
||||
var blockSize = data["blockSize"];
|
||||
var cipher = OFBBlockCipher(AESEngine(), blockSize);
|
||||
cipher.init(isEncode, KeyParameter(key));
|
||||
var offset = 0;
|
||||
var result = Uint8List(value.length);
|
||||
while (offset < value.length) {
|
||||
offset += cipher.processBlock(
|
||||
value,
|
||||
offset,
|
||||
result,
|
||||
offset,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return result;
|
||||
case "rsa":
|
||||
if (!isEncode) {
|
||||
var key = data["key"];
|
||||
|
||||
163
lib/foundation/js_pool.dart
Normal file
163
lib/foundation/js_pool.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
|
||||
class JSPool {
|
||||
static final int _maxInstances = 4;
|
||||
final List<IsolateJsEngine> _instances = [];
|
||||
bool _isInitializing = false;
|
||||
|
||||
static final JSPool _singleton = JSPool._internal();
|
||||
factory JSPool() {
|
||||
return _singleton;
|
||||
}
|
||||
JSPool._internal();
|
||||
|
||||
Future<void> init() async {
|
||||
if (_isInitializing) return;
|
||||
_isInitializing = true;
|
||||
var jsInitBuffer = await rootBundle.load("assets/init.js");
|
||||
var jsInit = jsInitBuffer.buffer.asUint8List();
|
||||
for (int i = 0; i < _maxInstances; i++) {
|
||||
_instances.add(IsolateJsEngine(jsInit));
|
||||
}
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
|
||||
await init();
|
||||
var selectedInstance = _instances[0];
|
||||
for (var instance in _instances) {
|
||||
if (instance.pendingTasks < selectedInstance.pendingTasks) {
|
||||
selectedInstance = instance;
|
||||
}
|
||||
}
|
||||
return selectedInstance.execute(jsFunction, args);
|
||||
}
|
||||
}
|
||||
|
||||
class _IsolateJsEngineInitParam {
|
||||
final SendPort sendPort;
|
||||
|
||||
final Uint8List jsInit;
|
||||
|
||||
_IsolateJsEngineInitParam(this.sendPort, this.jsInit);
|
||||
}
|
||||
|
||||
class IsolateJsEngine {
|
||||
Isolate? _isolate;
|
||||
|
||||
SendPort? _sendPort;
|
||||
ReceivePort? _receivePort;
|
||||
|
||||
int _counter = 0;
|
||||
final Map<int, Completer<dynamic>> _tasks = {};
|
||||
|
||||
bool _isClosed = false;
|
||||
|
||||
int get pendingTasks => _tasks.length;
|
||||
|
||||
IsolateJsEngine(Uint8List jsInit) {
|
||||
_receivePort = ReceivePort();
|
||||
_receivePort!.listen(_onMessage);
|
||||
Isolate.spawn(_run, _IsolateJsEngineInitParam(_receivePort!.sendPort, jsInit));
|
||||
}
|
||||
|
||||
void _onMessage(dynamic message) {
|
||||
if (message is SendPort) {
|
||||
_sendPort = message;
|
||||
} else if (message is TaskResult) {
|
||||
final completer = _tasks.remove(message.id);
|
||||
if (completer != null) {
|
||||
if (message.error != null) {
|
||||
completer.completeError(message.error!);
|
||||
} else {
|
||||
completer.complete(message.result);
|
||||
}
|
||||
}
|
||||
} else if (message is Exception) {
|
||||
Log.error("IsolateJsEngine", message.toString());
|
||||
for (var completer in _tasks.values) {
|
||||
completer.completeError(message);
|
||||
}
|
||||
_tasks.clear();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
static void _run(_IsolateJsEngineInitParam params) async {
|
||||
var sendPort = params.sendPort;
|
||||
final port = ReceivePort();
|
||||
sendPort.send(port.sendPort);
|
||||
final engine = JsEngine();
|
||||
try {
|
||||
JsEngine.cacheJsInit(params.jsInit);
|
||||
await engine.init();
|
||||
}
|
||||
catch(e, s) {
|
||||
sendPort.send(Exception("Failed to initialize JS engine: $e\n$s"));
|
||||
return;
|
||||
}
|
||||
await for (final message in port) {
|
||||
if (message is Task) {
|
||||
try {
|
||||
final jsFunc = engine.runCode(message.jsFunction);
|
||||
if (jsFunc is! JSInvokable) {
|
||||
throw Exception("The provided code does not evaluate to a function.");
|
||||
}
|
||||
final result = jsFunc.invoke(message.args);
|
||||
jsFunc.free();
|
||||
sendPort.send(TaskResult(message.id, result, null));
|
||||
} catch (e) {
|
||||
sendPort.send(TaskResult(message.id, null, e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
|
||||
if (_isClosed) {
|
||||
throw Exception("IsolateJsEngine is closed.");
|
||||
}
|
||||
while (_sendPort == null) {
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
final completer = Completer<dynamic>();
|
||||
final taskId = _counter++;
|
||||
_tasks[taskId] = completer;
|
||||
final task = Task(taskId, jsFunction, args);
|
||||
_sendPort?.send(task);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void close() async {
|
||||
if (!_isClosed) {
|
||||
_isClosed = true;
|
||||
while (_tasks.isNotEmpty) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
_receivePort?.close();
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Task {
|
||||
final int id;
|
||||
final String jsFunction;
|
||||
final List<dynamic> args;
|
||||
|
||||
const Task(this.id, this.jsFunction, this.args);
|
||||
}
|
||||
|
||||
class TaskResult {
|
||||
final int id;
|
||||
final Object? result;
|
||||
final String? error;
|
||||
|
||||
const TaskResult(this.id, this.result, this.error);
|
||||
}
|
||||
@@ -153,7 +153,7 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
),
|
||||
author: subtitle,
|
||||
tags: tags,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,6 +423,7 @@ class LocalManager with ChangeNotifier {
|
||||
if (comic.hasChapters) {
|
||||
var cid =
|
||||
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
|
||||
cid = getChapterDirectoryName(cid);
|
||||
directory = Directory(FilePath.join(directory.path, cid));
|
||||
}
|
||||
var files = <File>[];
|
||||
@@ -600,7 +601,10 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
var shouldRemovedDirs = <Directory>[];
|
||||
for (var chapter in chapters) {
|
||||
var dir = Directory(FilePath.join(c.baseDir, chapter));
|
||||
var dir = Directory(FilePath.join(
|
||||
c.baseDir,
|
||||
getChapterDirectoryName(chapter),
|
||||
));
|
||||
if (dir.existsSync()) {
|
||||
shouldRemovedDirs.add(dir);
|
||||
}
|
||||
@@ -668,6 +672,21 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static String getChapterDirectoryName(String name) {
|
||||
var builder = StringBuffer();
|
||||
for (var i = 0; i < name.length; i++) {
|
||||
var char = name[i];
|
||||
if (char == '/' || char == '\\' || char == ':' || char == '*' ||
|
||||
char == '?'
|
||||
|| char == '"' || char == '<' || char == '>' || char == '|') {
|
||||
builder.write('_');
|
||||
} else {
|
||||
builder.write(char);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalSortType {
|
||||
|
||||
@@ -42,7 +42,7 @@ class Log {
|
||||
|
||||
static void addLog(LogLevel level, String title, String content) {
|
||||
if (isMuted) return;
|
||||
if (_file == null) {
|
||||
if (_file == null && App.isInitialized) {
|
||||
Directory dir;
|
||||
if (App.isAndroid) {
|
||||
dir = Directory(App.externalStoragePath!);
|
||||
|
||||
@@ -199,6 +199,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
tertiary = light.tertiary;
|
||||
}
|
||||
return MaterialApp(
|
||||
title: "venera",
|
||||
home: home,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: getTheme(primary, secondary, tertiary, Brightness.light),
|
||||
@@ -246,9 +247,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
/// https://github.com/flutter/flutter/issues/161086
|
||||
var isPaddingCheckError =
|
||||
MediaQuery.of(context).viewPadding.top <= 0 ||
|
||||
MediaQuery.of(context).viewPadding.top > 50;
|
||||
MediaQuery.of(context).viewPadding.top > 200;
|
||||
|
||||
if (isPaddingCheckError) {
|
||||
if (isPaddingCheckError && Platform.isAndroid) {
|
||||
widget = MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
viewPadding: const EdgeInsets.only(
|
||||
|
||||
@@ -96,11 +96,28 @@ class MyLogInterceptor implements Interceptor {
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
const String headerMask = "********";
|
||||
const String dataMask = "****** DATA_PROTECTED ******";
|
||||
Log.info(
|
||||
"Network",
|
||||
"${options.method} ${options.uri}\n"
|
||||
"headers:\n${options.headers}\n"
|
||||
"data:\n${options.data}");
|
||||
"headers:\n${
|
||||
options.extra.containsKey("maskHeadersInLog")
|
||||
? options.headers.map((key, value) =>
|
||||
MapEntry(
|
||||
key,
|
||||
options.extra["maskHeadersInLog"].contains(key)
|
||||
? headerMask
|
||||
: value
|
||||
))
|
||||
: options.headers
|
||||
}\n"
|
||||
"data:\n${
|
||||
options.extra["maskDataInLog"] == true
|
||||
? dataMask
|
||||
: options.data
|
||||
}"
|
||||
);
|
||||
options.connectTimeout = const Duration(seconds: 15);
|
||||
options.receiveTimeout = const Duration(seconds: 15);
|
||||
options.sendTimeout = const Duration(seconds: 15);
|
||||
@@ -112,10 +129,12 @@ class AppDio with DioMixin {
|
||||
AppDio([BaseOptions? options]) {
|
||||
this.options = options ?? BaseOptions();
|
||||
httpClientAdapter = RHttpAdapter();
|
||||
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
interceptors.add(NetworkCacheManager());
|
||||
interceptors.add(CloudflareInterceptor());
|
||||
interceptors.add(MyLogInterceptor());
|
||||
if (App.isInitialized) {
|
||||
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
interceptors.add(NetworkCacheManager());
|
||||
interceptors.add(CloudflareInterceptor());
|
||||
interceptors.add(MyLogInterceptor());
|
||||
}
|
||||
}
|
||||
|
||||
static final Map<String, bool> _requests = {};
|
||||
|
||||
@@ -128,10 +128,15 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
|
||||
var head =
|
||||
await controller.evaluateJavascript("document.head.innerHTML") ??
|
||||
"";
|
||||
var body =
|
||||
await controller.evaluateJavascript("document.body.innerHTML") ??
|
||||
"";
|
||||
Log.info("Cloudflare", "Checking head: $head");
|
||||
var isChallenging = head.contains('#challenge-success-text') ||
|
||||
head.contains("#challenge-error-text") ||
|
||||
head.contains("#challenge-form");
|
||||
head.contains("#challenge-form") ||
|
||||
body.contains("challenge-platform") ||
|
||||
body.contains("window._cf_chl_opt");
|
||||
if (!isChallenging) {
|
||||
Log.info(
|
||||
"Cloudflare",
|
||||
@@ -159,10 +164,14 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
|
||||
void check(InAppWebViewController controller) async {
|
||||
var head = await controller.evaluateJavascript(
|
||||
source: "document.head.innerHTML") as String;
|
||||
var body = await controller.evaluateJavascript(
|
||||
source: "document.body.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");
|
||||
head.contains("#challenge-form") ||
|
||||
body.contains("challenge-platform") ||
|
||||
body.contains("window._cf_chl_opt");
|
||||
if (!isChallenging) {
|
||||
Log.info(
|
||||
"Cloudflare",
|
||||
|
||||
@@ -202,9 +202,13 @@ class SingleInstanceCookieJar extends CookieJarSql {
|
||||
|
||||
static SingleInstanceCookieJar? instance;
|
||||
|
||||
static Future<void> createInstance() async {
|
||||
static Future<SingleInstanceCookieJar> createInstance() async {
|
||||
if (instance != null) {
|
||||
return instance!;
|
||||
}
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
|
||||
return instance!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,21 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
var local = LocalManager().find(id, comicType);
|
||||
if (path != null) {
|
||||
if (local == null) {
|
||||
Directory(path!).deleteIgnoreError(recursive: true);
|
||||
Future.sync(() async {
|
||||
var tasks = this.tasks.values.toList();
|
||||
for (var i = 0; i < tasks.length; i++) {
|
||||
if (!tasks[i].isComplete) {
|
||||
tasks[i].cancel();
|
||||
await tasks[i].wait();
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Directory(path!).delete(recursive: true);
|
||||
}
|
||||
catch(e) {
|
||||
Log.error("Download", "Failed to delete directory: $e");
|
||||
}
|
||||
});
|
||||
} else if (chapters != null) {
|
||||
for (var c in chapters!) {
|
||||
var dir = Directory(FilePath.join(path!, c));
|
||||
@@ -197,7 +211,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
if (comic!.chapters != null) {
|
||||
saveTo = Directory(FilePath.join(
|
||||
path!,
|
||||
_images!.keys.elementAt(_chapter),
|
||||
LocalManager.getChapterDirectoryName(
|
||||
_images!.keys.elementAt(_chapter),
|
||||
),
|
||||
));
|
||||
if (!saveTo.existsSync()) {
|
||||
saveTo.createSync(recursive: true);
|
||||
|
||||
@@ -52,7 +52,11 @@ abstract class ImageDownloader {
|
||||
responseType: ResponseType.stream,
|
||||
));
|
||||
|
||||
var req = await dio.request<ResponseBody>(configs['url'] ?? url,
|
||||
String requestUrl = configs['url'] ?? url;
|
||||
if (requestUrl.startsWith('//')) {
|
||||
requestUrl = 'https:$requestUrl';
|
||||
}
|
||||
var req = await dio.request<ResponseBody>(requestUrl,
|
||||
data: configs['data']);
|
||||
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
||||
int? expectedBytes = req.data!.contentLength;
|
||||
@@ -181,12 +185,25 @@ abstract class ImageDownloader {
|
||||
}
|
||||
|
||||
if (configs['onResponse'] is JSInvokable) {
|
||||
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
|
||||
dynamic result = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
|
||||
if (result is Future) {
|
||||
result = await result;
|
||||
}
|
||||
if (result is List<int>) {
|
||||
buffer = result;
|
||||
} else {
|
||||
throw "Error: Invalid onResponse result.";
|
||||
}
|
||||
(configs['onResponse'] as JSInvokable).free();
|
||||
}
|
||||
|
||||
var data = Uint8List.fromList(buffer);
|
||||
buffer.clear();
|
||||
Uint8List data;
|
||||
if (buffer is Uint8List) {
|
||||
data = buffer;
|
||||
} else {
|
||||
data = Uint8List.fromList(buffer);
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
if (configs['modifyImage'] != null) {
|
||||
var newData = await modifyImageWithScript(
|
||||
|
||||
@@ -17,39 +17,50 @@ class CategoriesPage extends StatefulWidget {
|
||||
State<CategoriesPage> createState() => _CategoriesPageState();
|
||||
}
|
||||
|
||||
class _CategoriesPageState extends State<CategoriesPage> {
|
||||
class _CategoriesPageState extends State<CategoriesPage>
|
||||
with
|
||||
TickerProviderStateMixin,
|
||||
AutomaticKeepAliveClientMixin<CategoriesPage> {
|
||||
var categories = <String>[];
|
||||
|
||||
late TabController controller;
|
||||
|
||||
void onSettingsChanged() {
|
||||
var categories =
|
||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||
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.isEqualTo(this.categories)) {
|
||||
setState(() {
|
||||
this.categories = categories;
|
||||
});
|
||||
controller = TabController(length: categories.length, vsync: this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
var categories =
|
||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||
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();
|
||||
this.categories = categories
|
||||
.where((element) => allCategories.contains(element))
|
||||
.toList();
|
||||
appdata.settings.addListener(onSettingsChanged);
|
||||
controller = TabController(length: categories.length, vsync: this);
|
||||
}
|
||||
|
||||
void addPage() {
|
||||
@@ -59,6 +70,7 @@ class _CategoriesPageState extends State<CategoriesPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
controller.dispose();
|
||||
appdata.settings.removeListener(onSettingsChanged);
|
||||
}
|
||||
|
||||
@@ -85,46 +97,45 @@ class _CategoriesPageState extends State<CategoriesPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
if (categories.isEmpty) {
|
||||
return buildEmpty();
|
||||
}
|
||||
|
||||
return Material(
|
||||
child: DefaultTabController(
|
||||
length: categories.length,
|
||||
key: Key(categories.toString()),
|
||||
child: Column(
|
||||
children: [
|
||||
AppTabBar(
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
try {
|
||||
title = getCategoryDataWithKey(e).title;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return Tab(
|
||||
text: title,
|
||||
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(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
AppTabBar(
|
||||
controller: controller,
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
try {
|
||||
title = getCategoryDataWithKey(e).title;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return Tab(text: title, key: Key(e));
|
||||
}).toList(),
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: controller,
|
||||
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
typedef ClickTagCallback = void Function(String, String?);
|
||||
@@ -150,38 +161,42 @@ class _CategoryPage extends StatelessWidget {
|
||||
var children = <Widget>[];
|
||||
if (data.enableRankingPage || data.buttons.isNotEmpty) {
|
||||
children.add(buildTitle(data.title));
|
||||
children.add(Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
children: [
|
||||
if (data.enableRankingPage)
|
||||
buildTag("Ranking".tl, () {
|
||||
context.to(() => RankingPage(categoryKey: data.key));
|
||||
}),
|
||||
for (var buttonData in data.buttons)
|
||||
buildTag(buttonData.label.tl, buttonData.onTap)
|
||||
],
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
children: [
|
||||
if (data.enableRankingPage)
|
||||
buildTag("Ranking".tl, () {
|
||||
context.to(() => RankingPage(categoryKey: data.key));
|
||||
}),
|
||||
for (var buttonData in data.buttons)
|
||||
buildTag(buttonData.label.tl, buttonData.onTap),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
for (var part in data.categories) {
|
||||
if (part.enableRandom) {
|
||||
children.add(StatefulBuilder(builder: (context, updater) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitleWithRefresh(part.title, () => updater(() {})),
|
||||
buildTags(part.categories)
|
||||
],
|
||||
);
|
||||
}));
|
||||
children.add(
|
||||
StatefulBuilder(
|
||||
builder: (context, updater) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitleWithRefresh(part.title, () => updater(() {})),
|
||||
buildTags(part.categories),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
children.add(buildTitle(part.title));
|
||||
children.add(
|
||||
buildTags(part.categories),
|
||||
);
|
||||
children.add(buildTags(part.categories));
|
||||
}
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
@@ -195,8 +210,10 @@ class _CategoryPage extends StatelessWidget {
|
||||
Widget buildTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 5, 10),
|
||||
child: Text(title.tl,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500)),
|
||||
child: Text(
|
||||
title.tl,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,21 +224,16 @@ class _CategoryPage extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title.tl,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh))
|
||||
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTags(
|
||||
List<CategoryItem> categories,
|
||||
) {
|
||||
Widget buildTags(List<CategoryItem> categories) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
|
||||
@@ -27,9 +27,11 @@ class CategoryComicsPage extends StatefulWidget {
|
||||
|
||||
class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
late final CategoryComicsData data;
|
||||
late final List<CategoryComicsOptions> options;
|
||||
late List<CategoryComicsOptions>? options;
|
||||
late final CategoryOptionsLoader? optionsLoader;
|
||||
late List<String> optionsValue;
|
||||
late String sourceKey;
|
||||
String? error;
|
||||
|
||||
void findData() {
|
||||
for (final source in ComicSource.all()) {
|
||||
@@ -38,24 +40,23 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
throw "The comic source ${source.name} does not support category comics";
|
||||
}
|
||||
data = source.categoryComicsData!;
|
||||
options = data.options.where((element) {
|
||||
if (element.notShowWhen.contains(widget.category)) {
|
||||
return false;
|
||||
} else if (element.showWhen != null) {
|
||||
return element.showWhen!.contains(widget.category);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
var defaultOptionsValue =
|
||||
options.map((e) => e.options.keys.first).toList();
|
||||
if (optionsValue.length != options.length) {
|
||||
var newOptionsValue = List<String>.filled(options.length, "");
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
newOptionsValue[i] =
|
||||
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
|
||||
}
|
||||
optionsValue = newOptionsValue;
|
||||
if (data.options != null) {
|
||||
options = data.options!.where((element) {
|
||||
if (element.notShowWhen.contains(widget.category)) {
|
||||
return false;
|
||||
} else if (element.showWhen != null) {
|
||||
return element.showWhen!.contains(widget.category);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
} else {
|
||||
options = null;
|
||||
}
|
||||
if (data.optionsLoader != null) {
|
||||
optionsLoader = data.optionsLoader;
|
||||
loadOptions();
|
||||
}
|
||||
resetOptionsValue();
|
||||
sourceKey = source.key;
|
||||
return;
|
||||
}
|
||||
@@ -63,6 +64,36 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
throw "${widget.categoryKey} Not found";
|
||||
}
|
||||
|
||||
void resetOptionsValue() {
|
||||
if (options == null) return;
|
||||
var defaultOptionsValue = options!
|
||||
.map((e) => e.options.keys.first)
|
||||
.toList();
|
||||
if (optionsValue.length != options!.length) {
|
||||
var newOptionsValue = List<String>.filled(options!.length, "");
|
||||
for (var i = 0; i < options!.length; i++) {
|
||||
newOptionsValue[i] =
|
||||
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
|
||||
}
|
||||
optionsValue = newOptionsValue;
|
||||
}
|
||||
}
|
||||
|
||||
void loadOptions() async {
|
||||
final res = await optionsLoader!(widget.category, widget.param);
|
||||
if (res.error) {
|
||||
setState(() {
|
||||
error = res.errorMessage;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
options = res.data;
|
||||
resetOptionsValue();
|
||||
error = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.options != null) {
|
||||
@@ -77,27 +108,44 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var topPadding = context.padding.top + 56.0;
|
||||
|
||||
Widget body;
|
||||
|
||||
if (options == null) {
|
||||
body = Center(child: CircularProgressIndicator());
|
||||
} else if (error != null) {
|
||||
body = NetworkError(
|
||||
message: error!,
|
||||
retry: () {
|
||||
setState(() {
|
||||
error = null;
|
||||
});
|
||||
loadOptions();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
body = ComicList(
|
||||
key: Key(widget.category + optionsValue.toString()),
|
||||
errorLeading: buildOptions().paddingTop(topPadding),
|
||||
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
|
||||
loadPage: (i) =>
|
||||
data.load(widget.category, widget.param, optionsValue, i),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: Appbar(
|
||||
title: Text(widget.category),
|
||||
),
|
||||
body: ComicList(
|
||||
key: Key(widget.category + optionsValue.toString()),
|
||||
errorLeading: SizedBox(height: topPadding),
|
||||
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
|
||||
loadPage: (i) => data.load(
|
||||
widget.category,
|
||||
widget.param,
|
||||
optionsValue,
|
||||
i,
|
||||
),
|
||||
),
|
||||
appBar: Appbar(title: Text(widget.category)),
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildOptionItem(
|
||||
String text, String value, int group, BuildContext context) {
|
||||
String text,
|
||||
String value,
|
||||
int group,
|
||||
BuildContext context,
|
||||
) {
|
||||
return OptionChip(
|
||||
text: text.ts(sourceKey),
|
||||
isSelected: value == optionsValue[group],
|
||||
@@ -112,23 +160,57 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
|
||||
Widget buildOptions() {
|
||||
List<Widget> children = [];
|
||||
for (var optionList in options) {
|
||||
children.add(Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var option in optionList.options.entries)
|
||||
buildOptionItem(
|
||||
option.value.tl,
|
||||
option.key,
|
||||
options.indexOf(optionList),
|
||||
context,
|
||||
)
|
||||
],
|
||||
));
|
||||
if (options.last != optionList) {
|
||||
var group = 0;
|
||||
for (var optionList in options!) {
|
||||
if (optionList.label.isNotEmpty) {
|
||||
children.add(Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8.0,
|
||||
left: 4.0,
|
||||
),
|
||||
child: Text(
|
||||
optionList.label.ts(sourceKey),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
if (optionList.options.length <= 8) {
|
||||
children.add(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var option in optionList.options.entries)
|
||||
buildOptionItem(
|
||||
option.value.tl,
|
||||
option.key,
|
||||
group,
|
||||
context,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
var g = group;
|
||||
children.add(Select(
|
||||
current: optionList.options[optionsValue[g]],
|
||||
values: optionList.options.values.toList(),
|
||||
onTap: (i) {
|
||||
var key = optionList.options.keys.elementAt(i);
|
||||
if (key == optionsValue[g]) return;
|
||||
setState(() {
|
||||
optionsValue[g] = key;
|
||||
});
|
||||
},
|
||||
));
|
||||
}
|
||||
if (options!.last != optionList) {
|
||||
children.add(const SizedBox(height: 8));
|
||||
}
|
||||
group++;
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -56,8 +56,12 @@ abstract mixin class _ComicPageActions {
|
||||
type: comic.comicType,
|
||||
isFavorite: isFavorite,
|
||||
onFavorite: (local, network) {
|
||||
isFavorite = network ?? isFavorite;
|
||||
isAddToLocalFav = local ?? isAddToLocalFav;
|
||||
if (network != null) {
|
||||
isFavorite = network;
|
||||
}
|
||||
if (local != null) {
|
||||
isAddToLocalFav = local;
|
||||
}
|
||||
update();
|
||||
},
|
||||
favoriteItem: _toFavoriteItem(),
|
||||
@@ -111,7 +115,7 @@ abstract mixin class _ComicPageActions {
|
||||
history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
|
||||
author: comic.findAuthor() ?? '',
|
||||
tags: comic.plainTags,
|
||||
),
|
||||
)
|
||||
)
|
||||
.then((_) {
|
||||
onReadEnd();
|
||||
@@ -151,64 +155,60 @@ abstract mixin class _ComicPageActions {
|
||||
builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Download".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<int>(
|
||||
value: -1,
|
||||
groupValue: selected,
|
||||
title: Text("Normal".tl),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
title: Text("Archive".tl),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
content: RadioGroup<int>(
|
||||
groupValue: selected,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v ?? selected;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<int>(
|
||||
value: -1,
|
||||
title: Text("Normal".tl),
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
onExpansionChanged: (b) {
|
||||
if (!isLoading && b && archives == null) {
|
||||
isLoading = true;
|
||||
comicSource.archiveDownloader!
|
||||
.getArchives(comic.id)
|
||||
.then((value) {
|
||||
if (value.success) {
|
||||
archives = value.data;
|
||||
} else {
|
||||
App.rootContext
|
||||
.showMessage(message: value.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
ExpansionTile(
|
||||
title: Text("Archive".tl),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
onExpansionChanged: (b) {
|
||||
if (!isLoading && b && archives == null) {
|
||||
isLoading = true;
|
||||
comicSource.archiveDownloader!
|
||||
.getArchives(comic.id)
|
||||
.then((value) {
|
||||
if (value.success) {
|
||||
archives = value.data;
|
||||
} else {
|
||||
App.rootContext
|
||||
.showMessage(message: value.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
children: [
|
||||
if (archives == null)
|
||||
const ListLoadingIndicator().toCenter()
|
||||
else
|
||||
for (int i = 0; i < archives!.length; i++)
|
||||
RadioListTile<int>(
|
||||
value: i,
|
||||
groupValue: selected,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v!;
|
||||
});
|
||||
},
|
||||
title: Text(archives![i].title),
|
||||
subtitle: Text(archives![i].description),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
},
|
||||
children: [
|
||||
if (archives == null)
|
||||
const ListLoadingIndicator().toCenter()
|
||||
else
|
||||
for (int i = 0; i < archives!.length; i++)
|
||||
RadioListTile<int>(
|
||||
value: i,
|
||||
title: Text(archives![i].title),
|
||||
subtitle: Text(archives![i].description),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Button.filled(
|
||||
@@ -233,10 +233,12 @@ abstract mixin class _ComicPageActions {
|
||||
isGettingLink = false;
|
||||
});
|
||||
} else if (context.mounted) {
|
||||
LocalManager()
|
||||
if (res.data.isNotEmpty) {
|
||||
LocalManager()
|
||||
.addTask(ArchiveDownloadTask(res.data, comic));
|
||||
App.rootContext
|
||||
App.rootContext
|
||||
.showMessage(message: "Download started".tl);
|
||||
}
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:shimmer_animation/shimmer_animation.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -17,10 +20,12 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/network/cache.dart';
|
||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/app_links.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';
|
||||
@@ -38,6 +43,8 @@ part 'comments_preview.dart';
|
||||
|
||||
part 'actions.dart';
|
||||
|
||||
part 'cover_viewer.dart';
|
||||
|
||||
class ComicPage extends StatefulWidget {
|
||||
const ComicPage({
|
||||
super.key,
|
||||
@@ -77,8 +84,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
|
||||
@override
|
||||
void onReadEnd() {
|
||||
history ??=
|
||||
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
|
||||
history ??= HistoryManager().find(
|
||||
widget.id,
|
||||
ComicType(widget.sourceKey.hashCode),
|
||||
);
|
||||
update();
|
||||
}
|
||||
|
||||
@@ -93,6 +102,32 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildError() {
|
||||
final isDownloaded = LocalManager().isDownloaded(
|
||||
widget.id,
|
||||
ComicType.fromKey(widget.sourceKey),
|
||||
);
|
||||
Widget? action;
|
||||
if (isDownloaded) {
|
||||
action = FilledButton.tonal(
|
||||
child: Text("Read".tl),
|
||||
onPressed: () {
|
||||
final localComic = LocalManager().find(
|
||||
widget.id,
|
||||
ComicType.fromKey(widget.sourceKey),
|
||||
);
|
||||
if (localComic == null) {
|
||||
context.showMessage(message: "Local comic not found".tl);
|
||||
return;
|
||||
}
|
||||
localComic.read();
|
||||
},
|
||||
);
|
||||
}
|
||||
return NetworkError(message: error!, retry: retry, action: action);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
scrollController.addListener(onScroll);
|
||||
@@ -114,7 +149,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
ComicDetails get comic => data!;
|
||||
|
||||
void onScroll() {
|
||||
var offset = scrollController.position.pixels -
|
||||
var offset =
|
||||
scrollController.position.pixels -
|
||||
scrollController.position.minScrollExtent;
|
||||
var showFAB = offset > 0;
|
||||
if (showFAB != this.showFAB) {
|
||||
@@ -145,9 +181,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
floatingActionButton: showFAB
|
||||
? FloatingActionButton(
|
||||
onPressed: () {
|
||||
scrollController.animateTo(0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease);
|
||||
scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.arrow_upward),
|
||||
)
|
||||
@@ -164,7 +202,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
buildThumbnails(),
|
||||
buildRecommend(),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB
|
||||
padding: EdgeInsets.only(
|
||||
bottom: context.padding.bottom + 80,
|
||||
), // Add additional padding for FAB
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -190,12 +230,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
initialPage: history?.page,
|
||||
initialChapter: history?.ep,
|
||||
initialChapterGroup: history?.group,
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: localComic,
|
||||
ep: 0,
|
||||
page: 0,
|
||||
),
|
||||
history:
|
||||
history ??
|
||||
History.fromModel(model: localComic, ep: 0, page: 0),
|
||||
author: localComic.subTitle ?? '',
|
||||
tags: localComic.tags,
|
||||
);
|
||||
@@ -215,8 +252,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
widget.id,
|
||||
ComicType(widget.sourceKey.hashCode),
|
||||
);
|
||||
history =
|
||||
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
|
||||
history = HistoryManager().find(
|
||||
widget.id,
|
||||
ComicType(widget.sourceKey.hashCode),
|
||||
);
|
||||
return comicSource.loadComicInfo!(widget.id);
|
||||
}
|
||||
|
||||
@@ -224,12 +263,20 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
Future<void> onDataLoaded() async {
|
||||
isLiked = comic.isLiked ?? false;
|
||||
isFavorite = comic.isFavorite ?? false;
|
||||
// For sources with multi-folder favorites, prefer querying folders to get accurate favorite status
|
||||
// Some sources may not set isFavorite reliably when multi-folder is enabled
|
||||
if (comicSource.favoriteData?.loadFolders != null && comicSource.isLogged) {
|
||||
var res = await comicSource.favoriteData!.loadFolders!(comic.id);
|
||||
if (!res.error) {
|
||||
if (res.subData is List) {
|
||||
var list = List<String>.from(res.subData);
|
||||
isFavorite = list.isNotEmpty;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (comic.chapters == null) {
|
||||
isDownloaded = LocalManager().isDownloaded(
|
||||
comic.id,
|
||||
comic.comicType,
|
||||
0,
|
||||
);
|
||||
isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +289,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
|
||||
onPressed: showMoreActions,
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -253,31 +302,35 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
Hero(
|
||||
tag: "cover${widget.heroID}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 144,
|
||||
width: 144 * 0.72,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
GestureDetector(
|
||||
onTap: () => _viewCover(context),
|
||||
onLongPress: () => _saveCover(context),
|
||||
child: Hero(
|
||||
tag: "cover${widget.heroID}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 144,
|
||||
width: 144 * 0.72,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -288,8 +341,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
children: [
|
||||
SelectableText(comic.title, style: ts.s18),
|
||||
if (comic.subTitle != null)
|
||||
SelectableText(comic.subTitle!, style: ts.s14)
|
||||
.paddingVertical(4),
|
||||
SelectableText(
|
||||
comic.subTitle!,
|
||||
style: ts.s14,
|
||||
).paddingVertical(4),
|
||||
Text(
|
||||
(ComicSource.find(comic.sourceKey)?.name) ?? '',
|
||||
style: ts.s12,
|
||||
@@ -338,10 +393,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
icon: const Icon(Icons.favorite_border),
|
||||
activeIcon: const Icon(Icons.favorite),
|
||||
isActive: isLiked,
|
||||
text: ((data!.likesCount != null)
|
||||
? (data!.likesCount! + (isLiked ? 1 : 0))
|
||||
: (isLiked ? 'Liked'.tl : 'Like'.tl))
|
||||
.toString(),
|
||||
text:
|
||||
((data!.likesCount != null)
|
||||
? (data!.likesCount! + (isLiked ? 1 : 0))
|
||||
: (isLiked ? 'Liked'.tl : 'Like'.tl))
|
||||
.toString(),
|
||||
isLoading: isLiking,
|
||||
onPressed: likeOrUnlike,
|
||||
iconColor: context.useTextColor(Colors.red),
|
||||
@@ -383,9 +439,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
Expanded(
|
||||
child: hasHistory
|
||||
? FilledButton(
|
||||
onPressed: continueRead, child: Text("Continue".tl))
|
||||
onPressed: continueRead,
|
||||
child: Text("Continue".tl),
|
||||
)
|
||||
: FilledButton(onPressed: read, child: Text("Read".tl)),
|
||||
)
|
||||
),
|
||||
],
|
||||
).paddingHorizontal(16).paddingVertical(8),
|
||||
if (history != null)
|
||||
@@ -412,19 +470,20 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
var epName = "E$ep";
|
||||
String? groupName;
|
||||
try {
|
||||
if (group == null){
|
||||
if (group == null) {
|
||||
epName = comic.chapters!.titles.elementAt(
|
||||
math.min(ep - 1, comic.chapters!.length - 1),
|
||||
);
|
||||
} else {
|
||||
groupName = comic.chapters!.groups.elementAt(group - 1);
|
||||
groupName = comic.chapters!.groups.elementAt(
|
||||
group - 1,
|
||||
);
|
||||
epName = comic.chapters!
|
||||
.getGroupByIndex(group - 1)
|
||||
.values
|
||||
.elementAt(ep - 1);
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
text = groupName == null
|
||||
@@ -453,9 +512,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
return SliverLazyToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Description".tl),
|
||||
),
|
||||
ListTile(title: Text("Description".tl)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SelectableText(comic.description!).fixWidth(double.infinity),
|
||||
@@ -539,10 +596,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
decoration: BoxDecoration(color: color, borderRadius: borderRadius),
|
||||
child: Text(text).padding(padding),
|
||||
);
|
||||
}
|
||||
@@ -552,13 +606,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
if (int.tryParse(time) != null) {
|
||||
var t = int.tryParse(time);
|
||||
if (t! > 1000000000000) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(t)
|
||||
.toString()
|
||||
.substring(0, 19);
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
t,
|
||||
).toString().substring(0, 19);
|
||||
} else {
|
||||
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
|
||||
.toString()
|
||||
.substring(0, 19);
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
t * 1000,
|
||||
).toString().substring(0, 19);
|
||||
}
|
||||
}
|
||||
if (time.contains('T') || time.contains('Z')) {
|
||||
@@ -583,17 +637,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Information".tl),
|
||||
),
|
||||
ListTile(title: Text("Information".tl)),
|
||||
if (comic.stars != null)
|
||||
Row(
|
||||
children: [
|
||||
StarRating(
|
||||
value: comic.stars!,
|
||||
size: 24,
|
||||
onTap: starRating,
|
||||
),
|
||||
StarRating(value: comic.stars!, size: 24, onTap: starRating),
|
||||
const SizedBox(width: 8),
|
||||
Text(comic.stars!.toStringAsFixed(2)),
|
||||
],
|
||||
@@ -671,24 +719,67 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
if (comic.recommend == null || comic.recommend!.isEmpty) {
|
||||
return const SliverPadding(padding: EdgeInsets.zero);
|
||||
}
|
||||
return SliverMainAxisGroup(slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text("Related".tl),
|
||||
),
|
||||
),
|
||||
SliverGridComics(comics: comic.recommend!),
|
||||
]);
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: ListTile(title: Text("Related".tl))),
|
||||
SliverGridComics(comics: comic.recommend!),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildComments() {
|
||||
if (comic.comments == null || comic.comments!.isEmpty) {
|
||||
return const SliverPadding(padding: EdgeInsets.zero);
|
||||
}
|
||||
return _CommentsPart(
|
||||
comments: comic.comments!,
|
||||
showMore: showComments,
|
||||
return _CommentsPart(comments: comic.comments!, showMore: showComments);
|
||||
}
|
||||
|
||||
void _viewCover(BuildContext context) {
|
||||
final imageProvider = CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
);
|
||||
|
||||
context.to(
|
||||
() => _CoverViewer(
|
||||
imageProvider: imageProvider,
|
||||
title: comic.title,
|
||||
heroTag: "cover${widget.heroID}",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveCover(BuildContext context) async {
|
||||
try {
|
||||
final imageProvider = CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
);
|
||||
|
||||
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||
final completer = Completer<Uint8List>();
|
||||
|
||||
imageStream.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) async {
|
||||
final byteData = await info.image.toByteData(
|
||||
format: ImageByteFormat.png,
|
||||
);
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
final data = await completer.future;
|
||||
final fileType = detectFileType(data);
|
||||
await saveFile(filename: "cover${fileType.ext}", data: data);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showMessage(message: "Error".tl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,20 +883,21 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
|
||||
itemCount: widget.eps.length,
|
||||
itemBuilder: (context, i) {
|
||||
return CheckboxListTile(
|
||||
title: Text(widget.eps[i]),
|
||||
value: selected.contains(i) ||
|
||||
widget.downloadedEps.contains(i),
|
||||
onChanged: widget.downloadedEps.contains(i)
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
if (selected.contains(i)) {
|
||||
selected.remove(i);
|
||||
} else {
|
||||
selected.add(i);
|
||||
}
|
||||
});
|
||||
title: Text(widget.eps[i]),
|
||||
value:
|
||||
selected.contains(i) || widget.downloadedEps.contains(i),
|
||||
onChanged: widget.downloadedEps.contains(i)
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
if (selected.contains(i)) {
|
||||
selected.remove(i);
|
||||
} else {
|
||||
selected.add(i);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -813,9 +905,7 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
),
|
||||
top: BorderSide(color: context.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -880,8 +970,12 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget buildContainer(double? width, double? height,
|
||||
{Color? color, double? radius}) {
|
||||
Widget buildContainer(
|
||||
double? width,
|
||||
double? height, {
|
||||
Color? color,
|
||||
double? radius,
|
||||
}) {
|
||||
return Container(
|
||||
height: height,
|
||||
width: width,
|
||||
@@ -923,13 +1017,9 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
if (context.width < changePoint)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: buildContainer(null, 36, radius: 18),
|
||||
),
|
||||
Expanded(child: buildContainer(null, 36, radius: 18)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: buildContainer(null, 36, radius: 18),
|
||||
),
|
||||
Expanded(child: buildContainer(null, 36, radius: 18)),
|
||||
],
|
||||
).paddingHorizontal(16),
|
||||
const Divider(),
|
||||
@@ -938,7 +1028,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
).fixHeight(24).fixWidth(24),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -948,11 +1038,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
Widget child;
|
||||
if (cover != null) {
|
||||
child = AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
cover!,
|
||||
sourceKey: sourceKey,
|
||||
cid: cid,
|
||||
),
|
||||
image: CachedImageProvider(cover!, sourceKey: sourceKey, cid: cid),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
|
||||
140
lib/pages/comic_details_page/cover_viewer.dart
Normal file
140
lib/pages/comic_details_page/cover_viewer.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
part of 'comic_page.dart';
|
||||
|
||||
class _CoverViewer extends StatefulWidget {
|
||||
const _CoverViewer({
|
||||
required this.imageProvider,
|
||||
required this.title,
|
||||
required this.heroTag,
|
||||
});
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
final String title;
|
||||
final String heroTag;
|
||||
|
||||
@override
|
||||
State<_CoverViewer> createState() => _CoverViewerState();
|
||||
}
|
||||
|
||||
class _CoverViewerState extends State<_CoverViewer> {
|
||||
bool isAppBarShow = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: PhotoView(
|
||||
imageProvider: widget.imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||
maxScale: PhotoViewComputedScale.covered * 3.0,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
),
|
||||
loadingBuilder: (context, event) => Center(
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: CircularProgressIndicator(
|
||||
value: event == null || event.expectedTotalBytes == null
|
||||
? null
|
||||
: event.cumulativeBytesLoaded /
|
||||
event.expectedTotalBytes!,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTapUp: (context, details, controllerValue) {
|
||||
setState(() {
|
||||
isAppBarShow = !isAppBarShow;
|
||||
});
|
||||
},
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: widget.heroTag),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
top: isAppBarShow ? 0 : -(context.padding.top + 52),
|
||||
left: 0,
|
||||
right: 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: _buildAppBar(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save_alt),
|
||||
onPressed: _saveCover,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveCover() async {
|
||||
try {
|
||||
final imageStream = widget.imageProvider.resolve(
|
||||
const ImageConfiguration(),
|
||||
);
|
||||
final completer = Completer<Uint8List>();
|
||||
|
||||
imageStream.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) async {
|
||||
final byteData = await info.image.toByteData(
|
||||
format: ImageByteFormat.png,
|
||||
);
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
final data = await completer.future;
|
||||
final fileType = detectFileType(data);
|
||||
await saveFile(filename: "cover_${widget.title}${fileType.ext}", data: data);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
context.showMessage(message: "Error".tl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,198 +33,122 @@ class _FavoritePanelState extends State<_FavoritePanel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late ComicSource comicSource;
|
||||
|
||||
late TabController tabController;
|
||||
|
||||
late bool hasNetwork;
|
||||
|
||||
late List<String> localFolders;
|
||||
|
||||
late List<String> added;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
comicSource = widget.type.comicSource!;
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
added = LocalFavoritesManager().find(widget.cid, widget.type);
|
||||
hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
|
||||
var initIndex = 0;
|
||||
if (appdata.implicitData['favoritePanelIndex'] is int) {
|
||||
initIndex = appdata.implicitData['favoritePanelIndex'];
|
||||
}
|
||||
initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0);
|
||||
tabController = TabController(
|
||||
initialIndex: initIndex,
|
||||
length: hasNetwork ? 2 : 1,
|
||||
vsync: this,
|
||||
);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
var currentIndex = tabController.index;
|
||||
appdata.implicitData['favoritePanelIndex'] = currentIndex;
|
||||
appdata.writeImplicitData();
|
||||
tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
title: Text("Favorite".tl),
|
||||
appBar: Appbar(title: Text("Favorite".tl)),
|
||||
body: _FavoriteList(
|
||||
cid: widget.cid,
|
||||
type: widget.type,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: widget.onFavorite,
|
||||
favoriteItem: widget.favoriteItem,
|
||||
updateTime: widget.updateTime,
|
||||
comicSource: comicSource,
|
||||
hasNetwork: hasNetwork,
|
||||
localFolders: localFolders,
|
||||
added: added,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: [
|
||||
Tab(text: "Local".tl),
|
||||
if (hasNetwork) Tab(text: "Network".tl),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
buildLocal(),
|
||||
if (hasNetwork) buildNetwork(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
late List<String> localFolders;
|
||||
|
||||
late List<String> added;
|
||||
|
||||
var selectedLocalFolders = <String>{};
|
||||
|
||||
Widget buildLocal() {
|
||||
var isRemove = selectedLocalFolders.isNotEmpty &&
|
||||
added.contains(selectedLocalFolders.first);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: localFolders.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == localFolders.length) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
newFolder().then((v) {
|
||||
setState(() {
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text("New Folder".tl)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
var folder = localFolders[index];
|
||||
var disabled = false;
|
||||
if (selectedLocalFolders.isNotEmpty) {
|
||||
if (added.contains(folder) &&
|
||||
!added.contains(selectedLocalFolders.first)) {
|
||||
disabled = true;
|
||||
} else if (!added.contains(folder) &&
|
||||
added.contains(selectedLocalFolders.first)) {
|
||||
disabled = true;
|
||||
}
|
||||
}
|
||||
return CheckboxListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(folder),
|
||||
const SizedBox(width: 8),
|
||||
if (added.contains(folder))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
value: selectedLocalFolders.contains(folder),
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
if (v!) {
|
||||
selectedLocalFolders.add(folder);
|
||||
} else {
|
||||
selectedLocalFolders.remove(folder);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (selectedLocalFolders.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (isRemove) {
|
||||
for (var folder in selectedLocalFolders) {
|
||||
LocalFavoritesManager()
|
||||
.deleteComicWithId(folder, widget.cid, widget.type);
|
||||
}
|
||||
widget.onFavorite(false, null);
|
||||
} else {
|
||||
for (var folder in selectedLocalFolders) {
|
||||
LocalFavoritesManager().addComic(
|
||||
folder,
|
||||
widget.favoriteItem,
|
||||
null,
|
||||
widget.updateTime,
|
||||
);
|
||||
}
|
||||
widget.onFavorite(true, null);
|
||||
}
|
||||
context.pop();
|
||||
},
|
||||
child: isRemove ? Text("Remove".tl) : Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildNetwork() {
|
||||
return _NetworkFavorites(
|
||||
cid: widget.cid,
|
||||
comicSource: comicSource,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: (network) {
|
||||
widget.onFavorite(null, network);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetworkFavorites extends StatefulWidget {
|
||||
const _NetworkFavorites({
|
||||
class _FavoriteList extends StatefulWidget {
|
||||
const _FavoriteList({
|
||||
required this.cid,
|
||||
required this.type,
|
||||
required this.isFavorite,
|
||||
required this.onFavorite,
|
||||
required this.favoriteItem,
|
||||
this.updateTime,
|
||||
required this.comicSource,
|
||||
required this.hasNetwork,
|
||||
required this.localFolders,
|
||||
required this.added,
|
||||
});
|
||||
|
||||
final String cid;
|
||||
final ComicType type;
|
||||
final bool? isFavorite;
|
||||
final void Function(bool?, bool?) onFavorite;
|
||||
final FavoriteItem favoriteItem;
|
||||
final String? updateTime;
|
||||
final ComicSource comicSource;
|
||||
final bool hasNetwork;
|
||||
final List<String> localFolders;
|
||||
final List<String> added;
|
||||
|
||||
@override
|
||||
State<_FavoriteList> createState() => _FavoriteListState();
|
||||
}
|
||||
|
||||
class _FavoriteListState extends State<_FavoriteList> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localFavoritesFirst = appdata.settings['localFavoritesFirst'] ?? true;
|
||||
|
||||
final localSection = _LocalSection(
|
||||
cid: widget.cid,
|
||||
type: widget.type,
|
||||
favoriteItem: widget.favoriteItem,
|
||||
updateTime: widget.updateTime,
|
||||
localFolders: widget.localFolders,
|
||||
added: widget.added,
|
||||
onFavorite: (local) {
|
||||
widget.onFavorite(local, null);
|
||||
},
|
||||
);
|
||||
|
||||
final networkSection = widget.hasNetwork
|
||||
? _NetworkSection(
|
||||
cid: widget.cid,
|
||||
comicSource: widget.comicSource,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: (network) {
|
||||
widget.onFavorite(null, network);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
final divider = widget.hasNetwork
|
||||
? Container(
|
||||
height: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: context.colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
)
|
||||
: null;
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
if (localFavoritesFirst) ...[
|
||||
localSection,
|
||||
if (widget.hasNetwork) ...[divider!, networkSection!],
|
||||
] else ...[
|
||||
if (widget.hasNetwork) ...[networkSection!, divider!],
|
||||
localSection,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetworkSection extends StatefulWidget {
|
||||
const _NetworkSection({
|
||||
required this.cid,
|
||||
required this.comicSource,
|
||||
required this.isFavorite,
|
||||
@@ -232,82 +156,56 @@ class _NetworkFavorites extends StatefulWidget {
|
||||
});
|
||||
|
||||
final String cid;
|
||||
|
||||
final ComicSource comicSource;
|
||||
|
||||
final bool? isFavorite;
|
||||
|
||||
final void Function(bool) onFavorite;
|
||||
|
||||
@override
|
||||
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
|
||||
State<_NetworkSection> createState() => _NetworkSectionState();
|
||||
}
|
||||
|
||||
class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
|
||||
|
||||
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
|
||||
}
|
||||
|
||||
class _NetworkSectionState extends State<_NetworkSection> {
|
||||
bool isLoading = false;
|
||||
|
||||
Widget buildSingleFolder() {
|
||||
var isFavorite = widget.isFavorite ?? false;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget.comicSource.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
|
||||
if (res.success) {
|
||||
widget.onFavorite(!isFavorite);
|
||||
context.pop();
|
||||
App.rootContext.showMessage(
|
||||
message: isFavorite ? "Removed".tl : "Added".tl);
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
},
|
||||
child: isFavorite ? Text("Remove".tl) : Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String>? folders;
|
||||
|
||||
var addedFolders = <String>{};
|
||||
|
||||
var isLoadingFolders = true;
|
||||
bool? localIsFavorite;
|
||||
final Map<String, bool> _itemLoading = {};
|
||||
late List<double> _skeletonWidths;
|
||||
|
||||
// for network favorites, only one selection is allowed
|
||||
String? selected;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
localIsFavorite = widget.isFavorite;
|
||||
_skeletonWidths = List.generate(3, (_) => 0.3 + math.Random().nextDouble() * 0.5);
|
||||
if (widget.comicSource.favoriteData!.loadFolders != null) {
|
||||
loadFolders();
|
||||
} else {
|
||||
isLoadingFolders = false;
|
||||
}
|
||||
}
|
||||
|
||||
void loadFolders() async {
|
||||
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
|
||||
if (res.error) {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
setState(() {
|
||||
isLoadingFolders = false;
|
||||
});
|
||||
} else {
|
||||
folders = res.data;
|
||||
if (res.subData is List) {
|
||||
addedFolders = List<String>.from(res.subData).toSet();
|
||||
final list = List<String>.from(res.subData);
|
||||
if (list.isNotEmpty) {
|
||||
addedFolders = list.toSet();
|
||||
localIsFavorite = true;
|
||||
} else {
|
||||
addedFolders.clear();
|
||||
localIsFavorite = false;
|
||||
}
|
||||
} else {
|
||||
addedFolders.clear();
|
||||
localIsFavorite = false;
|
||||
}
|
||||
setState(() {
|
||||
isLoadingFolders = false;
|
||||
@@ -315,118 +213,414 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildMultiFolder() {
|
||||
if (widget.isFavorite == true &&
|
||||
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text("Added to favorites".tl),
|
||||
Widget _buildLoadingSkeleton() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget.comicSource.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', false, null);
|
||||
if (res.success) {
|
||||
widget.onFavorite(false);
|
||||
context.pop();
|
||||
App.rootContext.showMessage(message: "Removed".tl);
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
},
|
||||
child: Text("Remove".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (isLoadingFolders) {
|
||||
loadFolders();
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: folders!.length,
|
||||
itemBuilder: (context, index) {
|
||||
var name = folders!.values.elementAt(index);
|
||||
var id = folders!.keys.elementAt(index);
|
||||
return CheckboxListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(name),
|
||||
const SizedBox(width: 8),
|
||||
if (addedFolders.contains(id))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
Shimmer(
|
||||
child: Column(
|
||||
children: List.generate(3, (index) {
|
||||
return ListTile(
|
||||
title: Container(
|
||||
height: 20,
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: _skeletonWidths[index],
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
value: selected == id,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = id;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
trailing: Container(
|
||||
height: 28,
|
||||
width: 60 + (index * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
var res =
|
||||
await widget.comicSource.favoriteData!.addOrDelFavorite!(
|
||||
widget.cid,
|
||||
selected!,
|
||||
!addedFolders.contains(selected!),
|
||||
null,
|
||||
);
|
||||
if (res.success) {
|
||||
context.showMessage(message: "Success".tl);
|
||||
context.pop();
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: selected != null && addedFolders.contains(selected!)
|
||||
? Text("Remove".tl)
|
||||
: Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoadingFolders) {
|
||||
return _buildLoadingSkeleton();
|
||||
}
|
||||
|
||||
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
|
||||
|
||||
if (isMultiFolder) {
|
||||
return _buildMultiFolder();
|
||||
} else {
|
||||
return _buildSingleFolder();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSingleFolder() {
|
||||
var isFavorite = localIsFavorite ?? false;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text("Network Favorites".tl),
|
||||
const SizedBox(width: 8),
|
||||
if (isFavorite)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _HoverButton(
|
||||
isFavorite: isFavorite,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget
|
||||
.comicSource
|
||||
.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
|
||||
if (res.success) {
|
||||
setState(() {
|
||||
localIsFavorite = !isFavorite;
|
||||
});
|
||||
widget.onFavorite(!isFavorite);
|
||||
App.rootContext.showMessage(
|
||||
message: isFavorite ? "Removed".tl : "Added".tl,
|
||||
);
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiFolder() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...folders!.entries.map((entry) {
|
||||
var name = entry.value;
|
||||
var id = entry.key;
|
||||
var isAdded = addedFolders.contains(id);
|
||||
// When `singleFolderForSingleComic` is `false`, all add and remove buttons are clickable.
|
||||
// When `singleFolderForSingleComic` is `true`, the remove button is always clickable,
|
||||
// while the add button is only clickable if the comic has not been added to any list.
|
||||
var enabled = !(widget.comicSource.favoriteData!.singleFolderForSingleComic && addedFolders.isNotEmpty && !isAdded);
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(name),
|
||||
const SizedBox(width: 8),
|
||||
if (isAdded)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: (_itemLoading[id] ?? false)
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _HoverButton(
|
||||
isFavorite: isAdded,
|
||||
enabled: enabled,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_itemLoading[id] = true;
|
||||
});
|
||||
var res = await widget
|
||||
.comicSource
|
||||
.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, id, !isAdded, null);
|
||||
if (res.success) {
|
||||
// Invalidate network cache so folders/pages reload with fresh data
|
||||
NetworkCacheManager().clear();
|
||||
setState(() {
|
||||
if (isAdded) {
|
||||
addedFolders.remove(id);
|
||||
} else {
|
||||
addedFolders.add(id);
|
||||
}
|
||||
// sync local flag for single-folder-per-comic logic and parent
|
||||
localIsFavorite = addedFolders.isNotEmpty;
|
||||
});
|
||||
// notify parent so page state updates when closing and reopening panel
|
||||
widget.onFavorite(addedFolders.isNotEmpty);
|
||||
context.showMessage(message: "Success".tl);
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
_itemLoading[id] = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalSection extends StatefulWidget {
|
||||
const _LocalSection({
|
||||
required this.cid,
|
||||
required this.type,
|
||||
required this.favoriteItem,
|
||||
this.updateTime,
|
||||
required this.localFolders,
|
||||
required this.added,
|
||||
required this.onFavorite,
|
||||
});
|
||||
|
||||
final String cid;
|
||||
final ComicType type;
|
||||
final FavoriteItem favoriteItem;
|
||||
final String? updateTime;
|
||||
final List<String> localFolders;
|
||||
final List<String> added;
|
||||
final void Function(bool) onFavorite;
|
||||
|
||||
@override
|
||||
State<_LocalSection> createState() => _LocalSectionState();
|
||||
}
|
||||
|
||||
class _LocalSectionState extends State<_LocalSection> {
|
||||
late List<String> localFolders;
|
||||
late Set<String> localAdded;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
localFolders = widget.localFolders;
|
||||
localAdded = widget.added.toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Local Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...localFolders.map((folder) {
|
||||
var isAdded = localAdded.contains(folder);
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(folder),
|
||||
const SizedBox(width: 8),
|
||||
if (isAdded)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: _HoverButton(
|
||||
isFavorite: isAdded,
|
||||
onTap: () {
|
||||
if (isAdded) {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
folder,
|
||||
widget.cid,
|
||||
widget.type,
|
||||
);
|
||||
setState(() {
|
||||
localAdded.remove(folder);
|
||||
});
|
||||
widget.onFavorite(false);
|
||||
} else {
|
||||
LocalFavoritesManager().addComic(
|
||||
folder,
|
||||
widget.favoriteItem,
|
||||
null,
|
||||
widget.updateTime,
|
||||
);
|
||||
setState(() {
|
||||
localAdded.add(folder);
|
||||
});
|
||||
widget.onFavorite(true);
|
||||
}
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
// New folder button
|
||||
ListTile(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text("New Folder".tl),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
newFolder().then((v) {
|
||||
setState(() {
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverButton extends StatefulWidget {
|
||||
const _HoverButton({
|
||||
required this.isFavorite,
|
||||
required this.onTap,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final bool isFavorite;
|
||||
final VoidCallback onTap;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<_HoverButton> createState() => _HoverButtonState();
|
||||
}
|
||||
|
||||
class _HoverButtonState extends State<_HoverButton> {
|
||||
bool isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final removeColor = context.colorScheme.error;
|
||||
final removeHoverColor = Color.lerp(removeColor, Colors.black, 0.2)!;
|
||||
final addColor = context.colorScheme.primary;
|
||||
final addHoverColor = Color.lerp(addColor, Colors.black, 0.2)!;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: widget.enabled ? (_) => setState(() => isHovered = true) : null,
|
||||
onExit: widget.enabled ? (_) => setState(() => isHovered = false) : null,
|
||||
child: GestureDetector(
|
||||
onTap: widget.enabled ? widget.onTap : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.enabled
|
||||
? (widget.isFavorite
|
||||
? (isHovered ? removeHoverColor : removeColor)
|
||||
: (isHovered ? addHoverColor : addColor))
|
||||
: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.isFavorite ? "Remove".tl : "Add".tl,
|
||||
style: ts.s12.copyWith(
|
||||
color: widget.enabled
|
||||
? context.colorScheme.onPrimary
|
||||
: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ class ComicSourcePage extends StatelessWidget {
|
||||
try {
|
||||
var res = await AppDio().get<String>(
|
||||
source.url,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {"cache-time": "no"},
|
||||
),
|
||||
);
|
||||
if (cancel) return;
|
||||
controller?.close();
|
||||
@@ -200,10 +203,7 @@ class _BodyState extends State<_Body> {
|
||||
);
|
||||
}
|
||||
|
||||
void update(
|
||||
ComicSource source, [
|
||||
bool showLoading = true,
|
||||
]) {
|
||||
void update(ComicSource source, [bool showLoading = true]) {
|
||||
ComicSourcePage.update(source, showLoading);
|
||||
}
|
||||
|
||||
@@ -304,7 +304,10 @@ class _BodyState extends State<_Body> {
|
||||
try {
|
||||
var res = await AppDio().get<String>(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {"cache-time": "no"},
|
||||
),
|
||||
);
|
||||
if (cancel) return;
|
||||
controller.close();
|
||||
@@ -710,11 +713,13 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilledButton.tonalIcon(
|
||||
icon: isLoading ? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
) : Icon(Icons.update),
|
||||
icon: isLoading
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(Icons.update),
|
||||
label: Text("Check updates".tl),
|
||||
onPressed: check,
|
||||
);
|
||||
@@ -1240,6 +1245,15 @@ class _LoginPageState extends State<_LoginPage> {
|
||||
if (widget.config.checkLoginStatus != null &&
|
||||
widget.config.checkLoginStatus!(url, title)) {
|
||||
var cookies = (await c.getCookies(url)) ?? [];
|
||||
var localStorageItems = await c.webStorage.localStorage.getItems();
|
||||
var mappedLocalStorage = <String, dynamic>{};
|
||||
for (var item in localStorageItems) {
|
||||
if (item.key != null) {
|
||||
mappedLocalStorage[item.key!] = item.value;
|
||||
}
|
||||
}
|
||||
widget.source.data['_localStorage'] = mappedLocalStorage;
|
||||
await widget.source.saveData();
|
||||
SingleInstanceCookieJar.instance?.saveFromResponse(
|
||||
Uri.parse(url),
|
||||
cookies,
|
||||
@@ -1301,6 +1315,20 @@ class _LoginPageState extends State<_LoginPage> {
|
||||
Uri.parse(url),
|
||||
cookies,
|
||||
);
|
||||
var localStorageJson = await webview.evaluateJavascript(
|
||||
"JSON.stringify(window.localStorage);",
|
||||
);
|
||||
var localStorage = <String, dynamic>{};
|
||||
try {
|
||||
var decoded = jsonDecode(localStorageJson ?? '');
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
localStorage = decoded;
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error("ComicSourcePage", "Failed to parse localStorage JSON\n$e");
|
||||
}
|
||||
widget.source.data['_localStorage'] = localStorage;
|
||||
await widget.source.saveData();
|
||||
success = true;
|
||||
widget.config.onLoginWithWebviewSuccess?.call();
|
||||
webview.close();
|
||||
|
||||
@@ -11,10 +11,12 @@ 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/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/network/cache.dart';
|
||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
|
||||
@@ -30,6 +30,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
late List<String> added = [];
|
||||
|
||||
String keyword = "";
|
||||
bool searchHasUpper = false;
|
||||
|
||||
bool searchMode = false;
|
||||
|
||||
@@ -43,6 +44,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
|
||||
bool isLoading = false;
|
||||
|
||||
late String readFilterSelect;
|
||||
|
||||
var searchResults = <FavoriteItem>[];
|
||||
|
||||
void updateSearchResult() {
|
||||
@@ -104,27 +107,40 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
List<FavoriteItem> filterComics(List<FavoriteItem> curComics) {
|
||||
return curComics.where((comic) {
|
||||
var history =
|
||||
HistoryManager().find(comic.id, ComicType(comic.sourceKey.hashCode));
|
||||
if (readFilterSelect == "UnCompleted") {
|
||||
return history == null || history.page != history.maxPage;
|
||||
} else if (readFilterSelect == "Completed") {
|
||||
return history != null && history.page == history.maxPage;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
bool matchKeyword(String keyword, FavoriteItem comic) {
|
||||
var list = keyword.split(" ");
|
||||
for (var k in list) {
|
||||
if (k.isEmpty) continue;
|
||||
if (comic.title.contains(k)) {
|
||||
if (checkKeyWordMatch(k, comic.title, false)) {
|
||||
continue;
|
||||
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) {
|
||||
} else if (comic.subtitle != null && checkKeyWordMatch(k, comic.subtitle!, false)) {
|
||||
continue;
|
||||
} else if (comic.tags.any((tag) {
|
||||
if (tag == k) {
|
||||
if (checkKeyWordMatch(k, tag, true)) {
|
||||
return true;
|
||||
} else if (tag.contains(':') && tag.split(':')[1] == k) {
|
||||
} else if (tag.contains(':') && checkKeyWordMatch(k, tag.split(':')[1], true)) {
|
||||
return true;
|
||||
} else if (App.locale.languageCode != 'en' &&
|
||||
tag.translateTagsToCN == k) {
|
||||
checkKeyWordMatch(k, tag.translateTagsToCN, true)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})) {
|
||||
continue;
|
||||
} else if (comic.author == k) {
|
||||
} else if (checkKeyWordMatch(k, comic.author, true)) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
@@ -132,6 +148,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checkKeyWordMatch(String keyword, String compare, bool needEqual) {
|
||||
String temp = compare;
|
||||
// 没有大写的话, 就转成小写比较, 避免搜索需要注意大小写
|
||||
if (!searchHasUpper) {
|
||||
temp = temp.toLowerCase();
|
||||
}
|
||||
if (needEqual) {
|
||||
return keyword == temp;
|
||||
}
|
||||
return temp.contains(keyword);
|
||||
}
|
||||
// Convert keyword to traditional Chinese to match comics
|
||||
bool matchKeywordT(String keyword, FavoriteItem comic) {
|
||||
if (!OpenCC.hasChineseSimplified(keyword)) {
|
||||
@@ -149,9 +176,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
keyword = OpenCC.traditionalToSimplified(keyword);
|
||||
return matchKeyword(keyword, comic);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
readFilterSelect = appdata.implicitData["local_favorites_read_filter"] ??
|
||||
readFilterList[0];
|
||||
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
|
||||
if (!isAllFolder) {
|
||||
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
||||
@@ -320,6 +348,31 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Filter".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.sort_rounded),
|
||||
color: readFilterSelect != readFilterList[0]
|
||||
? context.colorScheme.primaryContainer
|
||||
: null,
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _LocalFavoritesFilterDialog(
|
||||
initReadFilterSelect: readFilterSelect,
|
||||
updateConfig: (readFilter) {
|
||||
setState(() {
|
||||
readFilterSelect = readFilter;
|
||||
});
|
||||
updateComics();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Search".tl,
|
||||
child: IconButton(
|
||||
@@ -454,15 +507,15 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
actions: [
|
||||
MenuButton(entries: [
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.drive_file_move,
|
||||
text: "Move to folder".tl,
|
||||
onClick: () => favoriteOption('move')),
|
||||
MenuEntry(
|
||||
icon: Icons.drive_file_move,
|
||||
text: "Move to folder".tl,
|
||||
onClick: () => favoriteOption('move')),
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
text: "Copy to folder".tl,
|
||||
onClick: () => favoriteOption('add')),
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
text: "Copy to folder".tl,
|
||||
onClick: () => favoriteOption('add')),
|
||||
MenuEntry(
|
||||
icon: Icons.select_all,
|
||||
text: "Select All".tl,
|
||||
@@ -512,6 +565,32 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
MenuEntry(
|
||||
icon: Icons.chrome_reader_mode_outlined,
|
||||
text: "Read".tl,
|
||||
onClick: () {
|
||||
final c = selectedComics.keys.first as FavoriteItem;
|
||||
App.rootContext.to(() => ReaderWithLoading(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
MenuEntry(
|
||||
icon: Icons.arrow_forward_ios,
|
||||
text: "Jump to Detail".tl,
|
||||
onClick: () {
|
||||
final c = selectedComics.keys.first as FavoriteItem;
|
||||
App.mainNavigatorKey?.currentContext?.to(() => ComicPage(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
)
|
||||
@@ -541,6 +620,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
),
|
||||
onChanged: (v) {
|
||||
keyword = v;
|
||||
searchHasUpper = keyword.contains(RegExp(r'[A-Z]'));
|
||||
updateSearchResult();
|
||||
},
|
||||
).paddingBottom(8).paddingRight(8),
|
||||
@@ -556,7 +636,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
)
|
||||
else
|
||||
SliverGridComics(
|
||||
comics: searchMode ? searchResults : comics,
|
||||
comics: searchMode ? searchResults : filterComics(comics),
|
||||
selections: selectedComics,
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
@@ -609,13 +689,13 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
() => ReaderWithLoading(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
),
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
onTap: (c) {
|
||||
onTap: (c, heroID) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as FavoriteItem)) {
|
||||
@@ -627,18 +707,22 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
lastSelectedIndex = comics.indexOf(c);
|
||||
});
|
||||
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
|
||||
App.mainNavigatorKey?.currentContext
|
||||
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
|
||||
} else {
|
||||
App.mainNavigatorKey?.currentContext?.to(
|
||||
() => ReaderWithLoading(
|
||||
() => ComicPage(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
),
|
||||
cover: c.cover,
|
||||
title: c.title,
|
||||
heroID: heroID,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
App.mainNavigatorKey?.currentContext?.to(
|
||||
() => ReaderWithLoading(id: c.id, sourceKey: c.sourceKey),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPressed: (c) {
|
||||
onLongPressed: (c, heroID) {
|
||||
setState(() {
|
||||
if (!multiSelectMode) {
|
||||
multiSelectMode = true;
|
||||
@@ -1063,3 +1147,78 @@ class _SelectUpdatePageNumState extends State<_SelectUpdatePageNum> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalFavoritesFilterDialog extends StatefulWidget {
|
||||
const _LocalFavoritesFilterDialog({
|
||||
required this.initReadFilterSelect,
|
||||
required this.updateConfig,
|
||||
});
|
||||
|
||||
final String initReadFilterSelect;
|
||||
final Function updateConfig;
|
||||
|
||||
@override
|
||||
State<_LocalFavoritesFilterDialog> createState() =>
|
||||
_LocalFavoritesFilterDialogState();
|
||||
}
|
||||
|
||||
const readFilterList = ['All', 'UnCompleted', 'Completed'];
|
||||
|
||||
class _LocalFavoritesFilterDialogState
|
||||
extends State<_LocalFavoritesFilterDialog> {
|
||||
List<String> optionTypes = ['Filter'];
|
||||
late var readFilter = widget.initReadFilterSelect;
|
||||
@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: [
|
||||
ListTile(
|
||||
title: Text("Filter reading status".tl),
|
||||
trailing: Select(
|
||||
current: readFilter.tl,
|
||||
values: readFilterList.map((e) => e.tl).toList(),
|
||||
minWidth: 64,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
readFilter = readFilterList[index];
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
appdata.implicitData["local_favorites_read_filter"] = readFilter;
|
||||
appdata.writeImplicitData();
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
widget.updateConfig(readFilter);
|
||||
}
|
||||
},
|
||||
child: Text("Confirm".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ Future<bool> _deleteComic(
|
||||
favId,
|
||||
);
|
||||
if (res.success) {
|
||||
// Invalidate network cache so next loads fetch fresh data
|
||||
NetworkCacheManager().clear();
|
||||
context.showMessage(message: "Deleted".tl);
|
||||
result = true;
|
||||
context.pop();
|
||||
@@ -115,6 +117,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
// Force refresh bypassing cache
|
||||
NetworkCacheManager().clear();
|
||||
comicListKey.currentState!.refresh();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -299,6 +299,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
);
|
||||
}
|
||||
updateFollowUpdatesUI();
|
||||
appdata.saveData();
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -211,7 +211,7 @@ class _HistoryPageState extends State<HistoryPage> {
|
||||
selections: selectedComics,
|
||||
onLongPressed: null,
|
||||
onTap: multiSelectMode
|
||||
? (c) {
|
||||
? (c, heroID) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as History)) {
|
||||
selectedComics.remove(c);
|
||||
|
||||
@@ -302,13 +302,18 @@ class _HistoryState extends State<_History> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: history.length,
|
||||
itemBuilder: (context, index) {
|
||||
final heroID = history[index].id.hashCode;
|
||||
return SimpleComicTile(
|
||||
comic: history[index],
|
||||
heroID: heroID,
|
||||
onTap: () {
|
||||
context.to(
|
||||
() => ComicPage(
|
||||
id: history[index].id,
|
||||
sourceKey: history[index].type.sourceKey,
|
||||
cover: history[index].cover,
|
||||
title: history[index].title,
|
||||
heroID: heroID,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -386,7 +391,9 @@ class _LocalState extends State<_Local> {
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -405,9 +412,22 @@ class _LocalState extends State<_Local> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: local.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SimpleComicTile(comic: local[index])
|
||||
.paddingHorizontal(8)
|
||||
.paddingVertical(2);
|
||||
final heroID = local[index].id.hashCode;
|
||||
return SimpleComicTile(
|
||||
comic: local[index],
|
||||
heroID: heroID,
|
||||
onTap: () {
|
||||
context.to(
|
||||
() => ComicPage(
|
||||
id: local[index].id,
|
||||
sourceKey: local[index].sourceKey,
|
||||
cover: local[index].cover,
|
||||
title: local[index].title,
|
||||
heroID: heroID,
|
||||
),
|
||||
);
|
||||
},
|
||||
).paddingHorizontal(8).paddingVertical(2);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
@@ -514,51 +534,53 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
...List.generate(importMethods.length, (index) {
|
||||
return RadioListTile(
|
||||
title: Text(importMethods[index]),
|
||||
value: index,
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value as int;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
if (type != 4)
|
||||
ListTile(
|
||||
title: Text("Add to favorites".tl),
|
||||
trailing: Select(
|
||||
current: selectedFolder,
|
||||
values: folders,
|
||||
minWidth: 112,
|
||||
onTap: (v) {
|
||||
setState(() {
|
||||
selectedFolder = folders[v];
|
||||
});
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
||||
CheckboxListTile(
|
||||
enabled: true,
|
||||
title: Text("Copy to app local path".tl),
|
||||
value: copyToLocalFolder,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
copyToLocalFolder = !copyToLocalFolder;
|
||||
});
|
||||
}).paddingHorizontal(8),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
: RadioGroup<int>(
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value ?? type;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
...List.generate(importMethods.length, (index) {
|
||||
return RadioListTile<int>(
|
||||
title: Text(importMethods[index]),
|
||||
value: index,
|
||||
);
|
||||
}),
|
||||
if (type != 4)
|
||||
ListTile(
|
||||
title: Text("Add to favorites".tl),
|
||||
trailing: Select(
|
||||
current: selectedFolder,
|
||||
values: folders,
|
||||
minWidth: 112,
|
||||
onTap: (v) {
|
||||
setState(() {
|
||||
selectedFolder = folders[v];
|
||||
});
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
||||
CheckboxListTile(
|
||||
enabled: true,
|
||||
title: Text("Copy to app local path".tl),
|
||||
value: copyToLocalFolder,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
copyToLocalFolder = !copyToLocalFolder;
|
||||
});
|
||||
}).paddingHorizontal(8),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
child: Row(
|
||||
@@ -872,7 +894,9 @@ class _ImageFavoritesState extends State<ImageFavorites> {
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
context.to(() => const ImageFavoritesPage());
|
||||
context.to(
|
||||
() => const ImageFavoritesPage()
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -991,7 +1015,9 @@ class _ImageFavoritesState extends State<ImageFavorites> {
|
||||
maxCount: maxCount,
|
||||
enableTranslation: displayType != 2,
|
||||
onTap: (text) {
|
||||
context.to(() => ImageFavoritesPage(initialKeyword: text));
|
||||
context.to(
|
||||
() => ImageFavoritesPage(initialKeyword: text),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
@@ -404,21 +404,23 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
|
||||
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(),
|
||||
RadioGroup<ImageFavoriteSortType>(
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v ?? sortType;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: ImageFavoriteSortType.values
|
||||
.map(
|
||||
(e) => RadioListTile<ImageFavoriteSortType>(
|
||||
title: Text(e.value.tl),
|
||||
value: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
|
||||
@@ -243,7 +243,7 @@ class _ImageFavoritesPhotoViewState extends State<ImageFavoritesPhotoView> {
|
||||
sourceKey: comic.sourceKey,
|
||||
initialEp: ep,
|
||||
initialPage: page,
|
||||
),
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -70,39 +70,29 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Sort".tl,
|
||||
content: Column(
|
||||
children: [
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Name".tl),
|
||||
value: LocalSortType.name,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date".tl),
|
||||
value: LocalSortType.timeAsc,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date Desc".tl),
|
||||
value: LocalSortType.timeDesc,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
content: RadioGroup<LocalSortType>(
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v ?? sortType;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Name".tl),
|
||||
value: LocalSortType.name,
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date".tl),
|
||||
value: LocalSortType.timeAsc,
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date Desc".tl),
|
||||
value: LocalSortType.timeDesc,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
@@ -268,40 +258,52 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
else if (searchMode)
|
||||
SliverAppbar(
|
||||
leading: Tooltip(
|
||||
message: "Cancel".tl,
|
||||
message: multiSelectMode ? "Cancel".tl : "Cancel".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
icon: multiSelectMode
|
||||
? const Icon(Icons.close)
|
||||
: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
keyword = "";
|
||||
update();
|
||||
});
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
keyword = "";
|
||||
update();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
title: TextField(
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search".tl,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (v) {
|
||||
keyword = v;
|
||||
update();
|
||||
},
|
||||
),
|
||||
title: multiSelectMode
|
||||
? Text(selectedComics.length.toString())
|
||||
: TextField(
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search".tl,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (v) {
|
||||
keyword = v;
|
||||
update();
|
||||
},
|
||||
),
|
||||
actions: multiSelectMode ? selectActions : null,
|
||||
),
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
selections: selectedComics,
|
||||
onLongPressed: (c) {
|
||||
onLongPressed: (c, heroID) {
|
||||
setState(() {
|
||||
multiSelectMode = true;
|
||||
selectedComics[c as LocalComic] = true;
|
||||
});
|
||||
},
|
||||
onTap: (c) {
|
||||
onTap: (c, heroID) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as LocalComic)) {
|
||||
@@ -354,6 +356,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
return PopScope(
|
||||
canPop: !multiSelectMode && !searchMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) return;
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
|
||||
573
lib/pages/reader/chapter_comments.dart
Normal file
573
lib/pages/reader/chapter_comments.dart
Normal file
@@ -0,0 +1,573 @@
|
||||
part of 'reader.dart';
|
||||
|
||||
class ChapterCommentsPage extends StatefulWidget {
|
||||
const ChapterCommentsPage({
|
||||
super.key,
|
||||
required this.comicId,
|
||||
required this.epId,
|
||||
required this.source,
|
||||
required this.comicTitle,
|
||||
required this.chapterTitle,
|
||||
this.replyComment,
|
||||
});
|
||||
|
||||
final String comicId;
|
||||
final String epId;
|
||||
final ComicSource source;
|
||||
final String comicTitle;
|
||||
final String chapterTitle;
|
||||
final Comment? replyComment;
|
||||
|
||||
@override
|
||||
State<ChapterCommentsPage> createState() => _ChapterCommentsPageState();
|
||||
}
|
||||
|
||||
class _ChapterCommentsPageState extends State<ChapterCommentsPage> {
|
||||
bool _loading = true;
|
||||
List<Comment>? _comments;
|
||||
String? _error;
|
||||
int _page = 1;
|
||||
int? maxPage;
|
||||
var controller = TextEditingController();
|
||||
bool sending = false;
|
||||
|
||||
void firstLoad() async {
|
||||
var res = await widget.source.chapterCommentsLoader!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
1,
|
||||
widget.replyComment?.id,
|
||||
);
|
||||
if (res.error) {
|
||||
setState(() {
|
||||
_error = res.errorMessage;
|
||||
_loading = false;
|
||||
});
|
||||
} else if (mounted) {
|
||||
setState(() {
|
||||
_comments = res.data;
|
||||
_loading = false;
|
||||
maxPage = res.subData;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loadMore() async {
|
||||
var res = await widget.source.chapterCommentsLoader!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
_page + 1,
|
||||
widget.replyComment?.id,
|
||||
);
|
||||
if (res.error) {
|
||||
context.showMessage(message: res.errorMessage ?? "Unknown Error");
|
||||
} else {
|
||||
setState(() {
|
||||
_comments!.addAll(res.data);
|
||||
_page++;
|
||||
if (maxPage == null && res.data.isEmpty) {
|
||||
maxPage = _page;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: Appbar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Chapter Comments".tl, style: ts.s18),
|
||||
Text(widget.chapterTitle, style: ts.s12),
|
||||
],
|
||||
),
|
||||
style: AppbarStyle.shadow,
|
||||
),
|
||||
body: buildBody(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBody(BuildContext context) {
|
||||
if (_loading) {
|
||||
firstLoad();
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (_error != null) {
|
||||
return NetworkError(
|
||||
message: _error!,
|
||||
retry: () {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
});
|
||||
},
|
||||
withAppbar: false,
|
||||
);
|
||||
} else {
|
||||
var showAvatar = _comments!.any((e) {
|
||||
return e.avatar != null;
|
||||
});
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SmoothScrollProvider(
|
||||
builder: (context, controller, physics) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
primary: false,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _comments!.length + 2,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
if (widget.replyComment != null) {
|
||||
return Column(
|
||||
children: [
|
||||
_ChapterCommentTile(
|
||||
comment: widget.replyComment!,
|
||||
source: widget.source,
|
||||
comicId: widget.comicId,
|
||||
epId: widget.epId,
|
||||
showAvatar: showAvatar,
|
||||
showActions: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text("Replies".tl, style: ts.s18),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
index--;
|
||||
|
||||
if (index == _comments!.length) {
|
||||
if (_page < (maxPage ?? _page + 1)) {
|
||||
loadMore();
|
||||
return const ListLoadingIndicator();
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
return _ChapterCommentTile(
|
||||
comment: _comments![index],
|
||||
source: widget.source,
|
||||
comicId: widget.comicId,
|
||||
epId: widget.epId,
|
||||
showAvatar: showAvatar,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
buildBottom(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildBottom(BuildContext context) {
|
||||
if (widget.source.sendChapterCommentFunc == null) {
|
||||
return const SizedBox(height: 0);
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
hintText: "Comment".tl,
|
||||
),
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
),
|
||||
),
|
||||
if (sending)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
if (controller.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
sending = true;
|
||||
});
|
||||
var b = await widget.source.sendChapterCommentFunc!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
controller.text,
|
||||
widget.replyComment?.id,
|
||||
);
|
||||
if (!b.error) {
|
||||
controller.text = "";
|
||||
setState(() {
|
||||
sending = false;
|
||||
_loading = true;
|
||||
_comments?.clear();
|
||||
_page = 1;
|
||||
maxPage = null;
|
||||
});
|
||||
} else {
|
||||
context.showMessage(message: b.errorMessage ?? "Error");
|
||||
setState(() {
|
||||
sending = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.send,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingLeft(16).paddingRight(4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChapterCommentTile extends StatefulWidget {
|
||||
const _ChapterCommentTile({
|
||||
required this.comment,
|
||||
required this.source,
|
||||
required this.comicId,
|
||||
required this.epId,
|
||||
required this.showAvatar,
|
||||
this.showActions = true,
|
||||
});
|
||||
|
||||
final Comment comment;
|
||||
final ComicSource source;
|
||||
final String comicId;
|
||||
final String epId;
|
||||
final bool showAvatar;
|
||||
final bool showActions;
|
||||
|
||||
@override
|
||||
State<_ChapterCommentTile> createState() => _ChapterCommentTileState();
|
||||
}
|
||||
|
||||
class _ChapterCommentTileState extends State<_ChapterCommentTile> {
|
||||
@override
|
||||
void initState() {
|
||||
likes = widget.comment.score ?? 0;
|
||||
isLiked = widget.comment.isLiked ?? false;
|
||||
voteStatus = widget.comment.voteStatus;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.showAvatar)
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
child: widget.comment.avatar == null
|
||||
? null
|
||||
: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
widget.comment.avatar!,
|
||||
sourceKey: widget.source.key,
|
||||
),
|
||||
),
|
||||
).paddingRight(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.comment.userName, style: ts.bold),
|
||||
if (widget.comment.time != null)
|
||||
Text(widget.comment.time!, style: ts.s12),
|
||||
const SizedBox(height: 4),
|
||||
_CommentContent(text: widget.comment.content),
|
||||
buildActions(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildActions() {
|
||||
if (!widget.showActions) {
|
||||
return const SizedBox();
|
||||
}
|
||||
if (widget.comment.score == null && widget.comment.replyCount == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (widget.comment.score != null &&
|
||||
widget.source.voteCommentFunc != null)
|
||||
buildVote(),
|
||||
if (widget.comment.score != null &&
|
||||
widget.source.likeCommentFunc != null)
|
||||
buildLike(),
|
||||
// Only show reply button if comment has both id and replyCount
|
||||
if (widget.comment.replyCount != null && widget.comment.id != null)
|
||||
buildReply(),
|
||||
],
|
||||
),
|
||||
).paddingTop(8);
|
||||
}
|
||||
|
||||
Widget buildReply() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
// Get the parent page's widget to access comicTitle and chapterTitle
|
||||
var parentState = context.findAncestorStateOfType<_ChapterCommentsPageState>();
|
||||
showSideBar(
|
||||
context,
|
||||
ChapterCommentsPage(
|
||||
comicId: widget.comicId,
|
||||
epId: widget.epId,
|
||||
source: widget.source,
|
||||
comicTitle: parentState?.widget.comicTitle ?? '',
|
||||
chapterTitle: parentState?.widget.chapterTitle ?? '',
|
||||
replyComment: widget.comment,
|
||||
),
|
||||
showBarrier: false,
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.insert_comment_outlined, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(widget.comment.replyCount.toString()),
|
||||
],
|
||||
).padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 4)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool isLiking = false;
|
||||
bool isLiked = false;
|
||||
var likes = 0;
|
||||
|
||||
Widget buildLike() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () async {
|
||||
if (isLiking) return;
|
||||
setState(() {
|
||||
isLiking = true;
|
||||
});
|
||||
var res = await widget.source.likeCommentFunc!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
widget.comment.id!,
|
||||
!isLiked,
|
||||
);
|
||||
if (res.success) {
|
||||
isLiked = !isLiked;
|
||||
likes += isLiked ? 1 : -1;
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage ?? "Error");
|
||||
}
|
||||
setState(() {
|
||||
isLiking = false;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLiking)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
else if (isLiked)
|
||||
Icon(
|
||||
Icons.favorite,
|
||||
size: 16,
|
||||
color: context.useTextColor(Colors.red),
|
||||
)
|
||||
else
|
||||
const Icon(Icons.favorite_border, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(likes.toString()),
|
||||
],
|
||||
).padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 4)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int? voteStatus;
|
||||
bool isVotingUp = false;
|
||||
bool isVotingDown = false;
|
||||
|
||||
void vote(bool isUp) async {
|
||||
if (isVotingUp || isVotingDown) return;
|
||||
setState(() {
|
||||
if (isUp) {
|
||||
isVotingUp = true;
|
||||
} else {
|
||||
isVotingDown = true;
|
||||
}
|
||||
});
|
||||
var isCancel = (isUp && voteStatus == 1) || (!isUp && voteStatus == -1);
|
||||
var res = await widget.source.voteCommentFunc!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
widget.comment.id!,
|
||||
isUp,
|
||||
isCancel,
|
||||
);
|
||||
if (res.success) {
|
||||
if (isCancel) {
|
||||
voteStatus = 0;
|
||||
} else {
|
||||
if (isUp) {
|
||||
voteStatus = 1;
|
||||
} else {
|
||||
voteStatus = -1;
|
||||
}
|
||||
}
|
||||
widget.comment.voteStatus = voteStatus;
|
||||
widget.comment.score = res.data ?? widget.comment.score;
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage ?? "Error");
|
||||
}
|
||||
setState(() {
|
||||
isVotingUp = false;
|
||||
isVotingDown = false;
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildVote() {
|
||||
var upColor = context.colorScheme.outline;
|
||||
if (voteStatus == 1) {
|
||||
upColor = context.useTextColor(Colors.red);
|
||||
}
|
||||
var downColor = context.colorScheme.outline;
|
||||
if (voteStatus == -1) {
|
||||
downColor = context.useTextColor(Colors.blue);
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Button.icon(
|
||||
isLoading: isVotingUp,
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
size: 18,
|
||||
color: upColor,
|
||||
onPressed: () => vote(true),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(widget.comment.score.toString()),
|
||||
const SizedBox(width: 4),
|
||||
Button.icon(
|
||||
isLoading: isVotingDown,
|
||||
icon: const Icon(Icons.arrow_downward),
|
||||
size: 18,
|
||||
color: downColor,
|
||||
onPressed: () => vote(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentContent extends StatelessWidget {
|
||||
const _CommentContent({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!text.contains('<') && !text.contains('http')) {
|
||||
return SelectableText(text);
|
||||
} else {
|
||||
// Use the RichCommentContent from comments_page.dart
|
||||
// For simplicity, we'll just show plain text here
|
||||
// In a real implementation, you'd need to import or duplicate the RichCommentContent class
|
||||
return SelectableText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,12 +140,12 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
int get totalPages {
|
||||
if (!reader.showSingleImageOnFirstPage()) {
|
||||
return (reader.images!.length /
|
||||
reader.imagesPerPage())
|
||||
reader.imagesPerPage)
|
||||
.ceil();
|
||||
} else {
|
||||
return 1 +
|
||||
((reader.images!.length - 1) /
|
||||
reader.imagesPerPage())
|
||||
reader.imagesPerPage)
|
||||
.ceil();
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
/// Get the range of images for the given page. [page] is 1-based.
|
||||
(int start, int end) getPageImagesRange(int page) {
|
||||
var imagesPerPage = reader.imagesPerPage();
|
||||
var imagesPerPage = reader.imagesPerPage;
|
||||
if (reader.showSingleImageOnFirstPage()) {
|
||||
if (page == 1) {
|
||||
return (0, 1);
|
||||
@@ -191,6 +191,16 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the image indices for current page. Returns null if no images.
|
||||
/// Returns a single index if only one image, or a range if multiple images.
|
||||
(int, int)? getCurrentPageImageRange() {
|
||||
if (reader.images == null || reader.images!.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
var (startIndex, endIndex) = getPageImagesRange(reader.page);
|
||||
return (startIndex, endIndex);
|
||||
}
|
||||
|
||||
/// [cache] is used to cache the images.
|
||||
/// The count of images to cache is determined by the [preCacheCount] setting.
|
||||
/// For previous page and next page, it will do a memory cache.
|
||||
@@ -259,7 +269,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
photoViewControllers[index] ??= PhotoViewController();
|
||||
|
||||
if (reader.imagesPerPage() == 1 ||
|
||||
if (reader.imagesPerPage == 1 ||
|
||||
pageImages.length == 1) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
filterQuality: FilterQuality.medium,
|
||||
@@ -276,8 +286,9 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
);
|
||||
}
|
||||
|
||||
final viewportSize = MediaQuery.of(context).size;
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
childSize: reader.size * 2,
|
||||
childSize: viewportSize,
|
||||
controller: photoViewControllers[index],
|
||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||
maxScale: PhotoViewComputedScale.covered * 10.0,
|
||||
@@ -533,17 +544,27 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
@override
|
||||
String? getImageKeyByOffset(Offset offset) {
|
||||
String? imageKey;
|
||||
if (reader.imagesPerPage() == 1) {
|
||||
imageKey = reader.images![reader.page - 1];
|
||||
} else {
|
||||
for (var imageState in imageStates) {
|
||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||
var range = getCurrentPageImageRange();
|
||||
if (range == null) return null;
|
||||
|
||||
var (startIndex, endIndex) = range;
|
||||
int actualImageCount = endIndex - startIndex;
|
||||
|
||||
if (actualImageCount == 1) {
|
||||
return reader.images![startIndex];
|
||||
}
|
||||
|
||||
for (var imageState in imageStates) {
|
||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||
var imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||
int index = reader.images!.indexOf(imageKey);
|
||||
if (index >= startIndex && index < endIndex) {
|
||||
return imageKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageKey;
|
||||
|
||||
return reader.images![startIndex];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/global_state.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/image_provider/reader_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
@@ -54,6 +55,8 @@ part 'loading.dart';
|
||||
|
||||
part 'chapters.dart';
|
||||
|
||||
part 'chapter_comments.dart';
|
||||
|
||||
extension _ReaderContext on BuildContext {
|
||||
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
|
||||
|
||||
@@ -116,9 +119,9 @@ class _ReaderState extends State<Reader>
|
||||
return 1;
|
||||
}
|
||||
if (!showSingleImageOnFirstPage()) {
|
||||
return (images!.length / imagesPerPage()).ceil();
|
||||
return (images!.length / imagesPerPage).ceil();
|
||||
} else {
|
||||
return 1 + ((images!.length - 1) / imagesPerPage()).ceil();
|
||||
return 1 + ((images!.length - 1) / imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,14 +166,27 @@ class _ReaderState extends State<Reader>
|
||||
}
|
||||
if (widget.initialPage != null) {
|
||||
page = widget.initialPage!;
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
}
|
||||
// mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||
mode = ReaderMode.fromKey(appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'));
|
||||
mode = ReaderMode.fromKey(
|
||||
appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'),
|
||||
);
|
||||
history = widget.history;
|
||||
if (!appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSystemStatusBar')) {
|
||||
if (!appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'showSystemStatusBar',
|
||||
)) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) {
|
||||
if (appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'enableTurnPageByVolumeKey',
|
||||
)) {
|
||||
handleVolumeEvent();
|
||||
}
|
||||
setImageCacheSize();
|
||||
@@ -208,8 +224,10 @@ class _ReaderState extends State<Reader>
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -239,13 +257,15 @@ class _ReaderState extends State<Reader>
|
||||
onKeyEvent: onKeyEvent,
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return _ReaderScaffold(
|
||||
child: _ReaderGestureDetector(
|
||||
child: _ReaderImages(key: Key(chapter.toString())),
|
||||
),
|
||||
);
|
||||
})
|
||||
OverlayEntry(
|
||||
builder: (context) {
|
||||
return _ReaderScaffold(
|
||||
child: _ReaderGestureDetector(
|
||||
child: _ReaderImages(key: Key(chapter.toString())),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -277,13 +297,13 @@ class _ReaderState extends State<Reader>
|
||||
history!.page = images?.length ?? 1;
|
||||
} else {
|
||||
/// Record the first image of the page
|
||||
if (!showSingleImageOnFirstPage() || imagesPerPage() == 1) {
|
||||
history!.page = (page - 1) * imagesPerPage() + 1;
|
||||
if (!showSingleImageOnFirstPage() || imagesPerPage == 1) {
|
||||
history!.page = (page - 1) * imagesPerPage + 1;
|
||||
} else {
|
||||
if (page == 1) {
|
||||
history!.page = 1;
|
||||
} else {
|
||||
history!.page = (page - 2) * imagesPerPage() + 2;
|
||||
history!.page = (page - 2) * imagesPerPage + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,44 +391,64 @@ abstract mixin class _ImagePerPageHandler {
|
||||
ComicType get type;
|
||||
|
||||
void initImagesPerPage(int initialPage) {
|
||||
_lastImagesPerPage = imagesPerPage();
|
||||
_lastImagesPerPage = imagesPerPage;
|
||||
_lastOrientation = isPortrait;
|
||||
if (imagesPerPage() != 1) {
|
||||
if (imagesPerPage != 1) {
|
||||
if (showSingleImageOnFirstPage()) {
|
||||
page = ((initialPage - 1) / imagesPerPage()).ceil() + 1;
|
||||
page = ((initialPage - 1) / imagesPerPage).ceil() + 1;
|
||||
} else {
|
||||
page = (initialPage / imagesPerPage()).ceil();
|
||||
page = (initialPage / imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool showSingleImageOnFirstPage() =>
|
||||
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
|
||||
bool showSingleImageOnFirstPage() => appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'showSingleImageOnFirstPage',
|
||||
);
|
||||
|
||||
/// The number of images displayed on one screen
|
||||
int imagesPerPage() {
|
||||
int get imagesPerPage {
|
||||
if (mode.isContinuous) return 1;
|
||||
if (isPortrait) {
|
||||
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
|
||||
return appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'readerScreenPicNumberForPortrait',
|
||||
) ??
|
||||
1;
|
||||
} else {
|
||||
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForLandscape') ?? 1;
|
||||
return appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'readerScreenPicNumberForLandscape',
|
||||
) ??
|
||||
1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the number of images per page has changed
|
||||
void _checkImagesPerPageChange() {
|
||||
int currentImagesPerPage = imagesPerPage();
|
||||
int currentImagesPerPage = imagesPerPage;
|
||||
bool currentOrientation = isPortrait;
|
||||
|
||||
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
|
||||
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage);
|
||||
if (_lastImagesPerPage != currentImagesPerPage ||
|
||||
_lastOrientation != currentOrientation) {
|
||||
_adjustPageForImagesPerPageChange(
|
||||
_lastImagesPerPage,
|
||||
currentImagesPerPage,
|
||||
);
|
||||
_lastImagesPerPage = currentImagesPerPage;
|
||||
_lastOrientation = currentOrientation;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust the page number when the number of images per page changes
|
||||
void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) {
|
||||
void _adjustPageForImagesPerPageChange(
|
||||
int oldImagesPerPage,
|
||||
int newImagesPerPage,
|
||||
) {
|
||||
int previousImageIndex = 1;
|
||||
if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) {
|
||||
previousImageIndex = (page - 1) * oldImagesPerPage + 1;
|
||||
@@ -431,7 +471,7 @@ abstract mixin class _ImagePerPageHandler {
|
||||
newPage = previousImageIndex;
|
||||
}
|
||||
|
||||
page = newPage>0 ? newPage : 1;
|
||||
page = newPage > 0 ? newPage : 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,10 +506,7 @@ abstract mixin class _VolumeListener {
|
||||
if (volumeListener != null) {
|
||||
volumeListener?.cancel();
|
||||
}
|
||||
volumeListener = VolumeListener(
|
||||
onDown: onDown,
|
||||
onUp: onUp,
|
||||
)..listen();
|
||||
volumeListener = VolumeListener(onDown: onDown, onUp: onUp)..listen();
|
||||
}
|
||||
|
||||
void stopVolumeEvent() {
|
||||
@@ -504,7 +541,8 @@ abstract mixin class _ReaderLocation {
|
||||
|
||||
void update();
|
||||
|
||||
bool enablePageAnimation(String cid, ComicType type) => appdata.settings.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
|
||||
bool enablePageAnimation(String cid, ComicType type) => appdata.settings
|
||||
.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
|
||||
|
||||
_ImageViewController? _imageViewController;
|
||||
|
||||
@@ -585,7 +623,11 @@ abstract mixin class _ReaderLocation {
|
||||
autoPageTurningTimer!.cancel();
|
||||
autoPageTurningTimer = null;
|
||||
} else {
|
||||
int interval = appdata.settings.getReaderSetting(cid, type.sourceKey, 'autoPageTurningInterval');
|
||||
int interval = appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'autoPageTurningInterval',
|
||||
);
|
||||
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
|
||||
if (page == maxPage) {
|
||||
autoPageTurningTimer!.cancel();
|
||||
|
||||
@@ -166,32 +166,49 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface.toOpacity(0.92),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey.toOpacity(0.5), width: 0.5),
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.toOpacity(0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
const BackButton(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.reader.widget.name,
|
||||
style: ts.s18,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: context.padding.left,
|
||||
right: context.padding.right,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
const BackButton(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.reader.widget.name,
|
||||
style: ts.s18,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: "Settings".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: openSetting,
|
||||
const SizedBox(width: 8),
|
||||
if (shouldShowChapterComments())
|
||||
Tooltip(
|
||||
message: "Chapter Comments".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.comment),
|
||||
onPressed: openChapterComments,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Settings".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: openSetting,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -512,7 +529,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
: null,
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom),
|
||||
child: child,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: context.padding.left,
|
||||
right: context.padding.right,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -599,22 +622,26 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
|
||||
void saveCurrentImage() async {
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
var result = await selectImageToData();
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
var (imageIndex, data) = result;
|
||||
var fileType = detectFileType(data);
|
||||
var filename = "${context.reader.page}${fileType.ext}";
|
||||
var filename =
|
||||
"${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
saveFile(data: data, filename: filename);
|
||||
}
|
||||
|
||||
void share() async {
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
var result = await selectImageToData();
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
var (imageIndex, data) = result;
|
||||
var fileType = detectFileType(data);
|
||||
var filename = "${context.reader.page}${fileType.ext}";
|
||||
var filename =
|
||||
"${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
Share.shareFile(data: data, filename: filename, mime: fileType.mime);
|
||||
}
|
||||
|
||||
@@ -648,6 +675,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
if (key == "quickCollectImage") {
|
||||
addDragListener();
|
||||
}
|
||||
if (key == "showChapterComments") {
|
||||
update();
|
||||
}
|
||||
context.reader.update();
|
||||
},
|
||||
),
|
||||
@@ -655,12 +685,55 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
);
|
||||
}
|
||||
|
||||
bool shouldShowChapterComments() {
|
||||
// Check if chapters exist
|
||||
if (context.reader.widget.chapters == null) return false;
|
||||
|
||||
// Check if setting is enabled
|
||||
var showChapterComments = appdata.settings.getReaderSetting(
|
||||
context.reader.cid,
|
||||
context.reader.type.sourceKey,
|
||||
'showChapterComments',
|
||||
);
|
||||
if (showChapterComments != true) return false;
|
||||
|
||||
// Check if comic source supports chapter comments
|
||||
var source = ComicSource.find(context.reader.type.sourceKey);
|
||||
if (source == null || source.chapterCommentsLoader == null) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void openChapterComments() {
|
||||
var source = ComicSource.find(context.reader.type.sourceKey);
|
||||
if (source == null) return;
|
||||
|
||||
var chapters = context.reader.widget.chapters;
|
||||
if (chapters == null) return;
|
||||
|
||||
var chapterIndex = context.reader.chapter - 1;
|
||||
var epId = chapters.ids.elementAt(chapterIndex);
|
||||
var chapterTitle = chapters.titles.elementAt(chapterIndex);
|
||||
|
||||
showSideBar(
|
||||
context,
|
||||
ChapterCommentsPage(
|
||||
comicId: context.reader.cid,
|
||||
epId: epId,
|
||||
source: source,
|
||||
comicTitle: context.reader.widget.name,
|
||||
chapterTitle: chapterTitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEpChangeButton() {
|
||||
final extraWidth = context.padding.left + context.padding.right;
|
||||
if (context.reader.widget.chapters == null) return const SizedBox();
|
||||
switch (showFloatingButtonValue) {
|
||||
case 0:
|
||||
return Container(
|
||||
width: 58,
|
||||
width: 58 + extraWidth,
|
||||
height: 58,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
@@ -678,7 +751,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
case -1:
|
||||
case 1:
|
||||
return SizedBox(
|
||||
width: 58,
|
||||
width: 58 + extraWidth,
|
||||
height: 58,
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
@@ -719,8 +792,29 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
Future<int?> selectImage() async {
|
||||
var reader = context.reader;
|
||||
var imageViewController = context.reader._imageViewController;
|
||||
if (imageViewController is _GalleryModeState && reader.imagesPerPage == 1) {
|
||||
return reader.page - 1;
|
||||
|
||||
bool needsSelection = false;
|
||||
int? singleImageIndex;
|
||||
|
||||
if (imageViewController is _GalleryModeState) {
|
||||
var range = imageViewController.getCurrentPageImageRange();
|
||||
if (range != null) {
|
||||
var (startIndex, endIndex) = range;
|
||||
int actualImageCount = endIndex - startIndex;
|
||||
if (actualImageCount == 1) {
|
||||
needsSelection = false;
|
||||
singleImageIndex = startIndex;
|
||||
} else {
|
||||
needsSelection = true;
|
||||
}
|
||||
}
|
||||
} else if (imageViewController is _ContinuousModeState) {
|
||||
needsSelection = false;
|
||||
singleImageIndex = reader.page - 1;
|
||||
}
|
||||
|
||||
if (!needsSelection && singleImageIndex != null) {
|
||||
return singleImageIndex;
|
||||
} else {
|
||||
var location = await _showSelectImageOverlay();
|
||||
if (location == null) {
|
||||
@@ -734,20 +828,23 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [selectImage], but return the image data.
|
||||
Future<Uint8List?> selectImageToData() async {
|
||||
/// Same as [selectImage], but return the image data with its index.
|
||||
/// Returns (imageIndex, imageData) or null if cancelled.
|
||||
Future<(int, Uint8List)?> selectImageToData() async {
|
||||
var i = await selectImage();
|
||||
if (i == null) {
|
||||
return null;
|
||||
}
|
||||
var imageKey = context.reader.images![i];
|
||||
Uint8List data;
|
||||
if (imageKey.startsWith("file://")) {
|
||||
return await File(imageKey.substring(7)).readAsBytes();
|
||||
data = await File(imageKey.substring(7)).readAsBytes();
|
||||
} else {
|
||||
return (await CacheManager().findCache(
|
||||
data = await (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||
))!.readAsBytes();
|
||||
}
|
||||
return (i, data);
|
||||
}
|
||||
|
||||
Future<Offset?> _showSelectImageOverlay() {
|
||||
|
||||
@@ -49,7 +49,9 @@ class _SearchPageState extends State<SearchPage> {
|
||||
void search([String? text]) {
|
||||
if (aggregatedSearch) {
|
||||
context
|
||||
.to(() => AggregatedSearchPage(keyword: text ?? controller.text))
|
||||
.to(
|
||||
() => AggregatedSearchPage(keyword: text ?? controller.text)
|
||||
)
|
||||
.then((_) => update());
|
||||
} else {
|
||||
context
|
||||
@@ -58,7 +60,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
text: text ?? controller.text,
|
||||
sourceKey: searchTarget,
|
||||
options: options,
|
||||
),
|
||||
)
|
||||
)
|
||||
.then((_) => update());
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ class _AppSettingsState extends State<AppSettings> {
|
||||
title: "Export App Data".tl,
|
||||
callback: () async {
|
||||
var controller = showLoadingDialog(context);
|
||||
var file = await exportAppData();
|
||||
var file = await exportAppData(false);
|
||||
await saveFile(filename: "data.venera", file: file);
|
||||
controller.close();
|
||||
},
|
||||
@@ -353,6 +353,8 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
String url = "";
|
||||
String user = "";
|
||||
String pass = "";
|
||||
String disableSync = "";
|
||||
|
||||
bool autoSync = true;
|
||||
|
||||
bool isTesting = false;
|
||||
@@ -364,6 +366,9 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
if (appdata.settings['webdav'] is! List) {
|
||||
appdata.settings['webdav'] = [];
|
||||
}
|
||||
if (appdata.settings['disableSyncFields'].trim().isNotEmpty) {
|
||||
disableSync = appdata.settings['disableSyncFields'];
|
||||
}
|
||||
var configs = appdata.settings['webdav'] as List;
|
||||
if (configs.whereType<String>().length != 3) {
|
||||
return;
|
||||
@@ -418,6 +423,56 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
onChanged: (value) => pass = value,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: "Skip Setting Fields (Optional)".tl,
|
||||
hintText: "field0, field1, field2, ...",
|
||||
hintStyle: TextStyle(color: Theme.of(context).hintColor),
|
||||
border: OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(Icons.help_outline),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text("Skip Setting Fields".tl),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"When sync data, skip certain setting fields, which means these won't be uploaded / override.".tl,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
"See source code for available fields.".tl,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
onPressed: () {
|
||||
launchUrlString("https://github.com/venera-app/venera/blob/b08f11f6ac49bd07d34b4fcde233ed07e86efbc9/lib/foundation/appdata.dart#L138");
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
controller: TextEditingController(text: disableSync),
|
||||
onChanged: (value) => disableSync = value,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
leading: Icon(Icons.sync),
|
||||
title: Text("Auto Sync Data".tl),
|
||||
@@ -428,30 +483,26 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text("Operation".tl),
|
||||
Radio<bool>(
|
||||
groupValue: upload,
|
||||
value: true,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text("Upload".tl),
|
||||
Radio<bool>(
|
||||
groupValue: upload,
|
||||
value: false,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text("Download".tl),
|
||||
],
|
||||
RadioGroup<bool>(
|
||||
groupValue: upload,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value ?? upload;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Text("Operation".tl),
|
||||
Radio<bool>(
|
||||
value: true,
|
||||
),
|
||||
Text("Upload".tl),
|
||||
Radio<bool>(
|
||||
value: false,
|
||||
),
|
||||
Text("Download".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSize(
|
||||
@@ -498,6 +549,7 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
}
|
||||
|
||||
appdata.settings['webdav'] = [url, user, pass];
|
||||
appdata.settings['disableSyncFields'] = disableSync;
|
||||
appdata.implicitData['webdavAutoSync'] = autoSync;
|
||||
appdata.writeImplicitData();
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class DebugPageState extends State<DebugPage> {
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
try {
|
||||
var res = JsEngine().runCode(controller.text);
|
||||
var res = JsEngine().runCode(controller.text, "<debug>");
|
||||
setState(() {
|
||||
result = res.toString();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,14 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(title: Text("Local Favorites".tl)),
|
||||
_SwitchSetting(
|
||||
title: "Show local favorites before network favorites".tl,
|
||||
settingKey: "localFavoritesFirst",
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Auto close favorite panel after operation".tl,
|
||||
settingKey: "autoCloseFavoritePanel",
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Add new favorite to".tl,
|
||||
settingKey: "newFavoriteAddTo",
|
||||
|
||||
@@ -111,44 +111,34 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
|
||||
return PopUpWidgetScaffold(
|
||||
title: "Proxy".tl,
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: Text("Direct".tl),
|
||||
value: 'direct',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
},
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: Text("System".tl),
|
||||
value: 'system',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text("Manual".tl),
|
||||
value: 'manual',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (type == 'manual') buildManualProxy(),
|
||||
],
|
||||
child: RadioGroup<String>(
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v ?? type;
|
||||
});
|
||||
if (type != 'manual') {
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: Text("Direct".tl),
|
||||
value: 'direct',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: Text("System".tl),
|
||||
value: 'system',
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text("Manual".tl),
|
||||
value: 'manual',
|
||||
),
|
||||
if (type == 'manual') buildManualProxy(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -303,6 +303,15 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Show Chapter Comments".tl,
|
||||
settingKey: "showChapterComments",
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("showChapterComments");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -385,17 +385,16 @@ class _SliderSettingState extends State<_SliderSetting> {
|
||||
: appdata.settings.getReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingsIndex,
|
||||
))
|
||||
widget.settingsIndex,
|
||||
))
|
||||
.toDouble();
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(widget.title),
|
||||
const Spacer(),
|
||||
Text(value.toString(), style: ts.s12),
|
||||
],
|
||||
title: Text(
|
||||
widget.title,
|
||||
softWrap: true,
|
||||
maxLines: 2,
|
||||
),
|
||||
trailing: Text(value.toString(), style: ts.s12),
|
||||
subtitle: Slider(
|
||||
value: value,
|
||||
onChanged: (value) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
|
||||
@@ -41,7 +40,7 @@ class SettingsPage extends StatefulWidget {
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
int currentPage = -1;
|
||||
|
||||
ColorScheme get colors => Theme.of(context).colorScheme;
|
||||
@@ -70,84 +69,14 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
Icons.bug_report,
|
||||
];
|
||||
|
||||
double offset = 0;
|
||||
|
||||
late final HorizontalDragGestureRecognizer gestureRecognizer;
|
||||
|
||||
ModalRoute? _route;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final ModalRoute<dynamic>? nextRoute = ModalRoute.of(context);
|
||||
if (nextRoute != _route) {
|
||||
_route?.unregisterPopEntry(this);
|
||||
_route = nextRoute;
|
||||
_route?.registerPopEntry(this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
currentPage = widget.initialPage;
|
||||
gestureRecognizer = HorizontalDragGestureRecognizer(debugOwner: this)
|
||||
..onUpdate = ((details) => setState(() => offset += details.delta.dx))
|
||||
..onEnd = (details) async {
|
||||
if (details.velocity.pixelsPerSecond.dx.abs() > 1 &&
|
||||
details.velocity.pixelsPerSecond.dx >= 0) {
|
||||
setState(() {
|
||||
Future.delayed(const Duration(milliseconds: 300), () => offset = 0);
|
||||
currentPage = -1;
|
||||
});
|
||||
} else if (offset > MediaQuery.of(context).size.width / 2) {
|
||||
setState(() {
|
||||
Future.delayed(const Duration(milliseconds: 300), () => offset = 0);
|
||||
currentPage = -1;
|
||||
});
|
||||
} else {
|
||||
int i = 10;
|
||||
while (offset != 0) {
|
||||
setState(() {
|
||||
offset -= i;
|
||||
i *= 10;
|
||||
if (offset < 0) {
|
||||
offset = 0;
|
||||
}
|
||||
});
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
..onCancel = () async {
|
||||
int i = 10;
|
||||
while (offset != 0) {
|
||||
setState(() {
|
||||
offset -= i;
|
||||
i *= 10;
|
||||
if (offset < 0) {
|
||||
offset = 0;
|
||||
}
|
||||
});
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
};
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
dispose() {
|
||||
super.dispose();
|
||||
gestureRecognizer.dispose();
|
||||
_route?.unregisterPopEntry(this);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (currentPage != -1) {
|
||||
canPop.value = false;
|
||||
} else {
|
||||
canPop.value = true;
|
||||
}
|
||||
return Material(
|
||||
child: buildBody(),
|
||||
);
|
||||
@@ -209,51 +138,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: buildLeft()),
|
||||
Positioned(
|
||||
left: offset,
|
||||
width: constrains.maxWidth,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Listener(
|
||||
onPointerDown: handlePointerDown,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.fastOutSlowIn,
|
||||
switchOutCurve: Curves.fastOutSlowIn,
|
||||
transitionBuilder: (child, animation) {
|
||||
var tween = Tween<Offset>(
|
||||
begin: const Offset(1, 0), end: const Offset(0, 0));
|
||||
|
||||
return SlideTransition(
|
||||
position: tween.animate(animation),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
key: ValueKey(currentPage),
|
||||
child: buildRight(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void handlePointerDown(PointerDownEvent event) {
|
||||
if (!App.isIOS) {
|
||||
return;
|
||||
}
|
||||
if (event.position.dx < 20) {
|
||||
gestureRecognizer.addPointer(event);
|
||||
return buildLeft();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +218,13 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
? const EdgeInsets.fromLTRB(8, 0, 8, 0)
|
||||
: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
onTap: () => setState(() => currentPage = id),
|
||||
onTap: () {
|
||||
if (enableTwoViews) {
|
||||
setState(() => currentPage = id);
|
||||
} else {
|
||||
context.to(() => _SettingsDetailPage(pageIndex: id));
|
||||
}
|
||||
},
|
||||
child: content,
|
||||
).paddingVertical(4),
|
||||
);
|
||||
@@ -347,8 +238,23 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
}
|
||||
|
||||
Widget buildRight() {
|
||||
return switch (currentPage) {
|
||||
-1 => const SizedBox(),
|
||||
if (currentPage == -1) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return Navigator(
|
||||
onGenerateRoute: (settings) {
|
||||
return PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return _buildSettingsContent(currentPage);
|
||||
},
|
||||
transitionDuration: Duration.zero,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSettingsContent(int pageIndex) {
|
||||
return switch (pageIndex) {
|
||||
0 => const ExploreSettings(),
|
||||
1 => const ReaderSettings(),
|
||||
2 => const AppearanceSettings(),
|
||||
@@ -361,26 +267,31 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
};
|
||||
}
|
||||
|
||||
var canPop = ValueNotifier(true);
|
||||
}
|
||||
|
||||
class _SettingsDetailPage extends StatelessWidget {
|
||||
const _SettingsDetailPage({required this.pageIndex});
|
||||
|
||||
final int pageIndex;
|
||||
|
||||
@override
|
||||
ValueListenable<bool> get canPopNotifier => canPop;
|
||||
|
||||
@override
|
||||
void onPopInvokedWithResult(bool didPop, result) {
|
||||
if (currentPage != -1) {
|
||||
setState(() {
|
||||
currentPage = -1;
|
||||
});
|
||||
}
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
child: _buildPage(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onPopInvoked(bool didPop) {
|
||||
if (currentPage != -1) {
|
||||
setState(() {
|
||||
currentPage = -1;
|
||||
});
|
||||
}
|
||||
Widget _buildPage() {
|
||||
return switch (pageIndex) {
|
||||
0 => const ExploreSettings(),
|
||||
1 => const ReaderSettings(),
|
||||
2 => const AppearanceSettings(),
|
||||
3 => const LocalFavoritesSettings(),
|
||||
4 => const AppSettings(),
|
||||
5 => const NetworkSettings(),
|
||||
6 => const AboutSettings(),
|
||||
7 => const DebugPage(),
|
||||
_ => throw UnimplementedError()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
58
lib/utils/channel.dart
Normal file
58
lib/utils/channel.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
class Channel<T> {
|
||||
final Queue<T> _queue;
|
||||
|
||||
final int size;
|
||||
|
||||
Channel(this.size) : _queue = Queue<T>();
|
||||
|
||||
Completer? _releaseCompleter;
|
||||
|
||||
Completer? _pushCompleter;
|
||||
|
||||
var currentSize = 0;
|
||||
|
||||
var isClosed = false;
|
||||
|
||||
Future<void> push(T item) async {
|
||||
if (currentSize >= size) {
|
||||
_releaseCompleter ??= Completer();
|
||||
return _releaseCompleter!.future.then((_) {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
_queue.addLast(item);
|
||||
currentSize++;
|
||||
});
|
||||
}
|
||||
_queue.addLast(item);
|
||||
currentSize++;
|
||||
_pushCompleter?.complete();
|
||||
_pushCompleter = null;
|
||||
}
|
||||
|
||||
Future<T?> pop() async {
|
||||
while (_queue.isEmpty) {
|
||||
if (isClosed) {
|
||||
return null;
|
||||
}
|
||||
_pushCompleter ??= Completer();
|
||||
await _pushCompleter!.future;
|
||||
}
|
||||
var item = _queue.removeFirst();
|
||||
currentSize--;
|
||||
if (_releaseCompleter != null && currentSize < size) {
|
||||
_releaseCompleter!.complete();
|
||||
_releaseCompleter = null;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
void close() {
|
||||
isClosed = true;
|
||||
_pushCompleter?.complete();
|
||||
_releaseCompleter?.complete();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import 'package:zip_flutter/zip_flutter.dart';
|
||||
|
||||
import 'io.dart';
|
||||
|
||||
Future<File> exportAppData() async {
|
||||
Future<File> exportAppData([bool sync = true]) async {
|
||||
var time = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
var cacheFilePath = FilePath.join(App.cachePath, '$time.venera');
|
||||
var cacheFile = File(cacheFilePath);
|
||||
@@ -27,7 +27,7 @@ Future<File> exportAppData() async {
|
||||
var zipFile = ZipFile.open(cacheFilePath);
|
||||
var historyFile = FilePath.join(dataPath, "history.db");
|
||||
var localFavoriteFile = FilePath.join(dataPath, "local_favorite.db");
|
||||
var appdata = FilePath.join(dataPath, "appdata.json");
|
||||
var appdata = FilePath.join(dataPath, sync ? "syncdata.json" : "appdata.json");
|
||||
var cookies = FilePath.join(dataPath, "cookie.db");
|
||||
zipFile.addFile("history.db", historyFile);
|
||||
zipFile.addFile("local_favorite.db", localFavoriteFile);
|
||||
|
||||
@@ -130,7 +130,9 @@ class DataSync with ChangeNotifier {
|
||||
try {
|
||||
appdata.settings['dataVersion']++;
|
||||
await appdata.saveData(false);
|
||||
var data = await exportAppData();
|
||||
var data = await exportAppData(
|
||||
appdata.settings['disableSyncFields'].toString().isNotEmpty
|
||||
);
|
||||
var time =
|
||||
(DateTime.now().millisecondsSinceEpoch ~/ 86400000).toString();
|
||||
var filename = time;
|
||||
|
||||
@@ -362,7 +362,7 @@ Future<void> saveFile(
|
||||
}
|
||||
}
|
||||
|
||||
class _IOOverrides extends IOOverrides {
|
||||
final class _IOOverrides extends IOOverrides {
|
||||
@override
|
||||
Directory createDirectory(String path) {
|
||||
if (App.isAndroid) {
|
||||
|
||||
28
patch/font.dart
Normal file
28
patch/font.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
void main() async {
|
||||
const harmonySansLink = "https://developer.huawei.com/images/download/general/HarmonyOS-Sans.zip";
|
||||
|
||||
var dio = Dio();
|
||||
await dio.download(harmonySansLink, "HarmonyOS-Sans.zip");
|
||||
await extractFileToDisk("HarmonyOS-Sans.zip", "./assets/");
|
||||
File("HarmonyOS-Sans.zip").deleteSync();
|
||||
|
||||
var pubspec = await File("pubspec.yaml").readAsString();
|
||||
pubspec = pubspec.replaceFirst("# fonts:",
|
||||
""" fonts:
|
||||
- family: HarmonyOS Sans
|
||||
fonts:
|
||||
- asset: assets/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf
|
||||
""");
|
||||
await File("pubspec.yaml").writeAsString(pubspec);
|
||||
|
||||
var mainDart = await File("lib/main.dart").readAsString();
|
||||
mainDart = mainDart.replaceFirst("Noto Sans CJK", "HarmonyOS Sans");
|
||||
await File("lib/main.dart").writeAsString(mainDart);
|
||||
|
||||
print("Successfully patched font.");
|
||||
}
|
||||
59
pubspec.lock
59
pubspec.lock
@@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -408,10 +416,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_memory_info
|
||||
sha256: "1f112f1d7503aa1681fc8e923f6cd0e847bb2fbeec3753ed021cf1e5f7e9cd74"
|
||||
sha256: eacfd0dd01ff596b4e5bf022442769a1807a73f2af43d62802436f0a5de99137
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.1"
|
||||
version: "0.0.3"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -470,10 +478,11 @@ packages:
|
||||
flutter_to_debian:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_to_debian
|
||||
sha256: d23534407334b331ce20fbaa8395b9ecc255d0c047136b8998715f36933ee696
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "3777c91b6b1cc0b7c03357c67ca216d4313c3db5"
|
||||
url: "https://github.com/venera-app/flutter_to_debian.git"
|
||||
source: git
|
||||
version: "2.0.2"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
@@ -556,26 +565,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.9"
|
||||
version: "11.0.1"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -653,10 +662,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -770,6 +779,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
rhttp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -941,10 +958,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
version: "0.7.7"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1037,10 +1054,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1110,10 +1127,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: zip_flutter
|
||||
sha256: c4d5a34c5803def866bc550926bb16fe89717c9b7304695d5b2ede30964eb8a8
|
||||
sha256: baecf8deb6bf53a50e5ab513707ab56cc0c25f5b43333aa56ef562e8e7057357
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.12"
|
||||
version: "0.0.13"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.32.6"
|
||||
flutter: ">=3.38.3"
|
||||
|
||||
14
pubspec.yaml
14
pubspec.yaml
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.4.6+146
|
||||
version: 1.6.0+160
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.32.6
|
||||
flutter: 3.38.3
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -53,7 +53,7 @@ dependencies:
|
||||
sliver_tools: ^0.2.12
|
||||
flutter_file_dialog: ^3.0.2
|
||||
file_selector: ^1.0.3
|
||||
zip_flutter: ^0.0.12
|
||||
zip_flutter: ^0.0.13
|
||||
lodepng_flutter:
|
||||
git:
|
||||
url: https://github.com/venera-app/lodepng_flutter
|
||||
@@ -75,7 +75,7 @@ dependencies:
|
||||
ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
|
||||
dynamic_color: ^1.7.0
|
||||
shimmer_animation: ^2.1.0
|
||||
flutter_memory_info: ^0.0.1
|
||||
flutter_memory_info: ^0.0.3
|
||||
syntax_highlight: ^0.4.0
|
||||
flutter_7zip:
|
||||
git:
|
||||
@@ -93,7 +93,10 @@ dev_dependencies:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_to_arch: ^1.0.1
|
||||
flutter_to_debian: ^2.0.2
|
||||
flutter_to_debian:
|
||||
git:
|
||||
url: https://github.com/venera-app/flutter_to_debian.git
|
||||
archive: any
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -104,6 +107,7 @@ flutter:
|
||||
- assets/tags.json
|
||||
- assets/tags_tw.json
|
||||
- assets/opencc.txt
|
||||
# fonts:
|
||||
|
||||
flutter_to_arch:
|
||||
name: Venera
|
||||
|
||||
115
test/channel_test.dart
Normal file
115
test/channel_test.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:venera/utils/channel.dart';
|
||||
|
||||
void main() {
|
||||
test("1-1-1", () async {
|
||||
var channel = Channel<int>(1);
|
||||
await channel.push(1);
|
||||
var item = await channel.pop();
|
||||
expect(item, 1);
|
||||
});
|
||||
|
||||
test("1-3-1", () async {
|
||||
var channel = Channel<int>(1);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(2);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(3);
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var item = await channel.pop();
|
||||
if (item != null) {
|
||||
results.add(item);
|
||||
}
|
||||
}
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("2-3-1", () async {
|
||||
var channel = Channel<int>(2);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(2);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(3);
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var item = await channel.pop();
|
||||
if (item != null) {
|
||||
results.add(item);
|
||||
}
|
||||
}
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("1-1-3", () async {
|
||||
var channel = Channel<int>(1);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
print("push 1");
|
||||
await channel.push(1);
|
||||
print("push 2");
|
||||
await channel.push(2);
|
||||
print("push 3");
|
||||
await channel.push(3);
|
||||
print("push done");
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var consumers = <Future>[];
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
consumers.add(() async {
|
||||
while (true) {
|
||||
var item = await channel.pop();
|
||||
if (item == null) {
|
||||
break;
|
||||
}
|
||||
print("pop $item");
|
||||
results.add(item);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
await Future.wait(consumers);
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("close", () async {
|
||||
var channel = Channel<int>(2);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
await channel.push(2);
|
||||
await channel.push(3);
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// consumer
|
||||
await channel.pop();
|
||||
await channel.pop();
|
||||
await channel.pop();
|
||||
var item4 = await channel.pop();
|
||||
expect(item4, null);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user