Compare commits

..

60 Commits

Author SHA1 Message Date
40ef8a63b0 fix: enable multi-select actions in local comics search mode 2025-11-29 15:00:30 +08:00
053293839e flutter 3.38.3 2025-11-29 14:43:15 +08:00
Pacalini
f0be40c6d7 feat: skip sync setting (#563)
* feat: skip sync setting

* fix: upload origin data if nothing to skip

* sync: optimize text
2025-11-29 14:21:56 +08:00
Pacalini
da5b64abb0 interceptor: mask log (#618) 2025-11-29 14:21:30 +08:00
Y-Ymeow
7e3addf7a6 Enhance Cloudflare challenge detection logic (#619)
添加了验证body内的,防止一些网站的漏判
2025-11-29 14:20:02 +08:00
boa
b9c06779ad Fix landscape reader layout and wrap long settings labels (#640)
* fix: handle mobile landscape safe area #604

* fix: adjust reader toolbars safe area

* fix: adjust multi-image reader layout after orientation change

* fix: item titles not fully displayed
2025-11-29 14:19:43 +08:00
RuriNyan
7e928d2c9c Optimize iOS full-screen back gesture implementation (#643)
* Optimize iOS full-screen back gesture implementation

- Fix #613 and #617

* Fix setting page
2025-11-29 14:18:44 +08:00
RuriNyan
b3239757a8 Add encryptAes for js_engine (#645) 2025-11-29 14:18:18 +08:00
ynyx631
bdaa10fa06 Merge pull request #602 from venera-app/update-altstore-20251101-075020
Update AltStore source with latest release
2025-11-02 15:58:06 +08:00
GitHub Action
4296768c8d Updated source with latest release 2025-11-01 07:50:20 +00:00
ynyx631
49abf92724 Update main.yml 2025-11-01 14:48:06 +08:00
ynyx631
38376c5b2e Merge pull request #600 from liulifox233/master
Fix editor page gesture confict
2025-11-01 13:55:00 +08:00
LiuliFox
4053faa186 Fix editor page gesture confict 2025-11-01 13:51:48 +08:00
ynyx631
17fd9b3606 Merge pull request #598 from luckyray-fan/feat-search-use-lower
feat: 本地收藏搜索支持转小写匹配
2025-11-01 13:29:05 +08:00
ynyx631
792c41fdc3 Update main.yml 2025-11-01 13:28:39 +08:00
Yoshiro_fan
05e661b101 feat: 本地收藏搜索支持转小写匹配 2025-11-01 13:08:43 +08:00
ynyx631
46131fcf41 Merge pull request #597 from venera-app/fix/deb-depends
Fix missing depends in deb package. Close #587
2025-11-01 12:30:04 +08:00
ynyx631
59750332cd Merge pull request #596 from venera-app/version-1.6.0
Update version code
2025-11-01 12:29:52 +08:00
ynyx631
fd017a35f9 Merge pull request #594 from lings03/favorite
Optimize favorite page and home page.
2025-11-01 12:29:42 +08:00
ynyx631
3834d0211f Merge pull request #593 from lings03/comment
Chapter comments.
2025-11-01 12:29:15 +08:00
ynyx631
10bec09c80 Merge pull request #592 from lings03/patch
Save data when mark all as read
2025-11-01 12:28:43 +08:00
ynyx631
62dd742280 Merge pull request #588 from venera-app/fix/comic-list-loading
Fix the issue of the comic list loading infinitely. Close #584
2025-11-01 12:28:30 +08:00
ynyx631
03603a53e1 Merge pull request #586 from venera-app/feat/login-webview-localstorage
Added support for localstorage when logging in via webview.
2025-11-01 12:28:17 +08:00
ynyx631
2847af91ff Merge pull request #585 from venera-app/feat/js-dialog-img
Add support for ArrayBuffer to showInputDialog.
2025-11-01 12:27:58 +08:00
ynyx631
0bc01f718a Merge pull request #583 from venera-app/fix/zip-chinese
Fix chinese character issue when compressing files. Close #565
2025-11-01 12:27:36 +08:00
ynyx631
b60119170a Merge pull request #582 from luckyray-fan/feat-add-filter-local-favorites
feat: 支持过滤阅读完成情况
2025-11-01 12:27:12 +08:00
ynyx631
f4af6f3954 Merge branch 'master' into feat-add-filter-local-favorites 2025-11-01 12:25:16 +08:00
ynyx631
9e9d1ac3b1 Merge pull request #578 from 4b1tQu4ntN3k0/normalize-protocol-relative-urls
[linux] Fix linux nhentai cover image
2025-11-01 12:06:49 +08:00
ynyx631
b3b9199cc3 Merge pull request #575 from liulifox233/master
[iOS] Enable full screen swipe back gesture
2025-11-01 12:06:16 +08:00
dd00ba11c8 Fix missing depends in deb package. Close #587 2025-11-01 12:04:14 +08:00
e87fb535b8 Update version code 2025-11-01 11:50:00 +08:00
角砂糖
df1649def6 Home page shared item 2025-11-01 04:17:29 +08:00
角砂糖
99559eaff8 Remove wrong showBarrier false 2025-11-01 02:59:50 +08:00
角砂糖
39a834815d Optimize favorite page. 2025-11-01 02:34:43 +08:00
角砂糖
a9e76201f3 Chapter comments. 2025-11-01 02:34:08 +08:00
角砂糖
0044d95e97 Save data when mark all as read 2025-11-01 00:37:25 +08:00
ynyx631
9636cf62cb Fix the issue of the comic list loading infinitely. Close #584 2025-10-29 19:39:18 +08:00
5ccf0eea43 Added support for localstorage when logging in via webview. 2025-10-28 19:21:28 +08:00
e8d98e8274 Add support for ArrayBuffer to showInputDialog. 2025-10-28 18:42:59 +08:00
ynyx631
d22501198a Fix chinese character issue when compressing files. Close #565 2025-10-26 20:38:58 +08:00
Yoshiro_fan
be23c4fe68 feat: 支持过滤阅读完成情况 2025-10-26 16:30:52 +08:00
4b1tQu4ntN3k0
a8422780a0 fix linux nhentai cover image 2025-10-25 07:33:51 +08:00
LiuliFox
75c2a3a417 Fix some gesture conflicts 2025-10-20 15:44:07 +08:00
LiuliFox
3d194d7f6a [iOS] Enable full screen swipe back gesture 2025-10-20 10:08:25 +08:00
09a1d2821c Enhance onResponse handling in ImageDownloader to support Future and validate result type 2025-10-19 21:50:27 +08:00
nyne
7842b5a1ac Merge pull request #571 from Ftbom/master
调整多收藏夹漫画源的收藏状态显示逻辑
2025-10-19 15:06:18 +08:00
Ftbom
079f574e2f improve network favorite handling in comic details page 2025-10-19 12:23:37 +08:00
GitHub Action
b08f11f6ac Updated source with latest release 2025-10-13 21:24:05 +08:00
nyne
cd925df125 Change base branch from main to master in workflow 2025-10-13 21:19:14 +08:00
nyne
8c87c4a906 Refactor AltStore update workflow script 2025-10-13 21:14:30 +08:00
nyne
c234a53518 Merge pull request #557 from venera-app/v1.5.3-dev
V1.5.3
2025-10-13 20:42:31 +08:00
49fd64358c Improve categories page. 2025-10-13 20:28:03 +08:00
3426d707fe Refactor radio button implementations to use RadioGroup. 2025-10-13 20:12:47 +08:00
ebc106d45b enable minify. Close #547 2025-10-13 19:51:54 +08:00
0cda9a2921 Fix alt_store workflow 2025-10-13 18:39:39 +08:00
0eb5d76687 fix android back gesture. Close #544 2025-10-12 19:49:33 +08:00
29d25f7fcd Update version code 2025-10-12 16:47:08 +08:00
7d60e78f27 ignore empty archive link 2025-10-12 16:44:13 +08:00
nyne
e93b56a008 Add Inno Setup installation to workflow 2025-10-09 22:06:21 +08:00
nyne
d10873a903 Update update_alt_store.yml 2025-10-09 21:39:42 +08:00
54 changed files with 2415 additions and 969 deletions

View File

@@ -116,6 +116,8 @@ jobs:
run: | run: |
choco install yq -y choco install yq -y
pip install httpx pip install httpx
- name: Install Inno Setup
run: choco install innosetup --no-progress
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
with: with:
channel: "stable" channel: "stable"
@@ -141,7 +143,7 @@ jobs:
- run: | - run: |
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1 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: python3 debian/build.py x64
- run: dart run flutter_to_arch - run: dart run flutter_to_arch
- run: | - run: |
@@ -169,7 +171,7 @@ jobs:
flutter pub get flutter pub get
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1 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" - name: "Patch font"
run: | run: |
dart run patch/font.dart dart run patch/font.dart

View File

@@ -40,8 +40,19 @@ jobs:
if git diff --staged --quiet; then if git diff --staged --quiet; then
echo "changes=false" >> $GITHUB_OUTPUT echo "changes=false" >> $GITHUB_OUTPUT
else 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 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 echo "changes=true" >> $GITHUB_OUTPUT
fi fi

View File

@@ -13,15 +13,15 @@
"bundleIdentifier": "com.github.wgh136.venera", "bundleIdentifier": "com.github.wgh136.venera",
"developerName": "wgh136", "developerName": "wgh136",
"subtitle": "A comic reader that supports reading local and network comics", "subtitle": "A comic reader that supports reading local and network comics",
"version": "1.4.5", "version": "1.6.0",
"versionDate": "2025-06-18", "versionDate": "2025-11-01",
"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.", "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.4.5/venera-ios-1.4.5%2B145.ipa", "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", "localizedDescription": "A comic reader that supports reading local and network comics",
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png", "iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
"tintColor": "#0784FC", "tintColor": "#0784FC",
"category": "utilities", "category": "utilities",
"size": 14960268, "size": 15064741,
"appPermissions": { "appPermissions": {
"entitlements": [ "entitlements": [
"application-identifier", "application-identifier",
@@ -39,6 +39,20 @@
} }
}, },
"versions": [ "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", "version": "1.4.5",
"date": "2025-06-18", "date": "2025-06-18",
@@ -59,6 +73,26 @@
"tintColor": "#0784FC", "tintColor": "#0784FC",
"title": "v1.4.5 - Venera 18/06/25", "title": "v1.4.5 - Venera 18/06/25",
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5" "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"
} }
] ]
} }

View File

@@ -23,7 +23,7 @@ linter:
rules: rules:
collection_methods_unrelated_type: false collection_methods_unrelated_type: false
use_build_context_synchronously: 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 # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at # Additional information about this file can be found at

View File

@@ -84,9 +84,8 @@ android {
buildTypes { buildTypes {
release { release {
// Temporarily solution to fix crash minifyEnabled true
minifyEnabled false shrinkResources true
shrinkResources false
ndk { ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64" abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
} }

View File

@@ -190,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} value
* @param {ArrayBuffer} key * @param {ArrayBuffer} key
@@ -205,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} value
* @param {ArrayBuffer} key * @param {ArrayBuffer} key
@@ -225,20 +257,58 @@ let Convert = {
/** /**
* @param {ArrayBuffer} value * @param {ArrayBuffer} value
* @param {ArrayBuffer} key * @param {ArrayBuffer} key
* @param {ArrayBuffer} iv
* @param {number} blockSize * @param {number} blockSize
* @returns {ArrayBuffer} * @returns {ArrayBuffer}
*/ */
decryptAesCfb: (value, key, blockSize) => { encryptAesCfb: (value, key, iv, blockSize) => {
return sendMessage({ return sendMessage({
method: "convert", method: "convert",
type: "aes-cfb", type: "aes-cfb",
value: value, value: value,
key: key, 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, blockSize: blockSize,
isEncode: false 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} value
* @param {ArrayBuffer} key * @param {ArrayBuffer} key
@@ -395,9 +465,10 @@ let Network = {
* @param {string} url - The URL to send the request to. * @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request. * @param {Object} headers - The headers to include in the request.
* @param data - The data to send with 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. * @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({ let result = await sendMessage({
method: 'http', method: 'http',
http_method: method, http_method: method,
@@ -405,6 +476,7 @@ let Network = {
url: url, url: url,
headers: headers, headers: headers,
data: data, data: data,
extra: extra,
}); });
if (result.error) { if (result.error) {
@@ -420,15 +492,17 @@ let Network = {
* @param {string} url - The URL to send the request to. * @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request. * @param {Object} headers - The headers to include in the request.
* @param data - The data to send with 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. * @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({ let result = await sendMessage({
method: 'http', method: 'http',
http_method: method, http_method: method,
url: url, url: url,
headers: headers, headers: headers,
data: data, data: data,
extra: extra,
}); });
if (result.error) { if (result.error) {
@@ -442,10 +516,11 @@ let Network = {
* Sends an HTTP GET request. * Sends an HTTP GET request.
* @param {string} url - The URL to send the request to. * @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request. * @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. * @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/ */
async get(url, headers) { async get(url, headers, extra) {
return this.sendRequest('GET', url, headers); return this.sendRequest('GET', url, headers, extra);
}, },
/** /**
@@ -453,10 +528,11 @@ let Network = {
* @param {string} url - The URL to send the request to. * @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request. * @param {Object} headers - The headers to include in the request.
* @param data - The data to send with 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. * @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/ */
async post(url, headers, data) { async post(url, headers, data, extra) {
return this.sendRequest('POST', url, headers, data); return this.sendRequest('POST', url, headers, data, extra);
}, },
/** /**
@@ -464,10 +540,11 @@ let Network = {
* @param {string} url - The URL to send the request to. * @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request. * @param {Object} headers - The headers to include in the request.
* @param data - The data to send with 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. * @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/ */
async put(url, headers, data) { async put(url, headers, data, extra) {
return this.sendRequest('PUT', url, headers, data); return this.sendRequest('PUT', url, headers, data, extra);
}, },
/** /**
@@ -475,20 +552,22 @@ let Network = {
* @param {string} url - The URL to send the request to. * @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request. * @param {Object} headers - The headers to include in the request.
* @param data - The data to send with 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. * @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/ */
async patch(url, headers, data) { async patch(url, headers, data, extra) {
return this.sendRequest('PATCH', url, headers, data); return this.sendRequest('PATCH', url, headers, data, extra);
}, },
/** /**
* Sends an HTTP DELETE request. * Sends an HTTP DELETE request.
* @param {string} url - The URL to send the request to. * @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request. * @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. * @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/ */
async delete(url, headers) { async delete(url, headers, extra) {
return this.sendRequest('DELETE', url, headers); return this.sendRequest('DELETE', url, headers, extra);
}, },
/** /**
@@ -1334,7 +1413,7 @@ let UI = {
* Show an input dialog * Show an input dialog
* @param title {string} * @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 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. * @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
*/ */
showInputDialog: (title, validator, image) => { showInputDialog: (title, validator, image) => {

View File

@@ -47,6 +47,7 @@
"Move to folder": "移动到文件夹", "Move to folder": "移动到文件夹",
"Copy to folder": "复制到文件夹", "Copy to folder": "复制到文件夹",
"Delete Comic": "删除漫画", "Delete Comic": "删除漫画",
"Jump to Detail": "跳转详情",
"Delete @c comics?": "删除 @c 本漫画?", "Delete @c comics?": "删除 @c 本漫画?",
"Add comic source": "添加漫画源", "Add comic source": "添加漫画源",
"Delete comic source '@n' ?": "删除漫画源 '@n' ", "Delete comic source '@n' ?": "删除漫画源 '@n' ",
@@ -69,6 +70,9 @@
"Next": "前进", "Next": "前进",
"Login with webview": "通过网页登录", "Login with webview": "通过网页登录",
"Read": "阅读", "Read": "阅读",
"Completed": "已完成",
"UnCompleted": "未完成",
"Filter reading status": "过滤阅读状态",
"Download": "下载", "Download": "下载",
"Favorite": "收藏", "Favorite": "收藏",
"Comments": "评论", "Comments": "评论",
@@ -197,6 +201,10 @@
"Sync Data": "同步数据", "Sync Data": "同步数据",
"Syncing Data": "正在同步数据", "Syncing Data": "正在同步数据",
"Data Sync": "数据同步", "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": "快速收藏", "Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹", "Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
"Added": "已添加", "Added": "已添加",
@@ -379,6 +387,8 @@
"Continuous": "连续", "Continuous": "连续",
"Display mode of comic list": "漫画列表的显示模式", "Display mode of comic list": "漫画列表的显示模式",
"Show Page Number": "显示页码", "Show Page Number": "显示页码",
"Show Chapter Comments": "显示章节评论",
"Chapter Comments": "章节评论",
"Jump to page": "跳转到页面", "Jump to page": "跳转到页面",
"Page": "页面", "Page": "页面",
"Jump": "跳转", "Jump": "跳转",
@@ -464,6 +474,7 @@
"Move": "移動", "Move": "移動",
"Move to folder": "移動到資料夾", "Move to folder": "移動到資料夾",
"Copy to folder": "複製到資料夾", "Copy to folder": "複製到資料夾",
"Jump to Detail": "跳轉詳情​​",
"Delete Comic": "刪除漫畫", "Delete Comic": "刪除漫畫",
"Delete @c comics?": "刪除 @c 本漫畫?", "Delete @c comics?": "刪除 @c 本漫畫?",
"Add comic source": "添加漫畫源", "Add comic source": "添加漫畫源",
@@ -487,6 +498,9 @@
"Next": "前進", "Next": "前進",
"Login with webview": "透過網頁登入", "Login with webview": "透過網頁登入",
"Read": "閱讀", "Read": "閱讀",
"Completed": "已完成",
"UnCompleted": "未完成",
"Filter reading status": "過濾閱讀狀態",
"Download": "下載", "Download": "下載",
"Favorite": "收藏", "Favorite": "收藏",
"Comments": "評論", "Comments": "評論",
@@ -614,6 +628,10 @@
"Sync Data": "同步資料", "Sync Data": "同步資料",
"Syncing Data": "正在同步資料", "Syncing Data": "正在同步資料",
"Data Sync": "資料同步", "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": "快速收藏", "Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾", "Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
"Added": "已添加", "Added": "已添加",
@@ -796,6 +814,8 @@
"Continuous": "連續", "Continuous": "連續",
"Display mode of comic list": "漫畫列表的顯示模式", "Display mode of comic list": "漫畫列表的顯示模式",
"Show Page Number": "顯示頁碼", "Show Page Number": "顯示頁碼",
"Show Chapter Comments": "顯示章節評論",
"Chapter Comments": "章節評論",
"Jump to page": "跳轉到頁面", "Jump to page": "跳轉到頁面",
"Page": "頁面", "Page": "頁面",
"Jump": "跳轉", "Jump": "跳轉",

View File

@@ -553,6 +553,51 @@ If `load` function is implemented, `loadNext` function will be ignored.
*/ */
sendComment: async (comicId, subId, content, replyTo) => { 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 * [Optional] like or unlike a comment

View File

@@ -753,9 +753,9 @@ class SliverGridComics extends StatefulWidget {
final List<MenuEntry> Function(Comic)? menuBuilder; 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 @override
State<SliverGridComics> createState() => _SliverGridComicsState(); State<SliverGridComics> createState() => _SliverGridComicsState();
@@ -856,52 +856,51 @@ class _SliverGridComics extends StatelessWidget {
final List<MenuEntry> Function(Comic)? menuBuilder; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverGrid( return SliverGrid(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { if (index == comics.length - 1) {
if (index == comics.length - 1) { onLastItemBuild?.call();
onLastItemBuild?.call(); }
} var badge = badgeBuilder?.call(comics[index]);
var badge = badgeBuilder?.call(comics[index]); var isSelected = selection == null
var isSelected = ? false
selection == null ? false : selection![comics[index]] ?? false; : selection![comics[index]] ?? false;
var comic = ComicTile( var comic = ComicTile(
comic: comics[index], comic: comics[index],
badge: badge, badge: badge,
menuOptions: menuBuilder?.call(comics[index]), menuOptions: menuBuilder?.call(comics[index]),
onTap: onTap != null ? () => onTap!(comics[index]) : null, onTap: onTap != null
onLongPressed: onLongPressed != null ? () => onTap!(comics[index], heroIDs[index])
? () => onLongPressed!(comics[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, : null,
heroID: heroIDs[index], borderRadius: BorderRadius.circular(12),
); ),
if (selection == null) { margin: const EdgeInsets.all(4),
return comic; child: comic,
} );
return AnimatedContainer( }, childCount: comics.length),
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,
),
gridDelegate: SliverGridDelegateWithComics(), gridDelegate: SliverGridDelegateWithComics(),
); );
} }
@@ -1159,7 +1158,7 @@ class ComicListState extends State<ComicList> {
if (res.data.isEmpty) { if (res.data.isEmpty) {
setState(() { setState(() {
_data[page] = const []; _data[page] = const [];
_maxPage = page; _maxPage ??= page;
}); });
} else { } else {
setState(() { setState(() {
@@ -1282,8 +1281,8 @@ class ComicListState extends State<ComicList> {
], ],
); );
} }
if (_data[_page] == null) { if (_data[1] == null) {
_loadPage(_page); _loadPage(1);
return Column( return Column(
children: [ children: [
if (widget.errorLeading != null) widget.errorLeading!, if (widget.errorLeading != null) widget.errorLeading!,
@@ -1304,7 +1303,7 @@ class ComicListState extends State<ComicList> {
comics: _data.values.expand((element) => element).toList(), comics: _data.values.expand((element) => element).toList(),
menuBuilder: widget.menuBuilder, menuBuilder: widget.menuBuilder,
onLastItemBuild: () { onLastItemBuild: () {
if (_error == null && (_maxPage == null || _page < _maxPage!)) { if (_error == null && (_maxPage == null || _data.length < _maxPage!)) {
_loadPage(_data.length + 1); _loadPage(_data.length + 1);
} }
}, },
@@ -1334,7 +1333,7 @@ class ComicListState extends State<ComicList> {
], ],
).paddingHorizontal(16).paddingVertical(8), ).paddingHorizontal(16).paddingVertical(8),
) )
else if (_maxPage == null || _page < _maxPage!) else if (_maxPage == null || _data.length < _maxPage!)
const SliverListLoadingIndicator(), const SliverListLoadingIndicator(),
if (widget.trailingSliver != null) widget.trailingSliver!, if (widget.trailingSliver != null) widget.trailingSliver!,
], ],
@@ -1627,7 +1626,7 @@ class _SMClipper extends CustomClipper<Rect> {
class SimpleComicTile extends StatelessWidget { class SimpleComicTile extends StatelessWidget {
const SimpleComicTile( 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; final Comic comic;
@@ -1635,6 +1634,8 @@ class SimpleComicTile extends StatelessWidget {
final bool withTitle; final bool withTitle;
final int? heroID;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var image = _findImageProvider(comic); var image = _findImageProvider(comic);
@@ -1660,6 +1661,13 @@ class SimpleComicTile extends StatelessWidget {
child: child, child: child,
); );
if (heroID != null) {
child = Hero(
tag: "cover$heroID",
child: child,
);
}
child = AnimatedTapRegion( child = AnimatedTapRegion(
borderRadius: 8, borderRadius: 8,
onTap: onTap ?? onTap: onTap ??
@@ -1668,6 +1676,9 @@ class SimpleComicTile extends StatelessWidget {
() => ComicPage( () => ComicPage(
id: comic.id, id: comic.id,
sourceKey: comic.sourceKey, sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
heroID: heroID,
), ),
); );
}, },

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@@ -40,7 +42,6 @@ mixin class JsUiApi {
var image = message['image']; var image = message['image'];
if (title is! String) return; if (title is! String) return;
if (validator != null && validator is! JSInvokable) return; if (validator != null && validator is! JSInvokable) return;
if (image != null && image is! String) return;
return _showInputDialog(title, validator, image); return _showInputDialog(title, validator, image);
case 'showSelectDialog': case 'showSelectDialog':
var title = message['title']; var title = message['title'];
@@ -126,13 +127,25 @@ mixin class JsUiApi {
controller?.close(); controller?.close();
} }
Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async { Future<String?> _showInputDialog(String title, JSInvokable? validator, dynamic image) async {
String? result; String? result;
var func = validator == null ? null : JSAutoFreeFunction(validator); 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( await showInputDialog(
context: App.rootContext, context: App.rootContext,
title: title, title: title,
image: image, image: imageUrl,
imageData: imageData,
onConfirm: (v) { onConfirm: (v) {
if (func != null) { if (func != null) {
var res = func.call([v]); var res = func.call([v]);

View File

@@ -360,6 +360,7 @@ Future<void> showInputDialog({
String cancelText = "Cancel", String cancelText = "Cancel",
RegExp? inputValidator, RegExp? inputValidator,
String? image, String? image,
Uint8List? imageData,
}) { }) {
var controller = TextEditingController(text: initialValue); var controller = TextEditingController(text: initialValue);
bool isLoading = false; bool isLoading = false;
@@ -379,6 +380,11 @@ Future<void> showInputDialog({
height: 108, height: 108,
child: Image.network(image, fit: BoxFit.none), child: Image.network(image, fit: BoxFit.none),
).paddingBottom(8), ).paddingBottom(8),
if (image == null && imageData != null)
SizedBox(
height: 108,
child: Image.memory(imageData, fit: BoxFit.none),
).paddingBottom(8),
TextField( TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(

View File

@@ -172,6 +172,16 @@ class NaviPaneState extends State<NaviPane>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
onRebuild(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( return _NaviPopScope(
action: () { action: () {
if (App.mainNavigatorKey!.currentState!.canPop()) { if (App.mainNavigatorKey!.currentState!.canPop()) {
@@ -185,7 +195,7 @@ class NaviPaneState extends State<NaviPane>
animation: controller, animation: controller,
builder: (context, child) { builder: (context, child) {
final value = controller.value; final value = controller.value;
return Stack( Widget content = Stack(
children: [ children: [
Positioned( Positioned(
left: _kFoldedSideBarWidth * ((value - 2.0).clamp(-1.0, 0.0)), left: _kFoldedSideBarWidth * ((value - 2.0).clamp(-1.0, 0.0)),
@@ -202,6 +212,13 @@ class NaviPaneState extends State<NaviPane>
), ),
], ],
); );
if (sideInsets != EdgeInsets.zero) {
content = Padding(
padding: sideInsets,
child: content,
);
}
return content;
}, },
), ),
); );

View File

@@ -241,6 +241,10 @@ class _AppScrollBarState extends State<AppScrollBar> {
late final VerticalDragGestureRecognizer _dragGestureRecognizer; late final VerticalDragGestureRecognizer _dragGestureRecognizer;
bool _isVisible = false;
Timer? _hideTimer;
static const _hideDuration = Duration(seconds: 2);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -248,7 +252,41 @@ class _AppScrollBarState extends State<AppScrollBar> {
_scrollController.addListener(onChanged); _scrollController.addListener(onChanged);
Future.microtask(onChanged); Future.microtask(onChanged);
_dragGestureRecognizer = VerticalDragGestureRecognizer() _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) { void onUpdate(DragUpdateDetails details) {
@@ -269,14 +307,24 @@ class _AppScrollBarState extends State<AppScrollBar> {
void onChanged() { void onChanged() {
if (_scrollController.positions.isEmpty) return; if (_scrollController.positions.isEmpty) return;
var position = _scrollController.position; var position = _scrollController.position;
bool hasChanged = false;
if (position.minScrollExtent != minExtent || if (position.minScrollExtent != minExtent ||
position.maxScrollExtent != maxExtent || position.maxScrollExtent != maxExtent ||
position.pixels != this.position) { position.pixels != this.position) {
setState(() { hasChanged = true;
minExtent = position.minScrollExtent; minExtent = position.minScrollExtent;
maxExtent = position.maxScrollExtent; maxExtent = position.maxScrollExtent;
this.position = position.pixels; this.position = position.pixels;
}); }
if (hasChanged) {
_showScrollbar();
_scheduleHide();
}
if (hasChanged && mounted) {
setState(() {});
} }
} }
@@ -300,29 +348,35 @@ class _AppScrollBarState extends State<AppScrollBar> {
Positioned( Positioned(
top: top + widget.topPadding, top: top + widget.topPadding,
right: 0, right: 0,
child: MouseRegion( child: AnimatedOpacity(
cursor: SystemMouseCursors.click, opacity: _isVisible ? 1.0 : 0.0,
child: Listener( duration: const Duration(milliseconds: 200),
behavior: HitTestBehavior.translucent, child: MouseRegion(
onPointerDown: (event) { cursor: SystemMouseCursors.click,
_dragGestureRecognizer.addPointer(event); onEnter: (_) => _showScrollbar(),
}, onExit: (_) => _scheduleHide(),
child: SizedBox( child: Listener(
width: _scrollIndicatorSize/2, behavior: HitTestBehavior.translucent,
height: _scrollIndicatorSize, onPointerDown: (event) {
child: CustomPaint( _dragGestureRecognizer.addPointer(event);
painter: _ScrollIndicatorPainter( },
backgroundColor: context.colorScheme.surface, child: SizedBox(
shadowColor: context.colorScheme.shadow, 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),
), ),
), ),
), ),

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.5.2"; final version = "1.6.0";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;

View File

@@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
const double _kBackGestureWidth = 20.0; const double _kBackGestureWidth = 20.0;
@@ -121,20 +122,21 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
builder = PredictiveBackPageTransitionsBuilder(); builder = PredictiveBackPageTransitionsBuilder();
} else { } else {
builder = SlidePageTransitionBuilder(); builder = SlidePageTransitionBuilder();
} }
return builder.buildTransitions( return builder.buildTransitions(
this, this,
context, context,
animation, animation,
secondaryAnimation, secondaryAnimation,
enableIOSGesture enableIOSGesture && App.isIOS
? IOSBackGestureDetector( ? IOSBackGestureDetector(
gestureWidth: _kBackGestureWidth, gestureWidth: _kBackGestureWidth,
enabledCallback: () => _isPopGestureEnabled<T>(this), enabledCallback: () => _isPopGestureEnabled<T>(this),
onStartPopGesture: () => _startPopGesture(this), onStartPopGesture: () => _startPopGesture(this),
child: child) child: child,
: child); )
: child);
} }
IOSBackGestureController _startPopGesture(PageRoute<T> route) { IOSBackGestureController _startPopGesture(PageRoute<T> route) {
@@ -201,19 +203,17 @@ class IOSBackGestureController {
} }
class IOSBackGestureDetector extends StatefulWidget { class IOSBackGestureDetector extends StatefulWidget {
const IOSBackGestureDetector( const IOSBackGestureDetector({
{required this.enabledCallback, required this.enabledCallback,
required this.child, required this.child,
required this.gestureWidth, required this.gestureWidth,
required this.onStartPopGesture, required this.onStartPopGesture,
super.key}); super.key,
});
final double gestureWidth; final double gestureWidth;
final bool Function() enabledCallback; final bool Function() enabledCallback;
final IOSBackGestureController Function() onStartPopGesture; final IOSBackGestureController Function() onStartPopGesture;
final Widget child; final Widget child;
@override @override
@@ -222,8 +222,22 @@ class IOSBackGestureDetector extends StatefulWidget {
class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> { class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
IOSBackGestureController? _backGestureController; 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 @override
void dispose() { void dispose() {
@@ -231,81 +245,211 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
super.dispose(); super.dispose();
} }
@override
void initState() {
super.initState();
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr return RawGestureDetector(
? MediaQuery.of(context).padding.left behavior: HitTestBehavior.translucent,
: MediaQuery.of(context).padding.right; gestures: {
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth); _BackSwipeRecognizer: GestureRecognizerFactoryWithHandlers<_BackSwipeRecognizer>(
return Stack( () => _recognizer,
fit: StackFit.passthrough, (instance) {
children: <Widget>[ instance.gestureWidth = widget.gestureWidth;
widget.child, },
Positioned(
width: dragAreaWidth,
top: 0.0,
bottom: 0.0,
left: 0,
child: Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.translucent,
),
), ),
], },
child: widget.child,
); );
} }
void _handlePointerDown(PointerDownEvent event) { bool _isPointerInHorizontalScrollable(Offset globalPosition) {
if (widget.enabledCallback()) _recognizer.addPointer(event); 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() { void _handleDragStart(DragStartDetails details) {
assert(mounted); if (!widget.enabledCallback()) return;
_backGestureController?.dragEnd(0.0); if (mounted && _backGestureController == null) {
_backGestureController = null; _backGestureController = widget.onStartPopGesture();
}
} }
double _convertToLogical(double value) { void _handleDragUpdate(DragUpdateDetails details) {
switch (Directionality.of(context)) { if (mounted && _backGestureController != null) {
case TextDirection.rtl: _backGestureController!.dragUpdate(
return -value; _convertToLogical(details.primaryDelta! / context.size!.width));
case TextDirection.ltr:
return value;
} }
} }
void _handleDragEnd(DragEndDetails details) { void _handleDragEnd(DragEndDetails details) {
assert(mounted); if (mounted && _backGestureController != null) {
assert(_backGestureController != null); _backGestureController!.dragEnd(_convertToLogical(
_backGestureController!.dragEnd(_convertToLogical( details.velocity.pixelsPerSecond.dx / context.size!.width));
details.velocity.pixelsPerSecond.dx / context.size!.width)); _backGestureController = null;
_backGestureController = null; }
} }
void _handleDragStart(DragStartDetails details) { void _handleDragCancel() {
assert(mounted); if (mounted && _backGestureController != null) {
assert(_backGestureController == null); _backGestureController?.dragEnd(0.0);
_backGestureController = widget.onStartPopGesture(); _backGestureController = null;
}
} }
void _handleDragUpdate(DragUpdateDetails details) { double _convertToLogical(double value) {
assert(mounted); switch (Directionality.of(context)) {
assert(_backGestureController != null); case TextDirection.rtl: return -value;
_backGestureController!.dragUpdate( case TextDirection.ltr: return value;
_convertToLogical(details.primaryDelta! / context.size!.width)); }
} }
} }
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 { class SlidePageTransitionBuilder extends PageTransitionsBuilder {
@override @override
Widget buildTransitions<T>( Widget buildTransitions<T>(
@@ -314,30 +458,31 @@ class SlidePageTransitionBuilder extends PageTransitionsBuilder {
Animation<double> animation, Animation<double> animation,
Animation<double> secondaryAnimation, Animation<double> secondaryAnimation,
Widget child) { 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( return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(primaryAnimation),
child: SlideTransition(
position: Tween<Offset>( position: Tween<Offset>(
begin: const Offset(1, 0), begin: Offset.zero,
end: Offset.zero, end: const Offset(-0.4, 0),
).animate(CurvedAnimation( ).animate(secondaryCurve),
parent: animation, child: PhysicalModel(
curve: Curves.ease, color: Colors.transparent,
)), borderRadius: BorderRadius.zero,
child: SlideTransition( clipBehavior: Clip.hardEdge,
position: Tween<Offset>( elevation: 6,
begin: Offset.zero, child: Material(child: child),
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,),
),
)
); );
} }
} }

View File

@@ -23,9 +23,26 @@ class Appdata with Init {
} }
_isSavingData = true; _isSavingData = true;
try { try {
var data = jsonEncode(toJson()); var futures = <Future>[];
var json = toJson();
var data = jsonEncode(json);
var file = File(FilePath.join(App.dataPath, 'appdata.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 { } finally {
_isSavingData = false; _isSavingData = false;
} }
@@ -59,20 +76,33 @@ class Appdata with Init {
return {'settings': settings._data, 'searchHistory': searchHistory}; 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. /// Following fields are related to device-specific data and should not be synced.
static const _disableSync = [ static const _disableSync = [
"proxy", "proxy",
"authorizationRequired", "authorizationRequired",
"customImageProcessing", "customImageProcessing",
"webdav", "webdav",
"disableSyncFields",
]; ];
/// Sync data from another device /// Sync data from another device
void syncData(Map<String, dynamic> data) { void syncData(Map<String, dynamic> data) {
if (data['settings'] is Map) { if (data['settings'] is Map) {
var settings = data['settings'] as Map<String, dynamic>; var settings = data['settings'] as Map<String, dynamic>;
List<String> customDisableSync = splitField(this.settings["disableSyncFields"] as String);
for (var key in settings.keys) { for (var key in settings.keys) {
if (!_disableSync.contains(key)) { if (!_disableSync.contains(key) &&
!customDisableSync.contains(key)) {
this.settings[key] = settings[key]; this.settings[key] = settings[key];
} }
} }
@@ -166,6 +196,7 @@ class Settings with ChangeNotifier {
'checkUpdateOnStart': false, 'checkUpdateOnStart': false,
'limitImageWidth': true, 'limitImageWidth': true,
'webdav': [], // empty means not configured 'webdav': [], // empty means not configured
"disableSyncFields": "", // "field1, field2, ..."
'dataVersion': 0, 'dataVersion': 0,
'quickFavorite': null, 'quickFavorite': null,
'enableTurnPageByVolumeKey': true, 'enableTurnPageByVolumeKey': true,
@@ -194,6 +225,7 @@ class Settings with ChangeNotifier {
'readerScrollSpeed': 1.0, // 0.5 - 3.0 'readerScrollSpeed': 1.0, // 0.5 - 3.0
'localFavoritesFirst': true, 'localFavoritesFirst': true,
'autoCloseFavoritePanel': false, 'autoCloseFavoritePanel': false,
'showChapterComments': true, // show chapter comments in reader
}; };
operator [](String key) { operator [](String key) {
@@ -207,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); setReaderSetting(comicId, sourceKey, "enabled", enabled);
} }
@@ -215,7 +251,8 @@ class Settings with ChangeNotifier {
if (comicId == null || sourceKey == null) { if (comicId == null || sourceKey == null) {
return false; return false;
} }
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] == true; return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] ==
true;
} }
dynamic getReaderSetting(String comicId, String sourceKey, String key) { dynamic getReaderSetting(String comicId, String sourceKey, String key) {

View File

@@ -61,8 +61,10 @@ class ComicSourceManager with ChangeNotifier, Init {
await for (var entity in Directory(path).list()) { await for (var entity in Directory(path).list()) {
if (entity is File && entity.path.endsWith(".js")) { if (entity is File && entity.path.endsWith(".js")) {
try { try {
var source = await ComicSourceParser() var source = await ComicSourceParser().parse(
.parse(await entity.readAsString(), entity.absolute.path); await entity.readAsString(),
entity.absolute.path,
);
_sources.add(source); _sources.add(source);
} catch (e, s) { } catch (e, s) {
Log.error("ComicSource", "$e\n$s"); Log.error("ComicSource", "$e\n$s");
@@ -154,7 +156,7 @@ class ComicSource {
final GetImageLoadingConfigFunc? getImageLoadingConfig; final GetImageLoadingConfigFunc? getImageLoadingConfig;
final Map<String, dynamic> Function(String imageKey)? final Map<String, dynamic> Function(String imageKey)?
getThumbnailLoadingConfig; getThumbnailLoadingConfig;
var data = <String, dynamic>{}; var data = <String, dynamic>{};
@@ -170,6 +172,10 @@ class ComicSource {
final SendCommentFunc? sendCommentFunc; final SendCommentFunc? sendCommentFunc;
final ChapterCommentsLoader? chapterCommentsLoader;
final SendChapterCommentFunc? sendChapterCommentFunc;
final RegExp? idMatcher; final RegExp? idMatcher;
final LikeOrUnlikeComicFunc? likeOrUnlikeComic; final LikeOrUnlikeComicFunc? likeOrUnlikeComic;
@@ -256,6 +262,8 @@ class ComicSource {
this.version, this.version,
this.commentsLoader, this.commentsLoader,
this.sendCommentFunc, this.sendCommentFunc,
this.chapterCommentsLoader,
this.sendChapterCommentFunc,
this.likeOrUnlikeComic, this.likeOrUnlikeComic,
this.voteCommentFunc, this.voteCommentFunc,
this.likeCommentFunc, this.likeCommentFunc,
@@ -367,11 +375,19 @@ enum ExplorePageType {
override, override,
} }
typedef SearchFunction = Future<Res<List<Comic>>> Function( typedef SearchFunction =
String keyword, int page, List<String> searchOption); Future<Res<List<Comic>>> Function(
String keyword,
int page,
List<String> searchOption,
);
typedef SearchNextFunction = Future<Res<List<Comic>>> Function( typedef SearchNextFunction =
String keyword, String? next, List<String> searchOption); Future<Res<List<Comic>>> Function(
String keyword,
String? next,
List<String> searchOption,
);
class SearchPageData { class SearchPageData {
/// If this is not null, the default value of search options will be first element. /// If this is not null, the default value of search options will be first element.
@@ -398,11 +414,19 @@ class SearchOptions {
String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? ""; String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? "";
} }
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function( typedef CategoryComicsLoader =
String category, String? param, List<String> options, int page); Future<Res<List<Comic>>> Function(
String category,
String? param,
List<String> options,
int page,
);
typedef CategoryOptionsLoader = Future<Res<List<CategoryComicsOptions>>> Function( typedef CategoryOptionsLoader =
String category, String? param); Future<Res<List<CategoryComicsOptions>>> Function(
String category,
String? param,
);
class CategoryComicsData { class CategoryComicsData {
/// options /// options
@@ -419,7 +443,12 @@ class CategoryComicsData {
final RankingData? rankingData; final RankingData? rankingData;
const CategoryComicsData({this.options, this.optionsLoader, required this.load, this.rankingData}); const CategoryComicsData({
this.options,
this.optionsLoader,
required this.load,
this.rankingData,
});
} }
class RankingData { class RankingData {
@@ -428,7 +457,7 @@ class RankingData {
final Future<Res<List<Comic>>> Function(String option, int page)? load; final Future<Res<List<Comic>>> Function(String option, int page)? load;
final Future<Res<List<Comic>>> Function(String option, String? next)? final Future<Res<List<Comic>>> Function(String option, String? next)?
loadWithNext; loadWithNext;
const RankingData(this.options, this.load, this.loadWithNext); const RankingData(this.options, this.load, this.loadWithNext);
} }
@@ -447,7 +476,12 @@ class CategoryComicsOptions {
final List<String>? showWhen; final List<String>? showWhen;
const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen); const CategoryComicsOptions(
this.label,
this.options,
this.notShowWhen,
this.showWhen,
);
} }
class LinkHandler { class LinkHandler {

View File

@@ -541,7 +541,7 @@ class PageJumpTarget {
text: attributes?["text"] ?? attributes?["keyword"] ?? "", text: attributes?["text"] ?? attributes?["keyword"] ?? "",
sourceKey: sourceKey, sourceKey: sourceKey,
options: List.from(attributes?["options"] ?? []), options: List.from(attributes?["options"] ?? []),
), )
); );
} else if (page == "category") { } else if (page == "category") {
var key = ComicSource.find(sourceKey)!.categoryData!.key; var key = ComicSource.find(sourceKey)!.categoryData!.key;

View File

@@ -151,6 +151,8 @@ class ComicSourceParser {
version ?? "1.0.0", version ?? "1.0.0",
_parseCommentsLoader(), _parseCommentsLoader(),
_parseSendCommentFunc(), _parseSendCommentFunc(),
_parseChapterCommentsLoader(),
_parseSendChapterCommentFunc(),
_parseLikeFunc(), _parseLikeFunc(),
_parseVoteCommentFunc(), _parseVoteCommentFunc(),
_parseLikeCommentFunc(), _parseLikeCommentFunc(),
@@ -560,12 +562,16 @@ class ComicSourceParser {
res = await res; res = await res;
} }
if (res is! List) { if (res is! List) {
return Res.error("Invalid data:\nExpected: List\nGot: ${res.runtimeType}"); return Res.error(
"Invalid data:\nExpected: List\nGot: ${res.runtimeType}",
);
} }
var options = <CategoryComicsOptions>[]; var options = <CategoryComicsOptions>[];
for (var element in res) { for (var element in res) {
if (element is! Map) { if (element is! Map) {
return Res.error("Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}"); return Res.error(
"Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}",
);
} }
LinkedHashMap<String, String> map = LinkedHashMap<String, String>(); LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"] ?? []) { for (var option in element["options"] ?? []) {
@@ -582,13 +588,14 @@ class ComicSourceParser {
element["label"] ?? "", element["label"] ?? "",
map, map,
List.from(element["notShowWhen"] ?? []), List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"]), element["showWhen"] == null
? null
: List.from(element["showWhen"]),
), ),
); );
} }
return Res(options); return Res(options);
} } catch (e) {
catch(e) {
Log.error("Data Analysis", "Failed to load category options.\n$e"); Log.error("Data Analysis", "Failed to load category options.\n$e");
return Res.error(e.toString()); return Res.error(e.toString());
} }
@@ -1005,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() { GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc() {
if (!_checkExists("comic.onImageLoad")) { if (!_checkExists("comic.onImageLoad")) {
return null; return null;

View File

@@ -4,50 +4,90 @@ part of 'comic_source.dart';
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page); 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. /// 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( typedef ComicListBuilderWithNext =
String? next); Future<Res<List<Comic>>> Function(String? next);
typedef LoginFunction = Future<Res<bool>> Function(String, String); typedef LoginFunction = Future<Res<bool>> Function(String, String);
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id); typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function( typedef LoadComicPagesFunc =
String id, String? ep); Future<Res<List<String>>> Function(String id, String? ep);
typedef CommentsLoader = Future<Res<List<Comment>>> Function( typedef CommentsLoader =
String id, String? subId, int page, String? replyTo); Future<Res<List<Comment>>> Function(
String id,
String? subId,
int page,
String? replyTo,
);
typedef SendCommentFunc = Future<Res<bool>> Function( typedef ChapterCommentsLoader =
String id, String? subId, String content, String? replyTo); Future<Res<List<Comment>>> Function(
String comicId,
String epId,
int page,
String? replyTo,
);
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function( typedef SendCommentFunc =
String imageKey, String comicId, String epId)?; Future<Res<bool>> Function(
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function( String id,
String imageKey)?; String? subId,
String content,
String? replyTo,
);
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function( typedef SendChapterCommentFunc =
String comicId, String? next); Future<Res<bool>> Function(
String comicId,
String epId,
String content,
String? replyTo,
);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function( typedef GetImageLoadingConfigFunc =
String comicId, bool isLiking); 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. /// [isLiking] is true if the user is liking the comment, false if unliking.
/// return the new likes count or null. /// return the new likes count or null.
typedef LikeCommentFunc = Future<Res<int?>> Function( typedef LikeCommentFunc =
String comicId, String? subId, String commentId, bool isLiking); 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. /// [isUp] is true if the user is upvoting the comment, false if downvoting.
/// return the new vote count or null. /// return the new vote count or null.
typedef VoteCommentFunc = Future<Res<int?>> Function( typedef VoteCommentFunc =
String comicId, String? subId, String commentId, bool isUp, bool isCancel); Future<Res<int?>> Function(
String comicId,
String? subId,
String commentId,
bool isUp,
bool isCancel,
);
typedef HandleClickTagEvent = PageJumpTarget? Function( typedef HandleClickTagEvent =
String namespace, String tag); PageJumpTarget? Function(String namespace, String tag);
/// Handle tag suggestion selection event. Should return the text to insert /// Handle tag suggestion selection event. Should return the text to insert
/// into the search field. /// into the search field.
typedef TagSuggestionSelectFunc = String Function( typedef TagSuggestionSelectFunc = String Function(String namespace, String tag);
String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star. /// [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);

View File

@@ -14,14 +14,14 @@ extension Navigation on BuildContext {
return Navigator.of(this).canPop(); return Navigator.of(this).canPop();
} }
Future<T?> to<T>(Widget Function() builder) { Future<T?> to<T>(Widget Function() builder,) {
return Navigator.of(this) return Navigator.of(this).push<T>(AppPageRoute(
.push<T>(AppPageRoute(builder: (context) => builder())); builder: (context) => builder()));
} }
Future<void> toReplacement<T>(Widget Function() builder) { Future<void> toReplacement<T>(Widget Function() builder) {
return Navigator.of(this) return Navigator.of(this).pushReplacement(AppPageRoute(
.pushReplacement(AppPageRoute(builder: (context) => builder())); builder: (context) => builder()));
} }
double get width => MediaQuery.of(this).size.width; double get width => MediaQuery.of(this).size.width;

View File

@@ -441,7 +441,7 @@ class ImageFavoriteManager with ChangeNotifier {
for (var comic in comics) { for (var comic in comics) {
count += comic.images.length; count += comic.images.length;
for (var tag in comic.tags) { for (var tag in comic.tags) {
String finalTag = tag; String finalTag = tag.split(":").last;
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1; tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
} }

View File

@@ -217,6 +217,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
try { try {
var headers = Map<String, dynamic>.from(req["headers"] ?? {}); 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) { if (headers["user-agent"] == null && headers["User-Agent"] == null) {
headers["User-Agent"] = webUA; headers["User-Agent"] = webUA;
} }
@@ -244,7 +245,10 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: req["bytes"] == true responseType: req["bytes"] == true
? ResponseType.bytes ? ResponseType.bytes
: ResponseType.plain, : ResponseType.plain,
headers: headers)); headers: headers,
extra: extra,
)
);
} catch (e) { } catch (e) {
error = e.toString(); error = e.toString();
} }
@@ -436,83 +440,72 @@ mixin class _JSEngineApi {
return Uint8List.fromList(hmac.convert(value).bytes); return Uint8List.fromList(hmac.convert(value).bytes);
} }
case "aes-ecb": case "aes-ecb":
if (!isEncode) { var key = data["key"];
var key = data["key"]; var cipher = ECBBlockCipher(AESEngine());
var cipher = ECBBlockCipher(AESEngine()); cipher.init(
cipher.init( isEncode,
false, KeyParameter(key),
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": case "aes-cbc":
if (!isEncode) { var key = data["key"];
var key = data["key"]; var iv = data["iv"];
var iv = data["iv"]; var cipher = CBCBlockCipher(AESEngine());
var cipher = CBCBlockCipher(AESEngine()); cipher.init(isEncode, ParametersWithIV(KeyParameter(key), iv));
cipher.init(false, ParametersWithIV(KeyParameter(key), iv)); var offset = 0;
var offset = 0; var result = Uint8List(value.length);
var result = Uint8List(value.length); while (offset < value.length) {
while (offset < value.length) { offset += cipher.processBlock(
offset += cipher.processBlock( value,
value, offset,
offset, result,
result, offset,
offset, );
);
}
return result;
} }
return null; return result;
case "aes-cfb": case "aes-cfb":
if (!isEncode) { var key = data["key"];
var key = data["key"]; var iv = data["iv"];
var blockSize = data["blockSize"]; var blockSize = data["blockSize"];
var cipher = CFBBlockCipher(AESEngine(), blockSize); var cipher = CFBBlockCipher(AESEngine(), blockSize);
cipher.init(false, KeyParameter(key)); cipher.init(isEncode, ParametersWithIV(KeyParameter(key), iv));
var offset = 0; var offset = 0;
var result = Uint8List(value.length); var result = Uint8List(value.length);
while (offset < value.length) { while (offset < value.length) {
offset += cipher.processBlock( offset += cipher.processBlock(
value, value,
offset, offset,
result, result,
offset, offset,
); );
}
return result;
} }
return null; return result;
case "aes-ofb": case "aes-ofb":
if (!isEncode) { var key = data["key"];
var key = data["key"]; var blockSize = data["blockSize"];
var blockSize = data["blockSize"]; var cipher = OFBBlockCipher(AESEngine(), blockSize);
var cipher = OFBBlockCipher(AESEngine(), blockSize); cipher.init(isEncode, KeyParameter(key));
cipher.init(false, KeyParameter(key)); var offset = 0;
var offset = 0; var result = Uint8List(value.length);
var result = Uint8List(value.length); while (offset < value.length) {
while (offset < value.length) { offset += cipher.processBlock(
offset += cipher.processBlock( value,
value, offset,
offset, result,
result, offset,
offset, );
);
}
return result;
} }
return null; return result;
case "rsa": case "rsa":
if (!isEncode) { if (!isEncode) {
var key = data["key"]; var key = data["key"];

View File

@@ -153,7 +153,7 @@ class LocalComic with HistoryMixin implements Comic {
), ),
author: subtitle, author: subtitle,
tags: tags, tags: tags,
), )
); );
} }

View File

@@ -96,11 +96,28 @@ class MyLogInterceptor implements Interceptor {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
const String headerMask = "********";
const String dataMask = "****** DATA_PROTECTED ******";
Log.info( Log.info(
"Network", "Network",
"${options.method} ${options.uri}\n" "${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n" "headers:\n${
"data:\n${options.data}"); 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.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15); options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15); options.sendTimeout = const Duration(seconds: 15);

View File

@@ -128,10 +128,15 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
var head = var head =
await controller.evaluateJavascript("document.head.innerHTML") ?? await controller.evaluateJavascript("document.head.innerHTML") ??
""; "";
var body =
await controller.evaluateJavascript("document.body.innerHTML") ??
"";
Log.info("Cloudflare", "Checking head: $head"); Log.info("Cloudflare", "Checking head: $head");
var isChallenging = head.contains('#challenge-success-text') || var isChallenging = head.contains('#challenge-success-text') ||
head.contains("#challenge-error-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) { if (!isChallenging) {
Log.info( Log.info(
"Cloudflare", "Cloudflare",
@@ -159,10 +164,14 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
void check(InAppWebViewController controller) async { void check(InAppWebViewController controller) async {
var head = await controller.evaluateJavascript( var head = await controller.evaluateJavascript(
source: "document.head.innerHTML") as String; source: "document.head.innerHTML") as String;
var body = await controller.evaluateJavascript(
source: "document.body.innerHTML") as String;
Log.info("Cloudflare", "Checking head: $head"); Log.info("Cloudflare", "Checking head: $head");
var isChallenging = head.contains('#challenge-success-text') || var isChallenging = head.contains('#challenge-success-text') ||
head.contains("#challenge-error-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) { if (!isChallenging) {
Log.info( Log.info(
"Cloudflare", "Cloudflare",

View File

@@ -52,7 +52,11 @@ abstract class ImageDownloader {
responseType: ResponseType.stream, 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']); data: configs['data']);
var stream = req.data?.stream ?? (throw "Error: Empty response body."); var stream = req.data?.stream ?? (throw "Error: Empty response body.");
int? expectedBytes = req.data!.contentLength; int? expectedBytes = req.data!.contentLength;
@@ -181,7 +185,15 @@ abstract class ImageDownloader {
} }
if (configs['onResponse'] is JSInvokable) { if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(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(); (configs['onResponse'] as JSInvokable).free();
} }

View File

@@ -17,39 +17,50 @@ class CategoriesPage extends StatefulWidget {
State<CategoriesPage> createState() => _CategoriesPageState(); State<CategoriesPage> createState() => _CategoriesPageState();
} }
class _CategoriesPageState extends State<CategoriesPage> { class _CategoriesPageState extends State<CategoriesPage>
with
TickerProviderStateMixin,
AutomaticKeepAliveClientMixin<CategoriesPage> {
var categories = <String>[]; var categories = <String>[];
late TabController controller;
void onSettingsChanged() { void onSettingsChanged() {
var categories = var categories = List.from(
List.from(appdata.settings["categories"]).whereType<String>().toList(); appdata.settings["categories"],
).whereType<String>().toList();
var allCategories = ComicSource.all() var allCategories = ComicSource.all()
.map((e) => e.categoryData?.key) .map((e) => e.categoryData?.key)
.where((element) => element != null) .where((element) => element != null)
.map((e) => e!) .map((e) => e!)
.toList(); .toList();
categories = categories = categories
categories.where((element) => allCategories.contains(element)).toList(); .where((element) => allCategories.contains(element))
.toList();
if (!categories.isEqualTo(this.categories)) { if (!categories.isEqualTo(this.categories)) {
setState(() { setState(() {
this.categories = categories; this.categories = categories;
}); });
controller = TabController(length: categories.length, vsync: this);
} }
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
var categories = var categories = List.from(
List.from(appdata.settings["categories"]).whereType<String>().toList(); appdata.settings["categories"],
).whereType<String>().toList();
var allCategories = ComicSource.all() var allCategories = ComicSource.all()
.map((e) => e.categoryData?.key) .map((e) => e.categoryData?.key)
.where((element) => element != null) .where((element) => element != null)
.map((e) => e!) .map((e) => e!)
.toList(); .toList();
this.categories = this.categories = categories
categories.where((element) => allCategories.contains(element)).toList(); .where((element) => allCategories.contains(element))
.toList();
appdata.settings.addListener(onSettingsChanged); appdata.settings.addListener(onSettingsChanged);
controller = TabController(length: categories.length, vsync: this);
} }
void addPage() { void addPage() {
@@ -59,6 +70,7 @@ class _CategoriesPageState extends State<CategoriesPage> {
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
controller.dispose();
appdata.settings.removeListener(onSettingsChanged); appdata.settings.removeListener(onSettingsChanged);
} }
@@ -85,46 +97,45 @@ class _CategoriesPageState extends State<CategoriesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context);
if (categories.isEmpty) { if (categories.isEmpty) {
return buildEmpty(); return buildEmpty();
} }
return Material( return Material(
child: DefaultTabController( child: Column(
length: categories.length, children: [
key: Key(categories.toString()), AppTabBar(
child: Column( controller: controller,
children: [ key: PageStorageKey(categories.toString()),
AppTabBar( tabs: categories.map((e) {
key: PageStorageKey(categories.toString()), String title = e;
tabs: categories.map((e) { try {
String title = e; title = getCategoryDataWithKey(e).title;
try { } catch (e) {
title = getCategoryDataWithKey(e).title; //
} catch (e) { }
// return Tab(text: title, key: Key(e));
} }).toList(),
return Tab( actionButton: TabActionButton(
text: title, icon: const Icon(Icons.add),
key: Key(e), text: "Add".tl,
); onPressed: addPage,
}).toList(), ),
actionButton: TabActionButton( ).paddingTop(context.padding.top),
icon: const Icon(Icons.add), Expanded(
text: "Add".tl, child: TabBarView(
onPressed: addPage, controller: controller,
), children: categories.map((e) => _CategoryPage(e)).toList(),
).paddingTop(context.padding.top), ),
Expanded( ),
child: TabBarView( ],
children: categories.map((e) => _CategoryPage(e)).toList(),
),
)
],
),
), ),
); );
} }
@override
bool get wantKeepAlive => true;
} }
typedef ClickTagCallback = void Function(String, String?); typedef ClickTagCallback = void Function(String, String?);
@@ -150,38 +161,42 @@ class _CategoryPage extends StatelessWidget {
var children = <Widget>[]; var children = <Widget>[];
if (data.enableRankingPage || data.buttons.isNotEmpty) { if (data.enableRankingPage || data.buttons.isNotEmpty) {
children.add(buildTitle(data.title)); children.add(buildTitle(data.title));
children.add(Padding( children.add(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16), Padding(
child: Wrap( padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
children: [ child: Wrap(
if (data.enableRankingPage) children: [
buildTag("Ranking".tl, () { if (data.enableRankingPage)
context.to(() => RankingPage(categoryKey: data.key)); buildTag("Ranking".tl, () {
}), context.to(() => RankingPage(categoryKey: data.key));
for (var buttonData in data.buttons) }),
buildTag(buttonData.label.tl, buttonData.onTap) for (var buttonData in data.buttons)
], buildTag(buttonData.label.tl, buttonData.onTap),
],
),
), ),
)); );
} }
for (var part in data.categories) { for (var part in data.categories) {
if (part.enableRandom) { if (part.enableRandom) {
children.add(StatefulBuilder(builder: (context, updater) { children.add(
return Column( StatefulBuilder(
mainAxisSize: MainAxisSize.min, builder: (context, updater) {
crossAxisAlignment: CrossAxisAlignment.start, return Column(
children: [ mainAxisSize: MainAxisSize.min,
buildTitleWithRefresh(part.title, () => updater(() {})), crossAxisAlignment: CrossAxisAlignment.start,
buildTags(part.categories) children: [
], buildTitleWithRefresh(part.title, () => updater(() {})),
); buildTags(part.categories),
})); ],
);
},
),
);
} else { } else {
children.add(buildTitle(part.title)); children.add(buildTitle(part.title));
children.add( children.add(buildTags(part.categories));
buildTags(part.categories),
);
} }
} }
return SingleChildScrollView( return SingleChildScrollView(
@@ -195,8 +210,10 @@ class _CategoryPage extends StatelessWidget {
Widget buildTitle(String title) { Widget buildTitle(String title) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 5, 10), padding: const EdgeInsets.fromLTRB(16, 10, 5, 10),
child: Text(title.tl, child: Text(
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500)), title.tl,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
),
); );
} }
@@ -207,21 +224,16 @@ class _CategoryPage extends StatelessWidget {
children: [ children: [
Text( Text(
title.tl, title.tl,
style: const TextStyle( style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
fontSize: 20,
fontWeight: FontWeight.w500,
),
), ),
const Spacer(), const Spacer(),
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh)) IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh)),
], ],
), ),
); );
} }
Widget buildTags( Widget buildTags(List<CategoryItem> categories) {
List<CategoryItem> categories,
) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16), padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
child: Wrap( child: Wrap(

View File

@@ -115,7 +115,7 @@ abstract mixin class _ComicPageActions {
history: history ?? History.fromModel(model: comic, ep: 0, page: 0), history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '', author: comic.findAuthor() ?? '',
tags: comic.plainTags, tags: comic.plainTags,
), )
) )
.then((_) { .then((_) {
onReadEnd(); onReadEnd();
@@ -155,64 +155,60 @@ abstract mixin class _ComicPageActions {
builder: (context, setState) { builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: "Download".tl, title: "Download".tl,
content: Column( content: RadioGroup<int>(
mainAxisSize: MainAxisSize.min, groupValue: selected,
children: [ onChanged: (v) {
RadioListTile<int>( setState(() {
value: -1, selected = v ?? selected;
groupValue: selected, });
title: Text("Normal".tl), },
onChanged: (v) { child: Column(
setState(() { mainAxisSize: MainAxisSize.min,
selected = v!; children: [
}); RadioListTile<int>(
}, value: -1,
), title: Text("Normal".tl),
ExpansionTile(
title: Text("Archive".tl),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
), ),
collapsedShape: const RoundedRectangleBorder( ExpansionTile(
borderRadius: BorderRadius.zero, title: Text("Archive".tl),
), shape: const RoundedRectangleBorder(
onExpansionChanged: (b) { borderRadius: BorderRadius.zero,
if (!isLoading && b && archives == null) { ),
isLoading = true; collapsedShape: const RoundedRectangleBorder(
comicSource.archiveDownloader! borderRadius: BorderRadius.zero,
.getArchives(comic.id) ),
.then((value) { onExpansionChanged: (b) {
if (value.success) { if (!isLoading && b && archives == null) {
archives = value.data; isLoading = true;
} else { comicSource.archiveDownloader!
App.rootContext .getArchives(comic.id)
.showMessage(message: value.errorMessage!); .then((value) {
} if (value.success) {
setState(() { archives = value.data;
isLoading = false; } else {
App.rootContext
.showMessage(message: value.errorMessage!);
}
setState(() {
isLoading = false;
});
}); });
}); }
} },
}, children: [
children: [ if (archives == null)
if (archives == null) const ListLoadingIndicator().toCenter()
const ListLoadingIndicator().toCenter() else
else for (int i = 0; i < archives!.length; i++)
for (int i = 0; i < archives!.length; i++) RadioListTile<int>(
RadioListTile<int>( value: i,
value: i, title: Text(archives![i].title),
groupValue: selected, subtitle: Text(archives![i].description),
onChanged: (v) { )
setState(() { ],
selected = v!; )
}); ],
}, ),
title: Text(archives![i].title),
subtitle: Text(archives![i].description),
)
],
)
],
), ),
actions: [ actions: [
Button.filled( Button.filled(
@@ -237,10 +233,12 @@ abstract mixin class _ComicPageActions {
isGettingLink = false; isGettingLink = false;
}); });
} else if (context.mounted) { } else if (context.mounted) {
LocalManager() if (res.data.isNotEmpty) {
LocalManager()
.addTask(ArchiveDownloadTask(res.data, comic)); .addTask(ArchiveDownloadTask(res.data, comic));
App.rootContext App.rootContext
.showMessage(message: "Download started".tl); .showMessage(message: "Download started".tl);
}
context.pop(); context.pop();
} }
}, },

View File

@@ -197,11 +197,12 @@ class _NetworkSectionState extends State<_NetworkSection> {
if (res.subData is List) { if (res.subData is List) {
final list = List<String>.from(res.subData); final list = List<String>.from(res.subData);
if (list.isNotEmpty) { if (list.isNotEmpty) {
addedFolders = {list.first}; addedFolders = list.toSet();
localIsFavorite = true;
} else { } else {
addedFolders.clear(); addedFolders.clear();
localIsFavorite = false;
} }
localIsFavorite = addedFolders.isNotEmpty;
} else { } else {
addedFolders.clear(); addedFolders.clear();
localIsFavorite = false; localIsFavorite = false;
@@ -352,62 +353,6 @@ class _NetworkSectionState extends State<_NetworkSection> {
} }
Widget _buildMultiFolder() { Widget _buildMultiFolder() {
if (localIsFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
return ListTile(
title: Row(
children: [
Text("Network Favorites".tl),
const SizedBox(width: 8),
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: true,
onTap: () async {
setState(() {
isLoading = true;
});
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, '', false, null);
if (res.success) {
// Invalidate network cache so subsequent loads see latest
NetworkCacheManager().clear();
setState(() {
localIsFavorite = false;
});
widget.onFavorite(false);
App.rootContext.showMessage(message: "Removed".tl);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
isLoading = false;
});
},
),
);
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -425,8 +370,10 @@ class _NetworkSectionState extends State<_NetworkSection> {
var name = entry.value; var name = entry.value;
var id = entry.key; var id = entry.key;
var isAdded = addedFolders.contains(id); var isAdded = addedFolders.contains(id);
var hasSelection = addedFolders.isNotEmpty; // When `singleFolderForSingleComic` is `false`, all add and remove buttons are clickable.
var enabled = !hasSelection || isAdded; // 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( return ListTile(
title: Row( title: Row(
@@ -469,11 +416,9 @@ class _NetworkSectionState extends State<_NetworkSection> {
NetworkCacheManager().clear(); NetworkCacheManager().clear();
setState(() { setState(() {
if (isAdded) { if (isAdded) {
addedFolders.clear(); addedFolders.remove(id);
} else { } else {
addedFolders addedFolders.add(id);
..clear()
..add(id);
} }
// sync local flag for single-folder-per-comic logic and parent // sync local flag for single-folder-per-comic logic and parent
localIsFavorite = addedFolders.isNotEmpty; localIsFavorite = addedFolders.isNotEmpty;

View File

@@ -1245,6 +1245,15 @@ class _LoginPageState extends State<_LoginPage> {
if (widget.config.checkLoginStatus != null && if (widget.config.checkLoginStatus != null &&
widget.config.checkLoginStatus!(url, title)) { widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? []; 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( SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url), Uri.parse(url),
cookies, cookies,
@@ -1306,6 +1315,20 @@ class _LoginPageState extends State<_LoginPage> {
Uri.parse(url), Uri.parse(url),
cookies, 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; success = true;
widget.config.onLoginWithWebviewSuccess?.call(); widget.config.onLoginWithWebviewSuccess?.call();
webview.close(); webview.close();

View File

@@ -11,6 +11,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';

View File

@@ -30,6 +30,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
late List<String> added = []; late List<String> added = [];
String keyword = ""; String keyword = "";
bool searchHasUpper = false;
bool searchMode = false; bool searchMode = false;
@@ -43,6 +44,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
bool isLoading = false; bool isLoading = false;
late String readFilterSelect;
var searchResults = <FavoriteItem>[]; var searchResults = <FavoriteItem>[];
void updateSearchResult() { void updateSearchResult() {
@@ -104,27 +107,40 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
setState(() {}); 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) { bool matchKeyword(String keyword, FavoriteItem comic) {
var list = keyword.split(" "); var list = keyword.split(" ");
for (var k in list) { for (var k in list) {
if (k.isEmpty) continue; if (k.isEmpty) continue;
if (comic.title.contains(k)) { if (checkKeyWordMatch(k, comic.title, false)) {
continue; continue;
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) { } else if (comic.subtitle != null && checkKeyWordMatch(k, comic.subtitle!, false)) {
continue; continue;
} else if (comic.tags.any((tag) { } else if (comic.tags.any((tag) {
if (tag == k) { if (checkKeyWordMatch(k, tag, true)) {
return true; return true;
} else if (tag.contains(':') && tag.split(':')[1] == k) { } else if (tag.contains(':') && checkKeyWordMatch(k, tag.split(':')[1], true)) {
return true; return true;
} else if (App.locale.languageCode != 'en' && } else if (App.locale.languageCode != 'en' &&
tag.translateTagsToCN == k) { checkKeyWordMatch(k, tag.translateTagsToCN, true)) {
return true; return true;
} }
return false; return false;
})) { })) {
continue; continue;
} else if (comic.author == k) { } else if (checkKeyWordMatch(k, comic.author, true)) {
continue; continue;
} }
return false; return false;
@@ -132,6 +148,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return true; 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 // Convert keyword to traditional Chinese to match comics
bool matchKeywordT(String keyword, FavoriteItem comic) { bool matchKeywordT(String keyword, FavoriteItem comic) {
if (!OpenCC.hasChineseSimplified(keyword)) { if (!OpenCC.hasChineseSimplified(keyword)) {
@@ -149,9 +176,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
keyword = OpenCC.traditionalToSimplified(keyword); keyword = OpenCC.traditionalToSimplified(keyword);
return matchKeyword(keyword, comic); return matchKeyword(keyword, comic);
} }
@override @override
void initState() { void initState() {
readFilterSelect = appdata.implicitData["local_favorites_read_filter"] ??
readFilterList[0];
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
if (!isAllFolder) { if (!isAllFolder) {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder); 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( Tooltip(
message: "Search".tl, message: "Search".tl,
child: IconButton( child: IconButton(
@@ -454,15 +507,15 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
actions: [ actions: [
MenuButton(entries: [ MenuButton(entries: [
if (!isAllFolder) if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.drive_file_move, icon: Icons.drive_file_move,
text: "Move to folder".tl, text: "Move to folder".tl,
onClick: () => favoriteOption('move')), onClick: () => favoriteOption('move')),
if (!isAllFolder) if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.copy, icon: Icons.copy,
text: "Copy to folder".tl, text: "Copy to folder".tl,
onClick: () => favoriteOption('add')), onClick: () => favoriteOption('add')),
MenuEntry( MenuEntry(
icon: Icons.select_all, icon: Icons.select_all,
text: "Select All".tl, text: "Select All".tl,
@@ -519,9 +572,23 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
onClick: () { onClick: () {
final c = selectedComics.keys.first as FavoriteItem; final c = selectedComics.keys.first as FavoriteItem;
App.rootContext.to(() => ReaderWithLoading( App.rootContext.to(() => ReaderWithLoading(
id: c.id, id: c.id,
sourceKey: c.sourceKey, 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,
)
);
}, },
), ),
]), ]),
@@ -553,6 +620,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
), ),
onChanged: (v) { onChanged: (v) {
keyword = v; keyword = v;
searchHasUpper = keyword.contains(RegExp(r'[A-Z]'));
updateSearchResult(); updateSearchResult();
}, },
).paddingBottom(8).paddingRight(8), ).paddingBottom(8).paddingRight(8),
@@ -568,7 +636,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
) )
else else
SliverGridComics( SliverGridComics(
comics: searchMode ? searchResults : comics, comics: searchMode ? searchResults : filterComics(comics),
selections: selectedComics, selections: selectedComics,
menuBuilder: (c) { menuBuilder: (c) {
return [ return [
@@ -621,13 +689,13 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
() => ReaderWithLoading( () => ReaderWithLoading(
id: c.id, id: c.id,
sourceKey: c.sourceKey, sourceKey: c.sourceKey,
), )
); );
}, },
), ),
]; ];
}, },
onTap: (c) { onTap: (c, heroID) {
if (multiSelectMode) { if (multiSelectMode) {
setState(() { setState(() {
if (selectedComics.containsKey(c as FavoriteItem)) { if (selectedComics.containsKey(c as FavoriteItem)) {
@@ -639,18 +707,22 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
lastSelectedIndex = comics.indexOf(c); lastSelectedIndex = comics.indexOf(c);
}); });
} else if (appdata.settings["onClickFavorite"] == "viewDetail") { } else if (appdata.settings["onClickFavorite"] == "viewDetail") {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
} else {
App.mainNavigatorKey?.currentContext?.to( App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading( () => ComicPage(
id: c.id, id: c.id,
sourceKey: c.sourceKey, 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(() { setState(() {
if (!multiSelectMode) { if (!multiSelectMode) {
multiSelectMode = true; multiSelectMode = true;
@@ -1075,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),
),
],
);
}
}

View File

@@ -299,6 +299,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
); );
} }
updateFollowUpdatesUI(); updateFollowUpdatesUI();
appdata.saveData();
}, },
); );
}, },

View File

@@ -211,7 +211,7 @@ class _HistoryPageState extends State<HistoryPage> {
selections: selectedComics, selections: selectedComics,
onLongPressed: null, onLongPressed: null,
onTap: multiSelectMode onTap: multiSelectMode
? (c) { ? (c, heroID) {
setState(() { setState(() {
if (selectedComics.containsKey(c as History)) { if (selectedComics.containsKey(c as History)) {
selectedComics.remove(c); selectedComics.remove(c);

View File

@@ -302,13 +302,18 @@ class _HistoryState extends State<_History> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: history.length, itemCount: history.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final heroID = history[index].id.hashCode;
return SimpleComicTile( return SimpleComicTile(
comic: history[index], comic: history[index],
heroID: heroID,
onTap: () { onTap: () {
context.to( context.to(
() => ComicPage( () => ComicPage(
id: history[index].id, id: history[index].id,
sourceKey: history[index].type.sourceKey, 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( Container(
margin: const EdgeInsets.symmetric(horizontal: 8), margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -405,9 +412,22 @@ class _LocalState extends State<_Local> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: local.length, itemCount: local.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return SimpleComicTile(comic: local[index]) final heroID = local[index].id.hashCode;
.paddingHorizontal(8) return SimpleComicTile(
.paddingVertical(2); 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), ).paddingHorizontal(8),
@@ -514,51 +534,53 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
) )
: Column( : RadioGroup<int>(
key: key, groupValue: type,
crossAxisAlignment: CrossAxisAlignment.start, onChanged: (value) {
children: [ setState(() {
const SizedBox(width: 600), type = value ?? type;
...List.generate(importMethods.length, (index) { });
return RadioListTile( },
title: Text(importMethods[index]), child: Column(
value: index, key: key,
groupValue: type, crossAxisAlignment: CrossAxisAlignment.start,
onChanged: (value) { children: [
setState(() { const SizedBox(width: 600),
type = value as int; ...List.generate(importMethods.length, (index) {
}); return RadioListTile<int>(
}, title: Text(importMethods[index]),
); value: index,
}), );
if (type != 4) }),
ListTile( if (type != 4)
title: Text("Add to favorites".tl), ListTile(
trailing: Select( title: Text("Add to favorites".tl),
current: selectedFolder, trailing: Select(
values: folders, current: selectedFolder,
minWidth: 112, values: folders,
onTap: (v) { minWidth: 112,
setState(() { onTap: (v) {
selectedFolder = folders[v]; setState(() {
}); selectedFolder = folders[v];
}, });
), },
).paddingHorizontal(8), ),
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3) ).paddingHorizontal(8),
CheckboxListTile( if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
enabled: true, CheckboxListTile(
title: Text("Copy to app local path".tl), enabled: true,
value: copyToLocalFolder, title: Text("Copy to app local path".tl),
onChanged: (v) { value: copyToLocalFolder,
setState(() { onChanged: (v) {
copyToLocalFolder = !copyToLocalFolder; setState(() {
}); copyToLocalFolder = !copyToLocalFolder;
}).paddingHorizontal(8), });
const SizedBox(height: 8), }).paddingHorizontal(8),
Text(info).paddingHorizontal(24), const SizedBox(height: 8),
], Text(info).paddingHorizontal(24),
), ],
),
),
actions: [ actions: [
Button.text( Button.text(
child: Row( child: Row(
@@ -872,7 +894,9 @@ class _ImageFavoritesState extends State<ImageFavorites> {
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () { onTap: () {
context.to(() => const ImageFavoritesPage()); context.to(
() => const ImageFavoritesPage()
);
}, },
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -991,7 +1015,9 @@ class _ImageFavoritesState extends State<ImageFavorites> {
maxCount: maxCount, maxCount: maxCount,
enableTranslation: displayType != 2, enableTranslation: displayType != 2,
onTap: (text) { onTap: (text) {
context.to(() => ImageFavoritesPage(initialKeyword: text)); context.to(
() => ImageFavoritesPage(initialKeyword: text),
);
}, },
); );
}).toList(), }).toList(),

View File

@@ -404,21 +404,23 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
children: [ children: [
tabBar, tabBar,
TabViewBody(children: [ TabViewBody(children: [
Column( RadioGroup<ImageFavoriteSortType>(
children: ImageFavoriteSortType.values groupValue: sortType,
.map( onChanged: (v) {
(e) => RadioListTile<ImageFavoriteSortType>( setState(() {
title: Text(e.value.tl), sortType = v ?? sortType;
value: e, });
groupValue: sortType, },
onChanged: (v) { child: Column(
setState(() { children: ImageFavoriteSortType.values
sortType = v!; .map(
}); (e) => RadioListTile<ImageFavoriteSortType>(
}, title: Text(e.value.tl),
), value: e,
) ),
.toList(), )
.toList(),
),
), ),
Column( Column(
children: [ children: [

View File

@@ -243,7 +243,7 @@ class _ImageFavoritesPhotoViewState extends State<ImageFavoritesPhotoView> {
sourceKey: comic.sourceKey, sourceKey: comic.sourceKey,
initialEp: ep, initialEp: ep,
initialPage: page, initialPage: page,
), )
); );
}, },
), ),

View File

@@ -70,39 +70,29 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: "Sort".tl, title: "Sort".tl,
content: Column( content: RadioGroup<LocalSortType>(
children: [ groupValue: sortType,
RadioListTile<LocalSortType>( onChanged: (v) {
title: Text("Name".tl), setState(() {
value: LocalSortType.name, sortType = v ?? sortType;
groupValue: sortType, });
onChanged: (v) { },
setState(() { child: Column(
sortType = v!; children: [
}); RadioListTile<LocalSortType>(
}, title: Text("Name".tl),
), value: LocalSortType.name,
RadioListTile<LocalSortType>( ),
title: Text("Date".tl), RadioListTile<LocalSortType>(
value: LocalSortType.timeAsc, title: Text("Date".tl),
groupValue: sortType, value: LocalSortType.timeAsc,
onChanged: (v) { ),
setState(() { RadioListTile<LocalSortType>(
sortType = v!; title: Text("Date Desc".tl),
}); value: LocalSortType.timeDesc,
}, ),
), ],
RadioListTile<LocalSortType>( ),
title: Text("Date Desc".tl),
value: LocalSortType.timeDesc,
groupValue: sortType,
onChanged: (v) {
setState(() {
sortType = v!;
});
},
),
],
), ),
actions: [ actions: [
FilledButton( FilledButton(
@@ -268,40 +258,52 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
else if (searchMode) else if (searchMode)
SliverAppbar( SliverAppbar(
leading: Tooltip( leading: Tooltip(
message: "Cancel".tl, message: multiSelectMode ? "Cancel".tl : "Cancel".tl,
child: IconButton( child: IconButton(
icon: const Icon(Icons.close), icon: multiSelectMode
? const Icon(Icons.close)
: const Icon(Icons.close),
onPressed: () { onPressed: () {
setState(() { if (multiSelectMode) {
searchMode = false; setState(() {
keyword = ""; multiSelectMode = false;
update(); selectedComics.clear();
}); });
} else {
setState(() {
searchMode = false;
keyword = "";
update();
});
}
}, },
), ),
), ),
title: TextField( title: multiSelectMode
autofocus: true, ? Text(selectedComics.length.toString())
decoration: InputDecoration( : TextField(
hintText: "Search".tl, autofocus: true,
border: InputBorder.none, decoration: InputDecoration(
), hintText: "Search".tl,
onChanged: (v) { border: InputBorder.none,
keyword = v; ),
update(); onChanged: (v) {
}, keyword = v;
), update();
},
),
actions: multiSelectMode ? selectActions : null,
), ),
SliverGridComics( SliverGridComics(
comics: comics, comics: comics,
selections: selectedComics, selections: selectedComics,
onLongPressed: (c) { onLongPressed: (c, heroID) {
setState(() { setState(() {
multiSelectMode = true; multiSelectMode = true;
selectedComics[c as LocalComic] = true; selectedComics[c as LocalComic] = true;
}); });
}, },
onTap: (c) { onTap: (c, heroID) {
if (multiSelectMode) { if (multiSelectMode) {
setState(() { setState(() {
if (selectedComics.containsKey(c as LocalComic)) { if (selectedComics.containsKey(c as LocalComic)) {
@@ -354,6 +356,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return PopScope( return PopScope(
canPop: !multiSelectMode && !searchMode, canPop: !multiSelectMode && !searchMode,
onPopInvokedWithResult: (didPop, result) { onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (multiSelectMode) { if (multiSelectMode) {
setState(() { setState(() {
multiSelectMode = false; multiSelectMode = false;

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

View File

@@ -286,8 +286,9 @@ class _GalleryModeState extends State<_GalleryMode>
); );
} }
final viewportSize = MediaQuery.of(context).size;
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
childSize: reader.size * 2, childSize: viewportSize,
controller: photoViewControllers[index], controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0, minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0, maxScale: PhotoViewComputedScale.covered * 10.0,

View File

@@ -25,6 +25,7 @@ import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/global_state.dart'; import 'package:venera/foundation/global_state.dart';
import 'package:venera/foundation/history.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/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
@@ -54,6 +55,8 @@ part 'loading.dart';
part 'chapters.dart'; part 'chapters.dart';
part 'chapter_comments.dart';
extension _ReaderContext on BuildContext { extension _ReaderContext on BuildContext {
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!; _ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
@@ -163,14 +166,27 @@ class _ReaderState extends State<Reader>
} }
if (widget.initialPage != null) { if (widget.initialPage != null) {
page = widget.initialPage!; page = widget.initialPage!;
if (page < 1) {
page = 1;
}
} }
// mode = ReaderMode.fromKey(appdata.settings['readerMode']); // 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; history = widget.history;
if (!appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSystemStatusBar')) { if (!appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'showSystemStatusBar',
)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
} }
if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) { if (appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'enableTurnPageByVolumeKey',
)) {
handleVolumeEvent(); handleVolumeEvent();
} }
setImageCacheSize(); setImageCacheSize();
@@ -208,8 +224,10 @@ class _ReaderState extends State<Reader>
} else { } else {
maxImageCacheSize = 500 << 20; maxImageCacheSize = 500 << 20;
} }
Log.info("Reader", Log.info(
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize"); "Reader",
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize",
);
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize; PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
} }
@@ -239,13 +257,15 @@ class _ReaderState extends State<Reader>
onKeyEvent: onKeyEvent, onKeyEvent: onKeyEvent,
child: Overlay( child: Overlay(
initialEntries: [ initialEntries: [
OverlayEntry(builder: (context) { OverlayEntry(
return _ReaderScaffold( builder: (context) {
child: _ReaderGestureDetector( return _ReaderScaffold(
child: _ReaderImages(key: Key(chapter.toString())), child: _ReaderGestureDetector(
), child: _ReaderImages(key: Key(chapter.toString())),
); ),
}) );
},
),
], ],
), ),
); );
@@ -382,16 +402,29 @@ abstract mixin class _ImagePerPageHandler {
} }
} }
bool showSingleImageOnFirstPage() => bool showSingleImageOnFirstPage() => appdata.settings.getReaderSetting(
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage'); cid,
type.sourceKey,
'showSingleImageOnFirstPage',
);
/// The number of images displayed on one screen /// The number of images displayed on one screen
int get imagesPerPage { int get imagesPerPage {
if (mode.isContinuous) return 1; if (mode.isContinuous) return 1;
if (isPortrait) { if (isPortrait) {
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1; return appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'readerScreenPicNumberForPortrait',
) ??
1;
} else { } else {
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForLandscape') ?? 1; return appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'readerScreenPicNumberForLandscape',
) ??
1;
} }
} }
@@ -400,15 +433,22 @@ abstract mixin class _ImagePerPageHandler {
int currentImagesPerPage = imagesPerPage; int currentImagesPerPage = imagesPerPage;
bool currentOrientation = isPortrait; bool currentOrientation = isPortrait;
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) { if (_lastImagesPerPage != currentImagesPerPage ||
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage); _lastOrientation != currentOrientation) {
_adjustPageForImagesPerPageChange(
_lastImagesPerPage,
currentImagesPerPage,
);
_lastImagesPerPage = currentImagesPerPage; _lastImagesPerPage = currentImagesPerPage;
_lastOrientation = currentOrientation; _lastOrientation = currentOrientation;
} }
} }
/// Adjust the page number when the number of images per page changes /// 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; int previousImageIndex = 1;
if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) { if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) {
previousImageIndex = (page - 1) * oldImagesPerPage + 1; previousImageIndex = (page - 1) * oldImagesPerPage + 1;
@@ -431,7 +471,7 @@ abstract mixin class _ImagePerPageHandler {
newPage = previousImageIndex; newPage = previousImageIndex;
} }
page = newPage>0 ? newPage : 1; page = newPage > 0 ? newPage : 1;
} }
} }
@@ -466,10 +506,7 @@ abstract mixin class _VolumeListener {
if (volumeListener != null) { if (volumeListener != null) {
volumeListener?.cancel(); volumeListener?.cancel();
} }
volumeListener = VolumeListener( volumeListener = VolumeListener(onDown: onDown, onUp: onUp)..listen();
onDown: onDown,
onUp: onUp,
)..listen();
} }
void stopVolumeEvent() { void stopVolumeEvent() {
@@ -504,7 +541,8 @@ abstract mixin class _ReaderLocation {
void update(); 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; _ImageViewController? _imageViewController;
@@ -585,7 +623,11 @@ abstract mixin class _ReaderLocation {
autoPageTurningTimer!.cancel(); autoPageTurningTimer!.cancel();
autoPageTurningTimer = null; autoPageTurningTimer = null;
} else { } 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), (_) { autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
if (page == maxPage) { if (page == maxPage) {
autoPageTurningTimer!.cancel(); autoPageTurningTimer!.cancel();

View File

@@ -166,32 +166,49 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.92), color: context.colorScheme.surface.toOpacity(0.92),
border: Border( 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( child: Padding(
children: [ padding: EdgeInsets.only(
const SizedBox(width: 8), left: context.padding.left,
const BackButton(), right: context.padding.right,
const SizedBox(width: 8), ),
Expanded( child: Row(
child: Text( children: [
context.reader.widget.name, const SizedBox(width: 8),
style: ts.s18, const BackButton(),
maxLines: 1, const SizedBox(width: 8),
overflow: TextOverflow.ellipsis, Expanded(
child: Text(
context.reader.widget.name,
style: ts.s18,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), if (shouldShowChapterComments())
Tooltip( Tooltip(
message: "Settings".tl, message: "Chapter Comments".tl,
child: IconButton( child: IconButton(
icon: const Icon(Icons.settings), icon: const Icon(Icons.comment),
onPressed: openSetting, 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, : null,
), ),
padding: EdgeInsets.only(bottom: context.padding.bottom), padding: EdgeInsets.only(bottom: context.padding.bottom),
child: child, child: Padding(
padding: EdgeInsets.only(
left: context.padding.left,
right: context.padding.right,
),
child: child,
),
), ),
); );
} }
@@ -605,7 +628,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
var (imageIndex, data) = result; var (imageIndex, data) = result;
var fileType = detectFileType(data); var fileType = detectFileType(data);
var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}"; var filename =
"${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
saveFile(data: data, filename: filename); saveFile(data: data, filename: filename);
} }
@@ -616,7 +640,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
var (imageIndex, data) = result; var (imageIndex, data) = result;
var fileType = detectFileType(data); var fileType = detectFileType(data);
var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}"; var filename =
"${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
Share.shareFile(data: data, filename: filename, mime: fileType.mime); Share.shareFile(data: data, filename: filename, mime: fileType.mime);
} }
@@ -650,6 +675,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
if (key == "quickCollectImage") { if (key == "quickCollectImage") {
addDragListener(); addDragListener();
} }
if (key == "showChapterComments") {
update();
}
context.reader.update(); context.reader.update();
}, },
), ),
@@ -657,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() { Widget buildEpChangeButton() {
final extraWidth = context.padding.left + context.padding.right;
if (context.reader.widget.chapters == null) return const SizedBox(); if (context.reader.widget.chapters == null) return const SizedBox();
switch (showFloatingButtonValue) { switch (showFloatingButtonValue) {
case 0: case 0:
return Container( return Container(
width: 58, width: 58 + extraWidth,
height: 58, height: 58,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -680,7 +751,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
case -1: case -1:
case 1: case 1:
return SizedBox( return SizedBox(
width: 58, width: 58 + extraWidth,
height: 58, height: 58,
child: Material( child: Material(
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,

View File

@@ -49,7 +49,9 @@ class _SearchPageState extends State<SearchPage> {
void search([String? text]) { void search([String? text]) {
if (aggregatedSearch) { if (aggregatedSearch) {
context context
.to(() => AggregatedSearchPage(keyword: text ?? controller.text)) .to(
() => AggregatedSearchPage(keyword: text ?? controller.text)
)
.then((_) => update()); .then((_) => update());
} else { } else {
context context
@@ -58,7 +60,7 @@ class _SearchPageState extends State<SearchPage> {
text: text ?? controller.text, text: text ?? controller.text,
sourceKey: searchTarget, sourceKey: searchTarget,
options: options, options: options,
), )
) )
.then((_) => update()); .then((_) => update());
} }

View File

@@ -100,7 +100,7 @@ class _AppSettingsState extends State<AppSettings> {
title: "Export App Data".tl, title: "Export App Data".tl,
callback: () async { callback: () async {
var controller = showLoadingDialog(context); var controller = showLoadingDialog(context);
var file = await exportAppData(); var file = await exportAppData(false);
await saveFile(filename: "data.venera", file: file); await saveFile(filename: "data.venera", file: file);
controller.close(); controller.close();
}, },
@@ -353,6 +353,8 @@ class _WebdavSettingState extends State<_WebdavSetting> {
String url = ""; String url = "";
String user = ""; String user = "";
String pass = ""; String pass = "";
String disableSync = "";
bool autoSync = true; bool autoSync = true;
bool isTesting = false; bool isTesting = false;
@@ -364,6 +366,9 @@ class _WebdavSettingState extends State<_WebdavSetting> {
if (appdata.settings['webdav'] is! List) { if (appdata.settings['webdav'] is! List) {
appdata.settings['webdav'] = []; appdata.settings['webdav'] = [];
} }
if (appdata.settings['disableSyncFields'].trim().isNotEmpty) {
disableSync = appdata.settings['disableSyncFields'];
}
var configs = appdata.settings['webdav'] as List; var configs = appdata.settings['webdav'] as List;
if (configs.whereType<String>().length != 3) { if (configs.whereType<String>().length != 3) {
return; return;
@@ -418,6 +423,56 @@ class _WebdavSettingState extends State<_WebdavSetting> {
onChanged: (value) => pass = value, onChanged: (value) => pass = value,
), ),
const SizedBox(height: 12), 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( ListTile(
leading: Icon(Icons.sync), leading: Icon(Icons.sync),
title: Text("Auto Sync Data".tl), title: Text("Auto Sync Data".tl),
@@ -428,30 +483,26 @@ class _WebdavSettingState extends State<_WebdavSetting> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( RadioGroup<bool>(
children: [ groupValue: upload,
Text("Operation".tl), onChanged: (value) {
Radio<bool>( setState(() {
groupValue: upload, upload = value ?? upload;
value: true, });
onChanged: (value) { },
setState(() { child: Row(
upload = value!; children: [
}); Text("Operation".tl),
}, Radio<bool>(
), value: true,
Text("Upload".tl), ),
Radio<bool>( Text("Upload".tl),
groupValue: upload, Radio<bool>(
value: false, value: false,
onChanged: (value) { ),
setState(() { Text("Download".tl),
upload = value!; ],
}); ),
},
),
Text("Download".tl),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AnimatedSize( AnimatedSize(
@@ -498,6 +549,7 @@ class _WebdavSettingState extends State<_WebdavSetting> {
} }
appdata.settings['webdav'] = [url, user, pass]; appdata.settings['webdav'] = [url, user, pass];
appdata.settings['disableSyncFields'] = disableSync;
appdata.implicitData['webdavAutoSync'] = autoSync; appdata.implicitData['webdavAutoSync'] = autoSync;
appdata.writeImplicitData(); appdata.writeImplicitData();

View File

@@ -111,44 +111,34 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
return PopUpWidgetScaffold( return PopUpWidgetScaffold(
title: "Proxy".tl, title: "Proxy".tl,
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column( child: RadioGroup<String>(
children: [ groupValue: type,
RadioListTile<String>( onChanged: (v) {
title: Text("Direct".tl), setState(() {
value: 'direct', type = v ?? type;
groupValue: type, });
onChanged: (v) { if (type != 'manual') {
setState(() { appdata.settings['proxy'] = toProxyStr();
type = v!; appdata.saveData();
}); }
appdata.settings['proxy'] = toProxyStr(); },
appdata.saveData(); child: Column(
}, children: [
), RadioListTile<String>(
RadioListTile<String>( title: Text("Direct".tl),
title: Text("System".tl), value: 'direct',
value: 'system', ),
groupValue: type, RadioListTile<String>(
onChanged: (v) { title: Text("System".tl),
setState(() { value: 'system',
type = v!; ),
}); RadioListTile(
appdata.settings['proxy'] = toProxyStr(); title: Text("Manual".tl),
appdata.saveData(); value: 'manual',
}, ),
), if (type == 'manual') buildManualProxy(),
RadioListTile( ],
title: Text("Manual".tl), ),
value: 'manual',
groupValue: type,
onChanged: (v) {
setState(() {
type = v!;
});
},
),
if (type == 'manual') buildManualProxy(),
],
), ),
), ),
); );

View File

@@ -303,6 +303,15 @@ class _ReaderSettingsState extends State<ReaderSettings> {
comicId: isEnabledSpecificSettings ? widget.comicId : null, comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null, comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(), ).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(),
], ],
); );
} }

View File

@@ -385,17 +385,16 @@ class _SliderSettingState extends State<_SliderSetting> {
: appdata.settings.getReaderSetting( : appdata.settings.getReaderSetting(
widget.comicId!, widget.comicId!,
widget.comicSource!, widget.comicSource!,
widget.settingsIndex, widget.settingsIndex,
)) ))
.toDouble(); .toDouble();
return ListTile( return ListTile(
title: Row( title: Text(
children: [ widget.title,
Text(widget.title), softWrap: true,
const Spacer(), maxLines: 2,
Text(value.toString(), style: ts.s12),
],
), ),
trailing: Text(value.toString(), style: ts.s12),
subtitle: Slider( subtitle: Slider(
value: value, value: value,
onChanged: (value) { onChanged: (value) {

View File

@@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
@@ -41,7 +40,7 @@ class SettingsPage extends StatefulWidget {
State<SettingsPage> createState() => _SettingsPageState(); State<SettingsPage> createState() => _SettingsPageState();
} }
class _SettingsPageState extends State<SettingsPage> implements PopEntry { class _SettingsPageState extends State<SettingsPage> {
int currentPage = -1; int currentPage = -1;
ColorScheme get colors => Theme.of(context).colorScheme; ColorScheme get colors => Theme.of(context).colorScheme;
@@ -70,84 +69,14 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
Icons.bug_report, 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 @override
void initState() { void initState() {
currentPage = widget.initialPage; 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(); super.initState();
} }
@override
dispose() {
super.dispose();
gestureRecognizer.dispose();
_route?.unregisterPopEntry(this);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (currentPage != -1) {
canPop.value = false;
} else {
canPop.value = true;
}
return Material( return Material(
child: buildBody(), child: buildBody(),
); );
@@ -209,51 +138,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
], ],
); );
} else { } else {
return LayoutBuilder( return buildLeft();
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);
} }
} }
@@ -333,7 +218,13 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
? const EdgeInsets.fromLTRB(8, 0, 8, 0) ? const EdgeInsets.fromLTRB(8, 0, 8, 0)
: EdgeInsets.zero, : EdgeInsets.zero,
child: InkWell( child: InkWell(
onTap: () => setState(() => currentPage = id), onTap: () {
if (enableTwoViews) {
setState(() => currentPage = id);
} else {
context.to(() => _SettingsDetailPage(pageIndex: id));
}
},
child: content, child: content,
).paddingVertical(4), ).paddingVertical(4),
); );
@@ -347,8 +238,23 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
} }
Widget buildRight() { Widget buildRight() {
return switch (currentPage) { if (currentPage == -1) {
-1 => const SizedBox(), 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(), 0 => const ExploreSettings(),
1 => const ReaderSettings(), 1 => const ReaderSettings(),
2 => const AppearanceSettings(), 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 @override
ValueListenable<bool> get canPopNotifier => canPop; Widget build(BuildContext context) {
return Material(
@override child: _buildPage(),
void onPopInvokedWithResult(bool didPop, result) { );
if (currentPage != -1) {
setState(() {
currentPage = -1;
});
}
} }
@override Widget _buildPage() {
void onPopInvoked(bool didPop) { return switch (pageIndex) {
if (currentPage != -1) { 0 => const ExploreSettings(),
setState(() { 1 => const ReaderSettings(),
currentPage = -1; 2 => const AppearanceSettings(),
}); 3 => const LocalFavoritesSettings(),
} 4 => const AppSettings(),
5 => const NetworkSettings(),
6 => const AboutSettings(),
7 => const DebugPage(),
_ => throw UnimplementedError()
};
} }
} }

View File

@@ -15,7 +15,7 @@ import 'package:zip_flutter/zip_flutter.dart';
import 'io.dart'; import 'io.dart';
Future<File> exportAppData() async { Future<File> exportAppData([bool sync = true]) async {
var time = DateTime.now().millisecondsSinceEpoch ~/ 1000; var time = DateTime.now().millisecondsSinceEpoch ~/ 1000;
var cacheFilePath = FilePath.join(App.cachePath, '$time.venera'); var cacheFilePath = FilePath.join(App.cachePath, '$time.venera');
var cacheFile = File(cacheFilePath); var cacheFile = File(cacheFilePath);
@@ -27,7 +27,7 @@ Future<File> exportAppData() async {
var zipFile = ZipFile.open(cacheFilePath); var zipFile = ZipFile.open(cacheFilePath);
var historyFile = FilePath.join(dataPath, "history.db"); var historyFile = FilePath.join(dataPath, "history.db");
var localFavoriteFile = FilePath.join(dataPath, "local_favorite.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"); var cookies = FilePath.join(dataPath, "cookie.db");
zipFile.addFile("history.db", historyFile); zipFile.addFile("history.db", historyFile);
zipFile.addFile("local_favorite.db", localFavoriteFile); zipFile.addFile("local_favorite.db", localFavoriteFile);

View File

@@ -130,7 +130,9 @@ class DataSync with ChangeNotifier {
try { try {
appdata.settings['dataVersion']++; appdata.settings['dataVersion']++;
await appdata.saveData(false); await appdata.saveData(false);
var data = await exportAppData(); var data = await exportAppData(
appdata.settings['disableSyncFields'].toString().isNotEmpty
);
var time = var time =
(DateTime.now().millisecondsSinceEpoch ~/ 86400000).toString(); (DateTime.now().millisecondsSinceEpoch ~/ 86400000).toString();
var filename = time; var filename = time;

View File

@@ -362,7 +362,7 @@ Future<void> saveFile(
} }
} }
class _IOOverrides extends IOOverrides { final class _IOOverrides extends IOOverrides {
@override @override
Directory createDirectory(String path) { Directory createDirectory(String path) {
if (App.isAndroid) { if (App.isAndroid) {

View File

@@ -416,10 +416,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_memory_info name: flutter_memory_info
sha256: "1f112f1d7503aa1681fc8e923f6cd0e847bb2fbeec3753ed021cf1e5f7e9cd74" sha256: eacfd0dd01ff596b4e5bf022442769a1807a73f2af43d62802436f0a5de99137
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.1" version: "0.0.3"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -478,10 +478,11 @@ packages:
flutter_to_debian: flutter_to_debian:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_to_debian path: "."
sha256: d23534407334b331ce20fbaa8395b9ecc255d0c047136b8998715f36933ee696 ref: HEAD
url: "https://pub.dev" resolved-ref: "3777c91b6b1cc0b7c03357c67ca216d4313c3db5"
source: hosted url: "https://github.com/venera-app/flutter_to_debian.git"
source: git
version: "2.0.2" version: "2.0.2"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
@@ -661,10 +662,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.17.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -957,10 +958,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.6" version: "0.7.7"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1126,10 +1127,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: zip_flutter name: zip_flutter
sha256: c4d5a34c5803def866bc550926bb16fe89717c9b7304695d5b2ede30964eb8a8 sha256: baecf8deb6bf53a50e5ab513707ab56cc0c25f5b43333aa56ef562e8e7057357
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.12" version: "0.0.13"
sdks: sdks:
dart: ">=3.8.0 <4.0.0" dart: ">=3.8.0 <4.0.0"
flutter: ">=3.35.5" flutter: ">=3.38.3"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.5.2+152 version: 1.6.0+160
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
flutter: 3.35.5 flutter: 3.38.3
dependencies: dependencies:
flutter: flutter:
@@ -53,7 +53,7 @@ dependencies:
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2 flutter_file_dialog: ^3.0.2
file_selector: ^1.0.3 file_selector: ^1.0.3
zip_flutter: ^0.0.12 zip_flutter: ^0.0.13
lodepng_flutter: lodepng_flutter:
git: git:
url: https://github.com/venera-app/lodepng_flutter url: https://github.com/venera-app/lodepng_flutter
@@ -75,7 +75,7 @@ dependencies:
ref: fe182cdf40e5fa6230f451bc1d643b860f610d13 ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
shimmer_animation: ^2.1.0 shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1 flutter_memory_info: ^0.0.3
syntax_highlight: ^0.4.0 syntax_highlight: ^0.4.0
flutter_7zip: flutter_7zip:
git: git:
@@ -93,7 +93,9 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
flutter_to_arch: ^1.0.1 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 archive: any
flutter: flutter: