Compare commits

...

206 Commits

Author SHA1 Message Date
ynyx631
068d6148ad Update main.yml 2025-11-29 16:50:26 +08:00
ynyx631
0b261f81ba Update version code 2025-11-29 15:08:25 +08:00
nyne
781ff2553d Merge pull request #649 from venera-app/feat/comment-blocking
feat: add comment keyword blocking functionality
2025-11-29 15:04:51 +08:00
ynyx631
0ce18cd738 Merge pull request #650 from venera-app/fix/local-search-menu
fix: enable multi-select actions in local comics search mode
2025-11-29 15:04:41 +08:00
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
nyne
2d27f7d650 Merge pull request #541 from venera-app/v1.5.2-dev
V1.5.2
2025-10-08 20:06:56 +08:00
e1fbdfbd50 Update version code 2025-10-07 16:10:14 +08:00
0a5b70b161 Add font patching script for linux arm64. Close #468 2025-10-07 16:09:30 +08:00
nyne
5a76a10fb2 Merge pull request #537 from lings03/master
Fix some issue when save or share image in reader.
2025-10-07 15:21:50 +08:00
9173665afe Fix invalid total comics count. Close #524 2025-10-07 09:47:25 +08:00
角砂糖
f09e766a8a Fix some issue when save or share image in reader.
1. Change the image name with comic name and real index
2. Fix wrong equal check
3. Fix wrong selection when image per page > 1 and show single image in first page
2025-10-07 01:19:59 +08:00
e0ea449c17 Improve updating following and fix potential crash. 2025-10-06 10:17:01 +08:00
c438a84537 flutter 3.35.5 2025-10-05 17:31:57 +08:00
8c625e212a fix downloading issue when chapter name contains special characters. Close #533 2025-10-05 17:31:24 +08:00
ab786ed2ab fix padding check. Close #527 2025-10-05 16:58:41 +08:00
d9303aab2e fix activity name. Close #528 2025-10-05 16:56:32 +08:00
nyne
b7f79476c8 Merge pull request #534 from lings03/v1.5.1-dev
favorite page && cover page
2025-10-05 16:17:14 +08:00
角砂糖
44bcce4385 Add a page to view cover 2025-10-03 02:32:36 +08:00
角砂糖
6ce6066de2 Update comic details favorite page style 2025-10-03 02:32:31 +08:00
nyne
7fa48cec29 Merge pull request #515 from venera-app/v1.5.1-dev
V1.5.1
2025-09-14 18:56:12 +08:00
e549a18dbf flutter 3.35.3 2025-09-14 18:54:26 +08:00
c17c4abb5b Reduce size of scroll bar. 2025-09-14 18:43:11 +08:00
af57bc31b1 Update version code. 2025-09-14 18:33:19 +08:00
16449a1440 Change page transition animation for Android. 2025-09-14 18:30:54 +08:00
a7c1983f35 Fallback to local cover if loading fails for favorite comic. 2025-09-14 17:19:23 +08:00
4c257d7178 Show read button if loading fails. 2025-09-14 17:05:45 +08:00
3a9d634edf Update android build script. 2025-09-14 10:21:14 +08:00
nyne
e179c8f67f Change padding check condition for Android platform (#503) 2025-09-05 17:52:33 +08:00
nyne
c4b85471c1 Merge pull request #499 from KarlZeo/fix-ios-padding-check
fix padding check error on ios
2025-09-05 17:42:49 +08:00
KarlZeo
a898b57d96 fix padding check error on ios 2025-09-04 20:04:28 +08:00
50c6bec4cd Disable minify 2025-09-04 00:30:01 +08:00
nyne
8c44f83d6c Update Xcode version in GitHub Actions workflow 2025-09-03 22:50:32 +08:00
nyne
103b6b2832 Merge pull request #497 from venera-app/v1.5.0-dev
V1.5.0
2025-09-03 22:12:00 +08:00
4129349c70 Improve js api onResponse 2025-09-03 22:09:07 +08:00
77a9aa5457 Update version code. 2025-09-03 22:05:04 +08:00
97940b9492 Refactor category options. 2025-09-03 22:03:54 +08:00
7945c0e54f Improve compute api. 2025-09-03 20:31:42 +08:00
dfee65c3af Add compute api to js engine. 2025-09-02 22:15:54 +08:00
fa2dbd79f6 Fix invalid js stacktrace. 2025-09-02 20:35:47 +08:00
9a9f539906 Disable cache when updating comic source. 2025-09-02 20:16:13 +08:00
d7331f36e9 flutter 3.35.2 2025-09-01 21:13:57 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
d0b76de465 Use badge from shields.io (#455)
* Use badge from shields.io

* AUR
2025-09-01 20:55:45 +08:00
894a922b8f fix js api onResponse 2025-09-01 20:55:15 +08:00
a91d7fff2d move logic to foundation 2025-09-01 20:54:11 +08:00
Luorix
926a3a530e Venera Headless Mode Update (#476)
* 添加无头模式支持,增强日志功能,优化更新流程
I have successfully implemented the headless mode feature in Venera, fixed all runtime errors, and updated the output to be in JSON format. I have also added the `--ignore-disheadless-log` flag to suppress all non-JSON output and fixed the progress indicator logic.

You can now use the following commands:

- `venera --headless webdav up`: Upload the current WebDAV configuration.
- `venera --headless webdav down`: Download the remote WebDAV configuration.
- `venera --headless updatescript all`: Update all comic source scripts.
- `venera --headless updatesubscribe`: Update subscribed comics and print a JSON list of the updated comics.
- `venera --headless --ignore-disheadless-log ...`: Run any of the above commands while suppressing all non-JSON output.

The implementation involved:

1. Creating a new `lib/headless.dart` file to handle the headless logic.
2. Modifying `lib/main.dart` to recognize the `--headless` argument.
3. Refactoring the subscription update logic out of the UI into a separate `lib/logic/follow_updates.dart` file to be used by both the UI and the headless mode.
4. Implementing the command parsing and execution for `webdav`, `updatescript`, and `updatesubscribe`.
5. Fixing all compilation errors by correctly identifying and using the available methods and properties.
6. Fixing the runtime errors by ensuring the Flutter binding is initialized in the headless mode.
7. Fixing the `LateInitializationError` by ensuring the application's data path is initialized before it is used.
8. Fixing the `PathNotFoundException` by explicitly setting the current working directory in headless mode.
9. Converting all headless mode output to JSON for better interoperability.
10. Fixing the progress indicator bug.
11. Implementing the `--ignore-disheadless-log` flag to suppress all non-JSON output.
12. Including comic metadata in the progress output.
13. Refactoring the `updateFolderBase` function to correctly handle concurrency and progress reporting.
14. Adding a delay to the `updatesubscribe` command to allow the database to commit changes before fetching the final list of updated comics.

* 将封面字段名称从 'cover' 更改为 'coverUrl',以统一 JSON 输出格式

* remove md

* 增强无头模式的更新进度报告,添加错误处理信息

* 修复init没有wait的问题

* 优化init函数中的异步初始化,确保所有组件初始化完成后再继续执行

* 重构更新漫画逻辑,添加错误处理并优化更新进度报告。添加单个漫画更新检查支持

* 添加无头模式文档,描述命令行功能及使用方法

* 增强无头模式下的更新信息,添加源数据的JS表示形式

* 增强无头模式下的更新脚本输出,添加详细进度和最终总结信息;改进错误处理逻辑以支持不同的显示模式
2025-09-01 20:49:47 +08:00
d308c2ac60 Add mouse scroll speed setting. Close #471 2025-08-24 19:52:24 +08:00
ac13807ef4 Add option to ignore certificate errors. Close #485 2025-08-24 19:19:40 +08:00
38a5b2b8cf refactor: comic specific settings 2025-08-24 19:04:42 +08:00
3a7c8d5e38 Fix toolbar overflow. 2025-08-24 17:57:35 +08:00
enximi
ce0d10aeb2 Add a feature to allow saving custom reader settings for each comic. (#459)
* Add a feature to allow saving custom reader settings for each  comic.

* Comic-specific settings disabled by default
2025-08-10 16:02:44 +08:00
角砂糖
0ac857ef9a Temp solution for hyper os multi window display issue (#467)
Temp solution for hyper os multi window display
2025-08-10 16:02:00 +08:00
3928f5afe7 Improve smooth scroll. Close #462 2025-08-03 17:05:31 +08:00
8a61a4750b Add avif format. 2025-08-03 16:40:25 +08:00
nyne
1bc3fef47b Fix workflow 2025-07-23 15:36:12 +08:00
nyne
4dac132bee Remove appimage. 2025-07-23 15:07:28 +08:00
nyne
7c60c00962 Merge pull request #454 from venera-app/v1.4.6-dev
V1.4.6
2025-07-23 14:38:42 +08:00
9d8ade6fe0 Add log export functionality. 2025-07-23 14:35:27 +08:00
6245399810 Improve UI of comic source page. 2025-07-23 14:28:40 +08:00
c074e7f9d1 Add default source list url. 2025-07-23 14:16:53 +08:00
f822e198ea Update version code. 2025-07-22 17:55:51 +08:00
7035f11eb5 Add optional image parameter to showInputDialog for captcha support. Close #422 2025-07-22 17:51:40 +08:00
f2f5a4f573 Convert between Simplified Chinese and Traditional Chinese when searching favorites. Close #438 2025-07-20 18:52:05 +08:00
2acf234f7d Fix response handling for unhandled method calls in flutter_window.cpp 2025-07-20 18:47:49 +08:00
9ed8f351c7 Add heartbeat monitoring in release builds. 2025-07-20 18:45:42 +08:00
7c35dc7cf7 Update doc. 2025-07-20 16:39:21 +08:00
nyne
17b8b9ea8f Update README.md 2025-07-16 14:10:00 +08:00
ccb03343f4 Fix the issue where the toolbar can not be open when chapter data loading failed. Close #415 2025-07-13 20:22:56 +08:00
Selene29
951bcae603 Local Comic: Add "Open Folder" button (#443) 2025-07-13 18:52:23 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
0b9de68c86 fastlane workflow: path condition (#442) 2025-07-11 14:11:59 +08:00
boa
81b27fd941 update iOS privacy permission descriptions in AltStore config (#432) 2025-07-01 22:14:44 +08:00
角砂糖
b9817ec030 Fix page calculation logic && trigger recalculation on orientation change (#428) 2025-06-26 19:55:21 +08:00
角砂糖
5ebb554e54 Add an option to filter logs by level (#427) 2025-06-26 19:55:07 +08:00
Gandum2077
d5d72911ed Add custom tag suggestion handler (#424) 2025-06-24 19:47:14 +08:00
boa
838d5c9c3e Add AltStore Source Support (#416)
* add altstore source

* rename altstore source
2025-06-24 19:46:22 +08:00
23ee79fe9d Set high refresh rate on Android. 2025-06-23 19:39:47 +08:00
nyne
85baac657a Merge pull request #421 from lings03/local
Allow user to keep favorite and history when delete local comic. Close #420
2025-06-23 19:06:34 +08:00
nyne
cceca6b96f Merge branch 'master' into local 2025-06-23 19:04:20 +08:00
角砂糖
b5b0dc85e3 Show group in last read and history when group existing. (#419) 2025-06-23 19:03:24 +08:00
nyne
50044c4372 Merge pull request #418 from lings03/reverse
Add a option to reverse the default chapter order. Close #414
2025-06-23 19:02:52 +08:00
nyne
5fd7f1b880 Merge branch 'master' into reverse 2025-06-23 19:00:48 +08:00
角砂糖
058fde3f5a Add a button to show system status bar (#417) 2025-06-23 19:00:04 +08:00
角砂糖
a2d46123dd Add missing translation
debug时发现之前少了这一句翻译,也不至于提个pr,顺便加在这里吧
2025-06-22 20:07:15 +08:00
角砂糖
01acc4f9de Allow user to keep favorite and history when delete local comic. Close #420 2025-06-22 19:50:38 +08:00
角砂糖
856aae0769 Add a option to reverse the default chapter order. Close #414 2025-06-22 00:29:49 +08:00
nyne
8eda8adcc8 Merge pull request #410 from venera-app/v1.4.5-dev
V1.4.5
2025-06-18 16:52:59 +08:00
defd4b8624 Update version code. 2025-06-18 16:39:02 +08:00
b2a164e066 Remove the config file repository url from app. 2025-06-18 16:34:49 +08:00
a46ceebf19 Fixed the issue where the update dialog was not showed on startup. 2025-06-18 16:07:36 +08:00
cc08445f13 Set initial chapter to first downloaded chapter if there is no history when starting to read a local comic. Close #405 2025-06-17 17:36:13 +08:00
93f7f72d07 Fixed some issues when using custom download path on Android. Close #400 2025-06-17 17:15:35 +08:00
20f7ab4866 Clear folder value if it does not exist in local favorites. Close #389 2025-06-15 15:02:45 +08:00
54363919cd Fixed RangeError when translating tags. Close #356 2025-06-15 14:58:15 +08:00
182a821fc5 Fixed the issue where the download task would stop after exiting the reader. Close #387 2025-06-15 14:58:15 +08:00
8868c6edb3 Update Flutter SDK version to 3.32.4 2025-06-15 14:58:15 +08:00
角砂糖
fffbb4ed23 Only add closeListener when app is desktop (#397) 2025-06-04 12:11:45 +08:00
角砂糖
b057be0311 Fix abnormal history recording when not flipping pages. Close #392 (#395) 2025-06-03 17:36:20 +08:00
角砂糖
fc5fed1707 Fix history of page when show single image on first page (#393) 2025-06-03 17:35:45 +08:00
角砂糖
8525f5318f Fix page calculate when showSingleImageOnFirstPage is enabled (#391) 2025-06-03 17:35:17 +08:00
角砂糖
d58cafc4a0 Fix abnormal single image height when imagesPerPage > 1. Close #379 2025-05-31 10:50:17 +08:00
23afafd1d6 Update rhttp 2025-05-26 19:05:15 +08:00
nyne
3b6e0adbbb Merge pull request #377 from venera-app/v1.4.4
V1.4.4
2025-05-26 18:18:43 +08:00
20a57c7a36 Update version code 2025-05-26 18:10:07 +08:00
665f50ed2a Fixed an issue where comic counts would become invalid after renaming a favorite folder. Close #357 2025-05-26 16:42:05 +08:00
55733ef505 Update selectAll method to handle search mode for selecting comics. Close #359 2025-05-26 16:09:23 +08:00
0c46214619 Reduce maximum length for comic directory names to improve consistency. Close #362 2025-05-26 15:35:24 +08:00
749a1a47fb Fix dialog content overflow. Close #363 2025-05-25 20:33:31 +08:00
76e9ef87d4 Add functionality to delete specific comic chapters. Close #368 2025-05-25 20:26:35 +08:00
dcd6466547 Improve performance of deleting favorites, coping favorites, moving favorites and deleting downloads. Close #365 2025-05-24 16:24:53 +08:00
ed70fdba93 Improve reordering local comics. Close #374 2025-05-22 20:51:47 +08:00
ded0068ea6 Improve performance for clearing history. 2025-05-22 20:37:25 +08:00
nyne
7dc6be622a fix clearing history. 2025-05-22 20:01:07 +08:00
nyne
88f093f7e5 Add clear unfavorited history functionality. Close #372 2025-05-22 19:59:42 +08:00
8f357b3e6c Merge branch 'master' into v1.4.4 2025-05-20 15:51:28 +08:00
9ee82975e8 Handle invalid appdata file. 2025-05-20 15:40:30 +08:00
nyne
9f048685e4 fix decryptAesCbc 2025-05-05 18:29:46 +08:00
nyne
bc1f5e11b5 Update version code 2025-05-05 18:26:01 +08:00
1f2147ef72 Add support for gbk. Close #354 2025-05-05 12:51:36 +08:00
fba365fd93 Fix crash caused by cache manager. Close #351 2025-05-04 23:03:37 +08:00
a5e3fbaee5 Improve image loading 2025-05-04 22:24:39 +08:00
190e645a12 Update translation 2025-04-29 11:35:54 +08:00
nyne
8a83ff5367 Merge pull request #349 from venera-app/v1.4.2-dev
V1.4.2
2025-04-29 11:32:40 +08:00
6e14942dab Add application category type to Info.plist 2025-04-29 11:29:30 +08:00
146fc70143 Update version code 2025-04-29 11:19:59 +08:00
b37ea01aca Add an option to disable double tap to zoom. 2025-04-29 11:18:59 +08:00
bf7b90313a Fix invalid total page count. Close #348 2025-04-28 20:18:29 +08:00
929c1a9d91 Show comics count of a folder on sidebar. 2025-04-28 19:46:29 +08:00
9ff68d0701 Improve local favorites performance. 2025-04-28 19:40:12 +08:00
dfd15ed34a Fix an issue where folders were not fully displayed on the favorites page. 2025-04-26 10:23:18 +08:00
nyne
dfe2a0db6a Merge pull request #345 from venera-app/v1.4.1-dev
V1.4.1
2025-04-25 09:22:51 +08:00
c6714f79b6 Revert "Add windows arm64"
This reverts commit 6877aa120f.
2025-04-25 09:18:45 +08:00
552a42fb27 Fix the issue where app crashes after exit app. 2025-04-24 20:11:09 +08:00
af456c52f1 Improve the UI of comic source list. 2025-04-24 17:20:16 +08:00
f38129133a Terminate the application when the UI thread is dead. Close #343 2025-04-24 16:44:51 +08:00
17e2696ca4 flutter 3.29.3 2025-04-23 17:50:04 +08:00
9d6999af33 Improve UI 2025-04-23 16:58:38 +08:00
ae5548918c Fix saving, sharing, and collecting images when there are multiple images on the screen. Close #289 2025-04-23 16:51:51 +08:00
92d22c977c Add a Save Image option to the Reader context menu. 2025-04-23 15:51:58 +08:00
8cc3702e1a Add an option to display single image on the first reader page. Close #244 2025-04-23 15:38:10 +08:00
3131ce52a7 Fix file name sanitising to remove trailing dots. Close #322 2025-04-22 20:29:18 +08:00
62e4056f4a Add an 'All' folder to the local favorites page. Close #335 2025-04-22 20:19:22 +08:00
a29a7cbaf3 Adjust the scroll distance when turning pages using the arrow keys. Close #329 2025-04-21 20:12:08 +08:00
7bdab7ade7 Add ComicInfo.xml to cbz file. Close #333 2025-04-21 20:04:06 +08:00
ea99e87afb Fixed issue where http client settings were not synchronised with appdata. Close #337 2025-04-21 19:44:23 +08:00
0d3fde9457 Adjust key repeat timer duration based on page animation setting. 2025-04-21 19:16:43 +08:00
aa9f4dae82 Reset state of photo view controllers on page change. Close #331 2025-04-19 10:54:25 +08:00
6877aa120f Add windows arm64 2025-04-15 17:08:28 +08:00
d25d72a5f7 Improve image cache. Close #326 2025-04-10 17:14:05 +08:00
105 changed files with 13043 additions and 3514 deletions

View File

@@ -4,8 +4,12 @@ on:
workflow_dispatch:
push:
branches: [ "master" ]
paths:
- 'fastlane/**'
pull_request:
branches: [ "master" ]
paths:
- 'fastlane/**'
jobs:
go:

View File

@@ -15,7 +15,7 @@ jobs:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
- run: flutter pub get
# Step 1: Decode and install the certificate
- name: Decode and install certificate
@@ -63,7 +63,7 @@ jobs:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
- run: flutter pub get
- run: flutter build ios --release --no-codesign
- run: |
@@ -84,6 +84,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- run: rm -rf /opt/hostedtoolcache
- uses: subosito/flutter-action@v2
with:
channel: "stable"
@@ -116,6 +117,8 @@ jobs:
run: |
choco install yq -y
pip install httpx
- name: Install Inno Setup
run: choco install innosetup --no-progress
- uses: subosito/flutter-action@v2
with:
channel: "stable"
@@ -141,7 +144,7 @@ jobs:
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
dart pub global activate -s git https://github.com/venera-app/flutter_to_debian.git
- run: python3 debian/build.py x64
- run: dart run flutter_to_arch
- run: |
@@ -149,45 +152,6 @@ jobs:
sudo rm -rf build/linux/arch/pkg
sudo rm -rf build/linux/arch/src
sudo rm -rf build/linux/arch/PKGBUILD
- name: Build AppImage
run: |
sudo apt-get install -y libfuse2
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x appimagetool
mkdir -p Venera.AppDir
cp -r build/linux/x64/release/bundle/* Venera.AppDir/
cat > Venera.AppDir/venera.desktop << EOF
[Desktop Entry]
Name=Venera
Exec=venera
Icon=venera
Type=Application
Categories=Utility;
EOF
cp assets/app_icon.png Venera.AppDir/venera.png
cat > Venera.AppDir/AppRun << EOF
#!/bin/sh
HERE=\$(dirname \$(readlink -f "\${0}"))
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
exec "\${HERE}"/venera "\$@"
EOF
chmod +x Venera.AppDir/AppRun
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
./appimagetool Venera.AppDir Venera-${APP_VERSION}-x86_64.AppImage
mkdir -p build/linux/appimage
mv Venera-${APP_VERSION}-x86_64.AppImage build/linux/appimage/
- uses: actions/upload-artifact@v4
with:
name: appimage_build
path: build/linux/appimage
- uses: actions/upload-artifact@v4
with:
name: deb_build
@@ -208,47 +172,11 @@ jobs:
flutter pub get
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py arm64
- name: Build AppImage
dart pub global activate -s git https://github.com/venera-app/flutter_to_debian.git
- name: "Patch font"
run: |
sudo apt-get install -y libfuse2
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage"
chmod +x appimagetool
mkdir -p Venera.AppDir
cp -r build/linux/arm64/release/bundle/* Venera.AppDir/
cat > Venera.AppDir/venera.desktop << EOF
[Desktop Entry]
Name=Venera
Exec=venera
Icon=venera
Type=Application
Categories=Utility;
EOF
cp assets/app_icon.png Venera.AppDir/venera.png
cat > Venera.AppDir/AppRun << EOF
#!/bin/sh
HERE=\$(dirname \$(readlink -f "\${0}"))
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
exec "\${HERE}"/venera "\$@"
EOF
chmod +x Venera.AppDir/AppRun
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
./appimagetool Venera.AppDir Venera-${APP_VERSION}-aarch64.AppImage
mkdir -p build/linux/appimage
mv Venera-${APP_VERSION}-aarch64.AppImage build/linux/appimage/
- uses: actions/upload-artifact@v4
with:
name: appimage_arm64_build
path: build/linux/appimage
dart run patch/font.dart
- run: python3 debian/build.py arm64
- uses: actions/upload-artifact@v4
with:
name: deb_arm64_build
@@ -287,14 +215,6 @@ jobs:
with:
name: deb_arm64_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: appimage_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: appimage_arm64_build
path: outputs
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}

87
.github/workflows/update_alt_store.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Update AltStore Source
on:
workflow_run:
workflows: ["Build ALL"]
types: [completed]
workflow_dispatch:
jobs:
update-source:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Record job start time
id: job_start_time
run: echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
- name: Update AltStore source
id: update_source
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python update_alt_store.py
git config --global user.name 'GitHub Action'
git config --global user.email 'action@github.com'
git add alt_store.json
if git diff --staged --quiet; then
echo "changes=false" >> $GITHUB_OUTPUT
else
# Create a new branch for the PR
branch_name="update-altstore-$(date +%Y%m%d-%H%M%S)"
git checkout -b "$branch_name"
git commit -m "Updated source with latest release"
git push -u origin "$branch_name"
# Create PR using GitHub CLI
gh pr create \
--title "Update AltStore source with latest release" \
--body "This PR updates the alt_store.json file with the latest release information." \
--head "$branch_name" \
--base master
echo "changes=true" >> $GITHUB_OUTPUT
fi
- name: Calculate job duration
id: duration
if: always()
run: |
end_time=$(date +%s)
duration=$((end_time - ${{ steps.job_start_time.outputs.start_time }}))
echo "duration=$duration seconds" >> $GITHUB_OUTPUT
- name: Create job summary
run: |
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
echo "✅ Changes Detected and Applied" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The alt_store.json file has been updated with the latest release information." >> $GITHUB_STEP_SUMMARY
else
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
echo "🔍 No Changes Detected" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The alt_store.json file is up to date. No changes were necessary." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "🕐 Execution Time" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "This job took ${{ steps.duration.outputs.duration }} to complete." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "📆 Next Scheduled Run" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The next scheduled run will be tomorrow at midnight UTC." >> $GITHUB_STEP_SUMMARY

View File

@@ -1,16 +1,15 @@
# venera
[![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/)
[![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![stars](https://img.shields.io/github/stars/venera-app/venera?style=flat)](https://github.com/venera-app/venera/stargazers)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/+Ws-IpmUutzkxMjhl)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/venera_release)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![AUR Version](https://img.shields.io/aur/version/venera-bin)](https://aur.archlinux.org/packages/venera-bin)
[![F-Droid Version](https://img.shields.io/f-droid/v/com.github.wgh136.venera)](https://f-droid.org/packages/com.github.wgh136.venera/)
A comic reader that support reading local and network comics.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
## Features
- Read local comics
- Use javascript to create comic sources
@@ -34,4 +33,7 @@ See [Comic Source](doc/comic_source.md)
### Tags Translation
[![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=EhTagTranslation&repo=Database)](https://github.com/EhTagTranslation/Database)
## Headless Mode
See [Headless Doc](doc/headless_doc.md)
The Chinese translation of the manga tags is from this project.

98
alt_store.json Normal file
View File

@@ -0,0 +1,98 @@
{
"name": "Venera",
"identifier": "com.github.wgh136.venera.source",
"website": "https://github.com/venera-app/venera",
"subtitle": "Venera official AltStore Source.",
"description": "This is the official AltStore Source for Venera.\n\n A comic reader that supports reading local and network comics",
"tintColor": "#0784FC",
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
"apps": [
{
"beta": false,
"name": "Venera",
"bundleIdentifier": "com.github.wgh136.venera",
"developerName": "wgh136",
"subtitle": "A comic reader that supports reading local and network comics",
"version": "1.6.0",
"versionDate": "2025-11-01",
"versionDescription": "What's Changed\r\n* Update AltStore source with latest release by @github-actions[bot] in https://github.com/venera-app/venera/pull/559\r\n* \u8c03\u6574\u591a\u6536\u85cf\u5939\u6f2b\u753b\u6e90\u7684\u6536\u85cf\u72b6\u6001\u663e\u793a\u903b\u8f91 by @Ftbom in https://github.com/venera-app/venera/pull/571\r\n* Enhance onResponse to support Future and validate result type by @wgh136 in https://github.com/venera-app/venera/pull/574\r\n* [iOS] Enable full screen swipe back gesture by @liulifox233 in https://github.com/venera-app/venera/pull/575\r\n* [linux] Fix linux nhentai cover image by @4b1tQu4ntN3k0 in https://github.com/venera-app/venera/pull/578\r\n* feat: \u652f\u6301\u8fc7\u6ee4\u9605\u8bfb\u5b8c\u6210\u60c5\u51b5 by @luckyray-fan in https://github.com/venera-app/venera/pull/582\r\n* Fix chinese character issue when compressing files. Close 565 by @ynyx631 in https://github.com/venera-app/venera/pull/583\r\n* Add support for ArrayBuffer to showInputDialog. by @wgh136 in https://github.com/venera-app/venera/pull/585\r\n* Added support for localstorage when logging in via webview. by @wgh136 in https://github.com/venera-app/venera/pull/586\r\n* Fix the issue of the comic list loading infinitely. Close 584 by @ynyx631 in https://github.com/venera-app/venera/pull/588\r\n* Save data when mark all as read by @lings03 in https://github.com/venera-app/venera/pull/592\r\n* Chapter comments. by @lings03 in https://github.com/venera-app/venera/pull/593\r\n* Optimize favorite page and home page. by @lings03 in https://github.com/venera-app/venera/pull/594\r\n* Update version code by @wgh136 in https://github.com/venera-app/venera/pull/596\r\n* Fix missing depends in deb package. Close 587 by @wgh136 in https://github.com/venera-app/venera/pull/597\r\n* feat: \u672c\u5730\u6536\u85cf\u641c\u7d22\u652f\u6301\u8f6c\u5c0f\u5199\u5339\u914d by @luckyray-fan in https://github.com/venera-app/venera/pull/598\r\n* Fix editor page gesture confict by @liulifox233 in https://github.com/venera-app/venera/pull/600\r \nNew Contributors\r\n* @github-actions[bot] made their first contribution in https://github.com/venera-app/venera/pull/559\r\n* @Ftbom made their first contribution in https://github.com/venera-app/venera/pull/571\r\n* @liulifox233 made their first contribution in https://github.com/venera-app/venera/pull/575\r\n* @4b1tQu4ntN3k0 made their first contribution in https://github.com/venera-app/venera/pull/578\r\n* @ynyx631 made their first contribution in https://github.com/venera-app/venera/pull/583\r \nFull Changelog: https://github.com/venera-app/venera/compare/v1.5.3...v1.6.0",
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.6.0/venera-ios-1.6.0%2B160.ipa",
"localizedDescription": "A comic reader that supports reading local and network comics",
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
"tintColor": "#0784FC",
"category": "utilities",
"size": 15064741,
"appPermissions": {
"entitlements": [
"application-identifier",
"com.apple.security.application-groups",
"get-task-allow",
"keychain-access-groups",
"com.apple.developer.kernel.extended-virtual-addressing",
"com.apple.developer.kernel.increased-memory-limit",
"com.apple.developer.healthkit.background-delivery"
],
"privacy": {
"NSFaceIDUsageDescription": "Face ID or Touch ID is used to protect your privacy when opening the app, ensuring secure access to your reading content.",
"NSPhotoLibraryAddUsageDescription": "Used to save comic images you've favorited or downloaded to your photo library for easy access and sharing.",
"NSPhotoLibraryUsageDescription": "Used to select images from your photo library when needed, and to save comic images you've collected to your device."
}
},
"versions": [
{
"version": "1.6.0",
"date": "2025-11-01",
"localizedDescription": "What's Changed\r\n* Update AltStore source with latest release by @github-actions[bot] in https://github.com/venera-app/venera/pull/559\r\n* \u8c03\u6574\u591a\u6536\u85cf\u5939\u6f2b\u753b\u6e90\u7684\u6536\u85cf\u72b6\u6001\u663e\u793a\u903b\u8f91 by @Ftbom in https://github.com/venera-app/venera/pull/571\r\n* Enhance onResponse to support Future and validate result type by @wgh136 in https://github.com/venera-app/venera/pull/574\r\n* [iOS] Enable full screen swipe back gesture by @liulifox233 in https://github.com/venera-app/venera/pull/575\r\n* [linux] Fix linux nhentai cover image by @4b1tQu4ntN3k0 in https://github.com/venera-app/venera/pull/578\r\n* feat: \u652f\u6301\u8fc7\u6ee4\u9605\u8bfb\u5b8c\u6210\u60c5\u51b5 by @luckyray-fan in https://github.com/venera-app/venera/pull/582\r\n* Fix chinese character issue when compressing files. Close 565 by @ynyx631 in https://github.com/venera-app/venera/pull/583\r\n* Add support for ArrayBuffer to showInputDialog. by @wgh136 in https://github.com/venera-app/venera/pull/585\r\n* Added support for localstorage when logging in via webview. by @wgh136 in https://github.com/venera-app/venera/pull/586\r\n* Fix the issue of the comic list loading infinitely. Close 584 by @ynyx631 in https://github.com/venera-app/venera/pull/588\r\n* Save data when mark all as read by @lings03 in https://github.com/venera-app/venera/pull/592\r\n* Chapter comments. by @lings03 in https://github.com/venera-app/venera/pull/593\r\n* Optimize favorite page and home page. by @lings03 in https://github.com/venera-app/venera/pull/594\r\n* Update version code by @wgh136 in https://github.com/venera-app/venera/pull/596\r\n* Fix missing depends in deb package. Close 587 by @wgh136 in https://github.com/venera-app/venera/pull/597\r\n* feat: \u672c\u5730\u6536\u85cf\u641c\u7d22\u652f\u6301\u8f6c\u5c0f\u5199\u5339\u914d by @luckyray-fan in https://github.com/venera-app/venera/pull/598\r\n* Fix editor page gesture confict by @liulifox233 in https://github.com/venera-app/venera/pull/600\r \nNew Contributors\r\n* @github-actions[bot] made their first contribution in https://github.com/venera-app/venera/pull/559\r\n* @Ftbom made their first contribution in https://github.com/venera-app/venera/pull/571\r\n* @liulifox233 made their first contribution in https://github.com/venera-app/venera/pull/575\r\n* @4b1tQu4ntN3k0 made their first contribution in https://github.com/venera-app/venera/pull/578\r\n* @ynyx631 made their first contribution in https://github.com/venera-app/venera/pull/583\r \nFull Changelog: https://github.com/venera-app/venera/compare/v1.5.3...v1.6.0",
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.6.0/venera-ios-1.6.0%2B160.ipa",
"size": 15064741
},
{
"version": "1.5.3",
"date": "2025-10-13",
"localizedDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
"size": 15047841
},
{
"version": "1.4.5",
"date": "2025-06-18",
"localizedDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
"size": 14960268
}
]
}
],
"news": [
{
"appID": "com.github.wgh136.venera",
"caption": "Update of Venera just got released!",
"date": "2025-06-18T09:02:01Z",
"identifier": "release-v1.4.5",
"notify": true,
"tintColor": "#0784FC",
"title": "v1.4.5 - Venera 18/06/25",
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
},
{
"appID": "com.github.wgh136.venera",
"caption": "Update of Venera just got released!",
"date": "2025-10-13T12:47:27Z",
"identifier": "release-v1.5.3",
"notify": true,
"tintColor": "#0784FC",
"title": "v1.5.3 - Venera 13/10/25",
"url": "https://github.com/venera-app/venera/releases/tag/v1.5.3"
},
{
"appID": "com.github.wgh136.venera",
"caption": "Update of Venera just got released!",
"date": "2025-11-01T07:31:38Z",
"identifier": "release-v1.6.0",
"notify": true,
"tintColor": "#0784FC",
"title": "v1.6.0 - Venera 01/11/25",
"url": "https://github.com/venera-app/venera/releases/tag/v1.6.0"
}
]
}

View File

@@ -23,7 +23,7 @@ linter:
rules:
collection_methods_unrelated_type: false
use_build_context_synchronously: false
# avoid_print: false # Uncomment to disable the `avoid_print` rule
avoid_print: false
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at

View File

@@ -34,6 +34,12 @@ android {
compileSdk = flutter.compileSdkVersion
ndkVersion "28.0.13004108"
packaging {
jniLibs {
useLegacyPackaging true
}
}
splits{
abi {
reset()
@@ -78,6 +84,8 @@ android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
}

View File

@@ -16,6 +16,7 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
@@ -58,8 +59,6 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- [flutter 3.27.1] Impeller is still worse than skia, disable it -->
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.9.0' apply false
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}
include ":app"

View File

@@ -4,6 +4,18 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app.
*/
/**
* @function sendMessage
* @global
* @param {Object} message
* @returns {any}
*/
/**
* Set a timeout to execute a callback function after a specified delay.
* @param callback {Function}
* @param delay {number} - delay in milliseconds
*/
function setTimeout(callback, delay) {
sendMessage({
method: 'delay',
@@ -39,6 +51,32 @@ let Convert = {
});
},
/**
* @param str {string}
* @returns {ArrayBuffer}
*/
encodeGbk: (str) => {
return sendMessage({
method: "convert",
type: "gbk",
value: str,
isEncode: true
});
},
/**
* @param value {ArrayBuffer}
* @returns {string}
*/
decodeGbk: (value) => {
return sendMessage({
method: "convert",
type: "gbk",
value: value,
isEncode: false
});
},
/**
* @param {ArrayBuffer} value
* @returns {string}
@@ -152,6 +190,21 @@ let Convert = {
});
},
/**
* @param {ArrayBuffer} value
* @param {ArrayBuffer} key
* @returns {ArrayBuffer}
*/
encryptAesEcb: (value, key) => {
return sendMessage({
method: "convert",
type: "aes-ecb",
value: value,
key: key,
isEncode: true
});
},
/**
* @param {ArrayBuffer} value
* @param {ArrayBuffer} key
@@ -167,6 +220,23 @@ let Convert = {
});
},
/**
* @param {ArrayBuffer} value
* @param {ArrayBuffer} key
* @param {ArrayBuffer} iv
* @returns {ArrayBuffer}
*/
encryptAesCbc: (value, key, iv) => {
return sendMessage({
method: "convert",
type: "aes-cbc",
value: value,
key: key,
iv: iv,
isEncode: true
});
},
/**
* @param {ArrayBuffer} value
* @param {ArrayBuffer} key
@@ -176,7 +246,7 @@ let Convert = {
decryptAesCbc: (value, key, iv) => {
return sendMessage({
method: "convert",
type: "aes-ecb",
type: "aes-cbc",
value: value,
key: key,
iv: iv,
@@ -187,20 +257,58 @@ let Convert = {
/**
* @param {ArrayBuffer} value
* @param {ArrayBuffer} key
* @param {ArrayBuffer} iv
* @param {number} blockSize
* @returns {ArrayBuffer}
*/
decryptAesCfb: (value, key, blockSize) => {
encryptAesCfb: (value, key, iv, blockSize) => {
return sendMessage({
method: "convert",
type: "aes-cfb",
value: value,
key: key,
iv: iv,
blockSize: blockSize,
isEncode: true
});
},
/**
* @param {ArrayBuffer} value
* @param {ArrayBuffer} key
* @param {ArrayBuffer} iv
* @param {number} blockSize
* @returns {ArrayBuffer}
*/
decryptAesCfb: (value, key, iv, blockSize) => {
return sendMessage({
method: "convert",
type: "aes-cfb",
value: value,
key: key,
iv: iv,
blockSize: blockSize,
isEncode: false
});
},
/**
* @param {ArrayBuffer} value
* @param {ArrayBuffer} key
* @param {number} blockSize
* @returns {ArrayBuffer}
*/
encryptAesOfb: (value, key, blockSize) => {
return sendMessage({
method: "convert",
type: "aes-ofb",
value: value,
key: key,
blockSize: blockSize,
isEncode: true
});
},
/**
* @param {ArrayBuffer} value
* @param {ArrayBuffer} key
@@ -357,9 +465,10 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @param {Object} extra - Extra options to pass to the interceptor.
* @returns {Promise<{status: number, headers: {}, body: ArrayBuffer}>} The response from the request.
*/
async fetchBytes(method, url, headers, data) {
async fetchBytes(method, url, headers, data, extra) {
let result = await sendMessage({
method: 'http',
http_method: method,
@@ -367,6 +476,7 @@ let Network = {
url: url,
headers: headers,
data: data,
extra: extra,
});
if (result.error) {
@@ -382,15 +492,17 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @param {Object} extra - Extra options to pass to the interceptor.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async sendRequest(method, url, headers, data) {
async sendRequest(method, url, headers, data, extra) {
let result = await sendMessage({
method: 'http',
http_method: method,
url: url,
headers: headers,
data: data,
extra: extra,
});
if (result.error) {
@@ -404,10 +516,11 @@ let Network = {
* Sends an HTTP GET request.
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param {Object} extra - Extra options to pass to the interceptor.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async get(url, headers) {
return this.sendRequest('GET', url, headers);
async get(url, headers, extra) {
return this.sendRequest('GET', url, headers, extra);
},
/**
@@ -415,10 +528,11 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @param {Object} extra - Extra options to pass to the interceptor.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async post(url, headers, data) {
return this.sendRequest('POST', url, headers, data);
async post(url, headers, data, extra) {
return this.sendRequest('POST', url, headers, data, extra);
},
/**
@@ -426,10 +540,11 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @param {Object} extra - Extra options to pass to the interceptor.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async put(url, headers, data) {
return this.sendRequest('PUT', url, headers, data);
async put(url, headers, data, extra) {
return this.sendRequest('PUT', url, headers, data, extra);
},
/**
@@ -437,20 +552,22 @@ let Network = {
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param data - The data to send with the request.
* @param {Object} extra - Extra options to pass to the interceptor.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async patch(url, headers, data) {
return this.sendRequest('PATCH', url, headers, data);
async patch(url, headers, data, extra) {
return this.sendRequest('PATCH', url, headers, data, extra);
},
/**
* Sends an HTTP DELETE request.
* @param {string} url - The URL to send the request to.
* @param {Object} headers - The headers to include in the request.
* @param {Object} extra - Extra options to pass to the interceptor.
* @returns {Promise<{status: number, headers: {}, body: string}>} The response from the request.
*/
async delete(url, headers) {
return this.sendRequest('DELETE', url, headers);
async delete(url, headers, extra) {
return this.sendRequest('DELETE', url, headers, extra);
},
/**
@@ -1296,13 +1413,15 @@ let UI = {
* Show an input dialog
* @param title {string}
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
* @param image {string | ArrayBuffer | null | undefined} - Since 1.4.6, you can pass an image url to show an image in the dialog. Since 1.5.3, you can also pass an ArrayBuffer to show a custom image.
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
*/
showInputDialog: (title, validator) => {
showInputDialog: (title, validator, image) => {
return sendMessage({
method: 'UI',
function: 'showInputDialog',
title: title,
image: image,
validator: validator
})
},
@@ -1383,4 +1502,19 @@ function getClipboard() {
return sendMessage({
method: 'getClipboard'
})
}
/**
* Compute a function with arguments. The function will be executed in the engine pool which is not in the main thread.
* @param func {string} - A js code string which can be evaluated to a function. The function will receive the args as its only argument.
* @param args {any[]} - The arguments to pass to the function.
* @returns {Promise<any>} - The result of the function.
* @since 1.5.0
*/
function compute(func, ...args) {
return sendMessage({
method: 'compute',
function: func,
args: args
})
}

3982
assets/opencc.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@
"Move to folder": "移动到文件夹",
"Copy to folder": "复制到文件夹",
"Delete Comic": "删除漫画",
"Jump to Detail": "跳转详情",
"Delete @c comics?": "删除 @c 本漫画?",
"Add comic source": "添加漫画源",
"Delete comic source '@n' ?": "删除漫画源 '@n' ",
@@ -69,6 +70,9 @@
"Next": "前进",
"Login with webview": "通过网页登录",
"Read": "阅读",
"Completed": "已完成",
"UnCompleted": "未完成",
"Filter reading status": "过滤阅读状态",
"Download": "下载",
"Favorite": "收藏",
"Comments": "评论",
@@ -83,7 +87,10 @@
"New Folder": "新建文件夹",
"Reading": "阅读中",
"Appearance": "外观",
"Network Favorites": "网络收藏",
"Local Favorites": "本地收藏",
"Show local favorites before network favorites": "在网络收藏之前显示本地收藏",
"Auto close favorite panel after operation": "自动关闭收藏面板",
"APP": "应用",
"About": "关于",
"Display mode of comic tile": "漫画缩略图的显示模式",
@@ -96,6 +103,7 @@
"Show favorite status on comic tile": "在漫画缩略图上显示收藏状态",
"Show history on comic tile": "在漫画缩略图上显示历史记录",
"Keyword blocking": "关键词屏蔽",
"Comment keyword blocking": "评论关键词屏蔽",
"Tap to turn Pages": "点击翻页",
"Page animation": "页面动画",
"Reading mode": "阅读模式",
@@ -194,6 +202,10 @@
"Sync Data": "同步数据",
"Syncing Data": "正在同步数据",
"Data Sync": "数据同步",
"Skip Setting Fields": "跳过设置项",
"Skip Setting Fields (Optional)": "跳过设置项(可选)",
"When sync data, skip certain setting fields, which means these won't be uploaded / override.": "同步时跳过指定设置项,这些项不会被上传或覆盖。",
"See source code for available fields.": "可用的设置项名称详见源码。",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
"Added": "已添加",
@@ -234,8 +246,10 @@
"Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面",
"Group @group": "第 @group 组",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 页",
"Remove local favorite and history": "删除本地收藏和历史记录",
"Also remove files on disk": "同时删除磁盘上的文件",
"Copy to app local path": "将漫画复制到本地存储目录中",
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
@@ -374,6 +388,8 @@
"Continuous": "连续",
"Display mode of comic list": "漫画列表的显示模式",
"Show Page Number": "显示页码",
"Show Chapter Comments": "显示章节评论",
"Chapter Comments": "章节评论",
"Jump to page": "跳转到页面",
"Page": "页面",
"Jump": "跳转",
@@ -387,7 +403,29 @@
"Screen center": "屏幕中心",
"Suggestions": "建议",
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
"Click the setting icon to change the source list url.": "点击设置图标更改源列表URL"
"Show single image on first page": "在首页显示单张图片",
"Show system status bar": "显示系统状态栏",
"Click to select an image": "点击选择一张图片",
"Repo URL": "仓库地址",
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
"Double tap to zoom": "双击缩放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反转",
"Delete Chapters": "删除章节",
"Open Folder": "打开文件夹",
"Path copied to clipboard": "路径已复制到剪贴板",
"Reverse default chapter order": "反转默认章节顺序",
"Reload Configs": "重新加载配置文件",
"Reload": "重载",
"Disable Length Limitation": "禁用长度限制",
"Only valid for this run": "仅对本次运行有效",
"Logs": "日志",
"Export logs": "导出日志",
"Clear specific reader settings for all comics": "清除所有漫画的特殊阅读设置",
"Clear specific reader settings for this comic": "清除该漫画的特殊阅读设置",
"Enable comic specific settings": "启用此漫画特定设置",
"Ignore Certificate Errors": "忽略证书错误",
"Mouse scroll speed": "鼠标滚动速度"
},
"zh_TW": {
"Home": "首頁",
@@ -437,6 +475,7 @@
"Move": "移動",
"Move to folder": "移動到資料夾",
"Copy to folder": "複製到資料夾",
"Jump to Detail": "跳轉詳情​​",
"Delete Comic": "刪除漫畫",
"Delete @c comics?": "刪除 @c 本漫畫?",
"Add comic source": "添加漫畫源",
@@ -460,6 +499,9 @@
"Next": "前進",
"Login with webview": "透過網頁登入",
"Read": "閱讀",
"Completed": "已完成",
"UnCompleted": "未完成",
"Filter reading status": "過濾閱讀狀態",
"Download": "下載",
"Favorite": "收藏",
"Comments": "評論",
@@ -473,7 +515,10 @@
"New Folder": "建立資料夾",
"Reading": "閱讀中",
"Appearance": "外觀",
"Network Favorites": "網路收藏",
"Local Favorites": "本機收藏",
"Show local favorites before network favorites": "在網路收藏之前顯示本機收藏",
"Auto close favorite panel after operation": "自動關閉收藏面板",
"APP": "應用",
"About": "關於",
"Display mode of comic tile": "漫畫縮圖的顯示模式",
@@ -485,6 +530,7 @@
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
"Keyword blocking": "關鍵字封鎖",
"Comment keyword blocking": "評論關鍵字封鎖",
"Tap to turn Pages": "點擊翻頁",
"Page animation": "頁面動畫",
"Reading mode": "閱讀模式",
@@ -584,6 +630,10 @@
"Sync Data": "同步資料",
"Syncing Data": "正在同步資料",
"Data Sync": "資料同步",
"Skip Setting Fields": "跳過設定項",
"Skip Setting Fields (Optional)": "跳過設定項(可選)",
"When sync data, skip certain setting fields, which means these won't be uploaded / override.": "同步時跳過指定設定項,這些項不會被上傳或覆寫。",
"See source code for available fields.": "可用的設定項名稱詳見源碼。",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
"Added": "已添加",
@@ -624,8 +674,10 @@
"Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面",
"Group @group": "第 @group 組",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁",
"Remove local favorite and history": "刪除本機收藏和歷史記錄",
"Also remove files on disk": "同時刪除磁碟上的文件",
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
@@ -764,6 +816,8 @@
"Continuous": "連續",
"Display mode of comic list": "漫畫列表的顯示模式",
"Show Page Number": "顯示頁碼",
"Show Chapter Comments": "顯示章節評論",
"Chapter Comments": "章節評論",
"Jump to page": "跳轉到頁面",
"Page": "頁面",
"Jump": "跳轉",
@@ -777,6 +831,28 @@
"Screen center": "螢幕中心",
"Suggestions": "建議",
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
"Click the setting icon to change the source list url.": "點擊設定圖示更改源列表URL"
"Show single image on first page": "在首頁顯示單張圖片",
"Show system status bar": "顯示系統狀態欄",
"Click to select an image": "點擊選擇一張圖片",
"Repo URL": "倉庫地址",
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
"Double tap to zoom": "雙擊縮放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反轉",
"Delete Chapters": "刪除章節",
"Open Folder": "打開資料夾",
"Path copied to clipboard": "路徑已複製到剪貼簿",
"Reverse default chapter order": "反轉預設章節順序",
"Reload Configs": "重新載入設定檔",
"Reload": "重載",
"Disable Length Limitation": "禁用長度限制",
"Only valid for this run": "僅對本次運行有效",
"Logs": "日誌",
"Export logs": "匯出日誌",
"Clear specific reader settings for all comics": "清除所有漫畫的特殊閱讀設定",
"Clear specific reader settings for this comic": "清除該漫畫的特殊閱讀設定",
"Enable comic specific settings": "啟用此漫畫特定設定",
"Ignore Certificate Errors": "忽略證書錯誤",
"Mouse scroll speed": "滑鼠滾動速度"
}
}
}

View File

@@ -9,13 +9,47 @@ Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine wh
This document will describe how to write a comic source for Venera.
## Preparation
## Comic Source List
Venera can display a list of comic sources in the app.
You can use the following repo url:
```
https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json
```
The repo is maintained by the Venera team.
> The link is a mirror of the original repo. To contribute your comic source, please visit the [original repo](https://github.com/venera-app/venera-configs)
You should provide a repository url to let the app load the comic source list.
The url should point to a JSON file that contains the list of comic sources.
The JSON file should have the following format:
```json
[
{
"name": "Source Name",
"url": "https://example.com/source.js",
"filename": "Relative path to the source file",
"version": "1.0.0",
"description": "A brief description of the source"
}
]
```
Only one of `url` and `filename` should be provided.
The description field is optional.
## Create a Comic Source
### Preparation
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
- An editor that supports javascript.
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
## Start Writing
### Start Writing
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
@@ -23,7 +57,7 @@ Here is a brief introduction to the template:
> Note: Javascript api document is [here](js_api.md).
### Write basic information
#### Write basic information
```javascript
class NewComicSource extends ComicSource {
@@ -49,7 +83,7 @@ In this part, you need to do the following:
- Change the class name to your source name.
- Fill in the name, key, version, minAppVersion, and url fields.
### init function
#### init function
```javascript
/**
@@ -64,7 +98,7 @@ The function will be called when the source is initialized. You can do some init
Remove this function if not used.
### Account
#### Account
```javascript
// [Optional] account related
@@ -140,7 +174,7 @@ In this part, you can implement login, logout, and register functions.
Remove this part if not used.
### Explore page
#### Explore page
```javascript
// explore page list
@@ -185,7 +219,7 @@ There are three types of explore pages:
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
### Category Page
#### Category Page
```javascript
// categories
@@ -227,7 +261,7 @@ Category page is a static page that contains multiple parts, each part contains
A comic source can only have one category page.
### Category Comics Page
#### Category Comics Page
```javascript
/// category comic loading related
@@ -280,7 +314,7 @@ When user clicks on a category, the category comics page will be displayed.
This part is used to load comics of a category.
### Search
#### Search
```javascript
/// search related
@@ -331,6 +365,11 @@ This part is used to load comics of a category.
// enable tags suggestions
enableTagsSuggestions: false,
// [Optional] handle tag suggestion click
onTagSuggestionSelected: (namespace, tag) => {
// return the text to insert into search box
return `${namespace}:${tag}`
},
}
```
@@ -339,7 +378,7 @@ This part is used to load search results.
`load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored.
### Favorites
#### Favorites
```javascript
// favorite related
@@ -411,7 +450,7 @@ This part is used to manage network favorites of the source.
`load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored.
### Comic Details
#### Comic Details
```javascript
/// single comic related
@@ -514,6 +553,51 @@ If `load` function is implemented, `loadNext` function will be ignored.
*/
sendComment: async (comicId, subId, content, replyTo) => {
},
/**
* [Optional] load chapter comments
*
* Chapter comments are displayed in the reader.
* Same rich text support as loadComments.
*
* Note: To control reply functionality:
* - If a comment does not support replies, set its `id` to null/undefined
* - Or set its `replyCount` to null/undefined
* - The reply button will only show when both `id` and `replyCount` are present
*
* @param comicId {string}
* @param epId {string} - chapter id
* @param page {number}
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
* @returns {Promise<{comments: Comment[], maxPage: number?}>}
*
* @example
* // Example for comments without reply support:
* return {
* comments: data.list.map(e => ({
* userName: e.user_name,
* avatar: e.user_avatar,
* content: e.comment,
* time: e.create_at,
* replyCount: null, // or undefined - no reply support
* id: null, // or undefined - no reply support
* })),
* maxPage: Math.ceil(total / 20)
* }
*/
loadChapterComments: async (comicId, epId, page, replyTo) => {
},
/**
* [Optional] send a chapter comment, return any value to indicate success
* @param comicId {string}
* @param epId {string} - chapter id
* @param content {string}
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
* @returns {Promise<any>}
*/
sendChapterComment: async (comicId, epId, content, replyTo) => {
},
/**
* [Optional] like or unlike a comment
@@ -576,7 +660,7 @@ If `load` function is implemented, `loadNext` function will be ignored.
This part is used to load comic details.
### Settings
#### Settings
```javascript
/*
@@ -635,7 +719,7 @@ This part is used to load comic details.
This part is used to provide settings for the source.
### Translations
#### Translations
```javascript
// [Optional] translations for the strings in this config

180
doc/headless_doc.md Normal file
View File

@@ -0,0 +1,180 @@
# Venera Headless Mode
Venera's headless mode allows you to run key features from the command line, making it easy to automate tasks and integrate with other tools. This document outlines the available commands and their usage.
## How to Use
To activate headless mode, use the `--headless` flag when running the Venera executable, followed by the desired command.
```bash
venera --headless <command> [subcommand] [options]
```
## Global Options
- **`--ignore-disheadless-log`**: Suppresses log output, providing a cleaner output for scripting.
## Commands
### `webdav`
Manage WebDAV data synchronization.
- **`webdav up`**: Uploads your local configuration to the WebDAV server.
- **`webdav down`**: Downloads and applies the remote configuration from the WebDAV server.
**Example:**
```bash
venera --headless webdav up
```
### `updatescript`
Update comic source scripts.
- **`updatescript all`**: Checks for and applies all available updates for your comic source scripts.
**Example:**
```bash
venera --headless updatescript all
```
**Output Format:**
The `updatescript` command provides detailed progress and a final summary.
**Progress Logs:**
- **`Progress`**: Indicates a successful update for a single script.
- **`ProgressError`**: Indicates a failure during a script update.
**Example `Progress` Log:**
```json
{
"status": "running",
"message": "Progress",
"data": {
"current": 1,
"total": 5,
"source": {
"key": "source-key",
"name": "Source Name",
"version": "1.0.0",
"url": "https://example.com/source.js"
}
}
}
```
**Final Summary:**
A summary is provided at the end, detailing the total number of scripts, how many were updated, and how many failed.
```json
{
"status": "success",
"message": "All scripts updated.",
"data": {
"total": 5,
"updated": 4,
"errors": 1
}
}
```
### `updatesubscribe`
Update your subscribed comics and retrieve a list of updated comics.
- **`updatesubscribe`**: Checks all subscribed comics for updates.
- **`updatesubscribe --update-comic-by-id-type <id> <type>`**: Updates a single comic specified by its `id` and `type`.
**Example:**
```bash
# Update all subscriptions
venera --headless updatesubscribe
# Update a single comic
venera --headless updatesubscribe --update-comic-by-id-type "comic-id" "source-key"
```
## Output Format
All headless commands output JSON objects prefixed with `[CLI PRINT]`. This structured format allows for easy parsing in automated scripts. The JSON object always contains a `status` and a `message`. For commands that return data, a `data` field will also be present.
### `updatesubscribe` Output
The `updatesubscribe` command provides detailed progress and final results in JSON format.
**Progress Logs:**
During an update, you will receive `Progress` or `ProgressError` messages.
- **`Progress`**: Indicates a successful step in the update process.
- **`ProgressError`**: Indicates an error occurred while updating a specific comic.
**Example `Progress` Log:**
```json
{
"status": "running",
"message": "Progress",
"data": {
"current": 1,
"total": 10,
"comic": {
"id": "some-comic-id",
"name": "Some Comic Name",
"coverUrl": "https://example.com/cover.jpg",
"author": "Author Name",
"type": "source-key",
"updateTime": "2023-10-27T12:00:00Z",
"tags": ["tag1", "tag2"]
}
}
}
```
**Example `ProgressError` Log:**
```json
{
"status": "running",
"message": "ProgressError",
"data": {
"current": 2,
"total": 10,
"comic": {
"id": "another-comic-id",
"name": "Another Comic Name",
...
},
"error": "Error message here"
}
}
```
**Final Output:**
Once the update process is complete, a final JSON object is returned with a list of all comics that have been updated.
```json
{
"status": "success",
"message": "Updated comics list.",
"data": [
{
"id": "some-comic-id",
"name": "Some Comic Name",
"coverUrl": "https://example.com/cover.jpg",
"author": "Author Name",
"type": "source-key",
"updateTime": "2023-10-27T12:00:00Z",
"tags": ["tag1", "tag2"]
}
]
}

View File

@@ -46,12 +46,14 @@
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose images</string>
<string>Choose images</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Ensure that the operation is being performed by the user themselves.</string>
<string>Ensure that the operation is being performed by the user themselves.</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.books</string>
</dict>
</plist>

View File

@@ -17,6 +17,7 @@ ImageProvider? _findImageProvider(Comic comic) {
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
fallbackToLocalCover: comic is FavoriteItem,
);
}
return image;
@@ -752,9 +753,9 @@ class SliverGridComics extends StatefulWidget {
final List<MenuEntry> Function(Comic)? menuBuilder;
final void Function(Comic)? onTap;
final void Function(Comic, int heroID)? onTap;
final void Function(Comic)? onLongPressed;
final void Function(Comic, int heroID)? onLongPressed;
@override
State<SliverGridComics> createState() => _SliverGridComicsState();
@@ -855,52 +856,51 @@ class _SliverGridComics extends StatelessWidget {
final List<MenuEntry> Function(Comic)? menuBuilder;
final void Function(Comic)? onTap;
final void Function(Comic, int heroID)? onTap;
final void Function(Comic)? onLongPressed;
final void Function(Comic, int heroID)? onLongPressed;
@override
Widget build(BuildContext context) {
return SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == comics.length - 1) {
onLastItemBuild?.call();
}
var badge = badgeBuilder?.call(comics[index]);
var isSelected =
selection == null ? false : selection![comics[index]] ?? false;
var comic = ComicTile(
comic: comics[index],
badge: badge,
menuOptions: menuBuilder?.call(comics[index]),
onTap: onTap != null ? () => onTap!(comics[index]) : null,
onLongPressed: onLongPressed != null
? () => onLongPressed!(comics[index])
delegate: SliverChildBuilderDelegate((context, index) {
if (index == comics.length - 1) {
onLastItemBuild?.call();
}
var badge = badgeBuilder?.call(comics[index]);
var isSelected = selection == null
? false
: selection![comics[index]] ?? false;
var comic = ComicTile(
comic: comics[index],
badge: badge,
menuOptions: menuBuilder?.call(comics[index]),
onTap: onTap != null
? () => onTap!(comics[index], heroIDs[index])
: null,
onLongPressed: onLongPressed != null
? () => onLongPressed!(comics[index], heroIDs[index])
: null,
heroID: heroIDs[index],
);
if (selection == null) {
return comic;
}
return AnimatedContainer(
key: ValueKey(comics[index].id),
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: isSelected
? Theme.of(
context,
).colorScheme.secondaryContainer.toOpacity(0.72)
: null,
heroID: heroIDs[index],
);
if (selection == null) {
return comic;
}
return AnimatedContainer(
key: ValueKey(comics[index].id),
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.secondaryContainer
.toOpacity(0.72)
: null,
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(4),
child: comic,
);
},
childCount: comics.length,
),
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(4),
child: comic,
);
}, childCount: comics.length),
gridDelegate: SliverGridDelegateWithComics(),
);
}
@@ -1158,7 +1158,7 @@ class ComicListState extends State<ComicList> {
if (res.data.isEmpty) {
setState(() {
_data[page] = const [];
_maxPage = page;
_maxPage ??= page;
});
} else {
setState(() {
@@ -1281,8 +1281,8 @@ class ComicListState extends State<ComicList> {
],
);
}
if (_data[_page] == null) {
_loadPage(_page);
if (_data[1] == null) {
_loadPage(1);
return Column(
children: [
if (widget.errorLeading != null) widget.errorLeading!,
@@ -1303,7 +1303,7 @@ class ComicListState extends State<ComicList> {
comics: _data.values.expand((element) => element).toList(),
menuBuilder: widget.menuBuilder,
onLastItemBuild: () {
if (_error == null && (_maxPage == null || _page < _maxPage!)) {
if (_error == null && (_maxPage == null || _data.length < _maxPage!)) {
_loadPage(_data.length + 1);
}
},
@@ -1333,7 +1333,7 @@ class ComicListState extends State<ComicList> {
],
).paddingHorizontal(16).paddingVertical(8),
)
else if (_maxPage == null || _page < _maxPage!)
else if (_maxPage == null || _data.length < _maxPage!)
const SliverListLoadingIndicator(),
if (widget.trailingSliver != null) widget.trailingSliver!,
],
@@ -1626,7 +1626,7 @@ class _SMClipper extends CustomClipper<Rect> {
class SimpleComicTile extends StatelessWidget {
const SimpleComicTile(
{super.key, required this.comic, this.onTap, this.withTitle = false});
{super.key, required this.comic, this.onTap, this.withTitle = false, this.heroID});
final Comic comic;
@@ -1634,6 +1634,8 @@ class SimpleComicTile extends StatelessWidget {
final bool withTitle;
final int? heroID;
@override
Widget build(BuildContext context) {
var image = _findImageProvider(comic);
@@ -1659,6 +1661,13 @@ class SimpleComicTile extends StatelessWidget {
child: child,
);
if (heroID != null) {
child = Hero(
tag: "cover$heroID",
child: child,
);
}
child = AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ??
@@ -1667,6 +1676,9 @@ class SimpleComicTile extends StatelessWidget {
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
heroID: heroID,
),
);
},

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:math' as math;
import 'dart:ui' as ui;
@@ -21,11 +22,13 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -37,9 +39,10 @@ mixin class JsUiApi {
case 'showInputDialog':
var title = message['title'];
var validator = message['validator'];
var image = message['image'];
if (title is! String) return;
if (validator != null && validator is! JSInvokable) return;
return _showInputDialog(title, validator);
return _showInputDialog(title, validator, image);
case 'showSelectDialog':
var title = message['title'];
var options = message['options'];
@@ -124,12 +127,25 @@ mixin class JsUiApi {
controller?.close();
}
Future<String?> _showInputDialog(String title, JSInvokable? validator) async {
Future<String?> _showInputDialog(String title, JSInvokable? validator, dynamic image) async {
String? result;
var func = validator == null ? null : JSAutoFreeFunction(validator);
String? imageUrl;
Uint8List? imageData;
if (image != null) {
if (image is String) {
imageUrl = image;
} else if (image is Uint8List) {
imageData = image;
} else if (image is List<int>) {
imageData = Uint8List.fromList(image);
}
}
await showInputDialog(
context: App.rootContext,
title: title,
image: imageUrl,
imageData: imageData,
onConfirm: (v) {
if (func != null) {
var res = func.call([v]);

View File

@@ -7,6 +7,7 @@ class NetworkError extends StatelessWidget {
this.retry,
this.withAppbar = true,
this.buttonText,
this.action,
});
final String message;
@@ -17,6 +18,8 @@ class NetworkError extends StatelessWidget {
final String? buttonText;
final Widget? action;
@override
Widget build(BuildContext context) {
var cfe = CloudflareException.fromString(message);
@@ -41,18 +44,22 @@ class NetworkError extends StatelessWidget {
],
),
),
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
Text(
cfe == null ? message : "Cloudflare verification required".tl,
textAlign: TextAlign.center,
maxLines: 3,
),
if (retry != null)
const SizedBox(
height: 12,
),
TextButton(
onPressed: () {
saveFile(
data: utf8.encode(Log().toString()),
filename: 'log.txt',
);
},
child: Text("Export logs".tl),
),
const SizedBox(height: 8),
if (retry != null)
if (cfe != null)
FilledButton(
@@ -63,9 +70,16 @@ class NetworkError extends StatelessWidget {
child: Text('Verify'.tl),
)
else
FilledButton(
onPressed: retry,
child: Text(buttonText ?? 'Retry'.tl),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (action != null)
action!.paddingRight(8),
FilledButton(
onPressed: retry,
child: Text(buttonText ?? 'Retry'.tl),
),
],
),
],
),
@@ -74,15 +88,11 @@ class NetworkError extends StatelessWidget {
body = Column(
children: [
const Appbar(title: Text("")),
Expanded(
child: body,
)
Expanded(child: body),
],
);
}
return Material(
child: body,
);
return Material(child: body);
}
}
@@ -94,9 +104,7 @@ class ListLoadingIndicator extends StatelessWidget {
return const SizedBox(
width: double.infinity,
height: 80,
child: Center(
child: FiveDotLoadingAnimation(),
),
child: Center(child: FiveDotLoadingAnimation()),
);
}
}
@@ -108,10 +116,9 @@ class SliverListLoadingIndicator extends StatelessWidget {
Widget build(BuildContext context) {
// SliverToBoxAdapter can not been lazy loaded.
// Use SliverList to make sure the animation can be lazy loaded.
return SliverList.list(children: const [
SizedBox(),
ListLoadingIndicator(),
]);
return SliverList.list(
children: const [SizedBox(), ListLoadingIndicator()],
);
}
}
@@ -178,10 +185,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
}
Widget buildError() {
return NetworkError(
message: error!,
retry: retry,
);
return NetworkError(message: error!, retry: retry);
}
@override
@@ -323,11 +327,7 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
}
Widget buildError(BuildContext context, String error) {
return NetworkError(
withAppbar: false,
message: error,
retry: reset,
);
return NetworkError(withAppbar: false, message: error, retry: reset);
}
@override
@@ -388,7 +388,7 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
Colors.green,
Colors.blue,
Colors.yellow,
Colors.purple
Colors.purple,
];
static const _padding = 12.0;
@@ -400,16 +400,15 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return SizedBox(
width: _dotSize * 5 + _padding * 6,
height: _height,
child: Stack(
children: List.generate(5, (index) => buildDot(index)),
),
);
});
animation: _controller,
builder: (context, child) {
return SizedBox(
width: _dotSize * 5 + _padding * 6,
height: _height,
child: Stack(children: List.generate(5, (index) => buildDot(index))),
);
},
);
}
Widget buildDot(int index) {
@@ -417,7 +416,8 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
var startValue = index * 0.8;
return Positioned(
left: index * _dotSize + (index + 1) * _padding,
bottom: (math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
bottom:
(math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
(_height - _dotSize),
child: Container(
width: _dotSize,

View File

@@ -290,28 +290,30 @@ class ContentDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
var content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title != null
? Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title!),
backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content,
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions,
).paddingRight(12),
const SizedBox(height: 16),
],
var content = SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title != null
? Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title!),
backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content,
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions,
).paddingRight(12),
const SizedBox(height: 16),
],
),
);
return Dialog(
shape: RoundedRectangleBorder(
@@ -357,6 +359,8 @@ Future<void> showInputDialog({
String confirmText = "Confirm",
String cancelText = "Cancel",
RegExp? inputValidator,
String? image,
Uint8List? imageData,
}) {
var controller = TextEditingController(text: initialValue);
bool isLoading = false;
@@ -369,14 +373,28 @@ Future<void> showInputDialog({
builder: (context, setState) {
return ContentDialog(
title: title,
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
errorText: error,
),
).paddingHorizontal(12),
content: Column(
children: [
if (image != null)
SizedBox(
height: 108,
child: Image.network(image, fit: BoxFit.none),
).paddingBottom(8),
if (image == null && imageData != null)
SizedBox(
height: 108,
child: Image.memory(imageData, fit: BoxFit.none),
).paddingBottom(8),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
errorText: error,
),
).paddingHorizontal(12),
],
),
actions: [
Button.filled(
isLoading: isLoading,

View File

@@ -7,8 +7,11 @@ class PaneItemEntry {
IconData activeIcon;
PaneItemEntry(
{required this.label, required this.icon, required this.activeIcon});
PaneItemEntry({
required this.label,
required this.icon,
required this.activeIcon,
});
}
class PaneActionEntry {
@@ -18,20 +21,24 @@ class PaneActionEntry {
VoidCallback onTap;
PaneActionEntry(
{required this.label, required this.icon, required this.onTap});
PaneActionEntry({
required this.label,
required this.icon,
required this.onTap,
});
}
class NaviPane extends StatefulWidget {
const NaviPane(
{required this.paneItems,
required this.paneActions,
required this.pageBuilder,
this.initialPage = 0,
this.onPageChanged,
required this.observer,
required this.navigatorKey,
super.key});
const NaviPane({
required this.paneItems,
required this.paneActions,
required this.pageBuilder,
this.initialPage = 0,
this.onPageChanged,
required this.observer,
required this.navigatorKey,
super.key,
});
final List<PaneItemEntry> paneItems;
@@ -165,6 +172,16 @@ class NaviPaneState extends State<NaviPane>
@override
Widget build(BuildContext context) {
onRebuild(context);
final mq = MediaQuery.of(context);
final sideInsets =
(App.isMobile && mq.orientation == Orientation.landscape)
? EdgeInsets.only(
left: math.max(
mq.viewPadding.left, mq.systemGestureInsets.left),
right: math.max(
mq.viewPadding.right, mq.systemGestureInsets.right),
)
: EdgeInsets.zero;
return _NaviPopScope(
action: () {
if (App.mainNavigatorKey!.currentState!.canPop()) {
@@ -178,7 +195,7 @@ class NaviPaneState extends State<NaviPane>
animation: controller,
builder: (context, child) {
final value = controller.value;
return Stack(
Widget content = Stack(
children: [
Positioned(
left: _kFoldedSideBarWidth * ((value - 2.0).clamp(-1.0, 0.0)),
@@ -187,13 +204,21 @@ class NaviPaneState extends State<NaviPane>
child: buildLeft(),
),
Positioned.fill(
left: _kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
left:
_kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
(_kSideBarWidth - _kFoldedSideBarWidth) *
((value - 2).clamp(0, 1)),
child: buildMainView(),
),
],
);
if (sideInsets != EdgeInsets.zero) {
content = Padding(
padding: sideInsets,
child: content,
);
}
return content;
},
),
);
@@ -202,14 +227,19 @@ class NaviPaneState extends State<NaviPane>
Widget buildMainView() {
return HeroControllerScope(
controller: MaterialApp.createMaterialHeroController(),
child: Navigator(
observers: [widget.observer],
key: widget.navigatorKey,
onGenerateRoute: (settings) => AppPageRoute(
preventRebuild: false,
builder: (context) {
return _NaviMainView(state: this);
},
child: NavigatorPopHandler(
onPopWithResult: (result) {
widget.navigatorKey.currentState?.maybePop(result);
},
child: Navigator(
observers: [widget.observer],
key: widget.navigatorKey,
onGenerateRoute: (settings) => AppPageRoute(
preventRebuild: false,
builder: (context) {
return _NaviMainView(state: this);
},
),
),
),
);
@@ -239,7 +269,7 @@ class NaviPaneState extends State<NaviPane>
icon: Icon(action.icon),
onPressed: action.onTap,
),
)
),
],
),
),
@@ -261,21 +291,18 @@ class NaviPaneState extends State<NaviPane>
),
),
child: Row(
children: List<Widget>.generate(
widget.paneItems.length,
(index) {
return Expanded(
child: _SingleBottomNaviWidget(
enabled: currentPage == index,
entry: widget.paneItems[index],
onTap: () {
updatePage(index);
},
key: ValueKey(index),
),
);
},
),
children: List<Widget>.generate(widget.paneItems.length, (index) {
return Expanded(
child: _SingleBottomNaviWidget(
enabled: currentPage == index,
entry: widget.paneItems[index],
onTap: () {
updatePage(index);
},
key: ValueKey(index),
),
);
}),
),
),
);
@@ -286,7 +313,8 @@ class NaviPaneState extends State<NaviPane>
const paddingHorizontal = 12.0;
return Material(
child: Container(
width: _kFoldedSideBarWidth +
width:
_kFoldedSideBarWidth +
(_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)),
height: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal),
@@ -323,9 +351,7 @@ class NaviPaneState extends State<NaviPane>
key: ValueKey(index + widget.paneItems.length),
),
),
const SizedBox(
height: 16,
)
const SizedBox(height: 16),
],
),
),
@@ -334,12 +360,13 @@ class NaviPaneState extends State<NaviPane>
}
class _SideNaviWidget extends StatelessWidget {
const _SideNaviWidget(
{required this.enabled,
required this.entry,
required this.onTap,
required this.showTitle,
super.key});
const _SideNaviWidget({
required this.enabled,
required this.entry,
required this.onTap,
required this.showTitle,
super.key,
});
final bool enabled;
@@ -368,18 +395,18 @@ class _SideNaviWidget extends StatelessWidget {
? Row(
children: [icon, const SizedBox(width: 12), Text(entry.label)],
)
: Align(
alignment: Alignment.centerLeft,
child: icon,
),
: Align(alignment: Alignment.centerLeft, child: icon),
),
).paddingVertical(4);
}
}
class _PaneActionWidget extends StatelessWidget {
const _PaneActionWidget(
{required this.entry, required this.showTitle, super.key});
const _PaneActionWidget({
required this.entry,
required this.showTitle,
super.key,
});
final PaneActionEntry entry;
@@ -399,21 +426,19 @@ class _PaneActionWidget extends StatelessWidget {
? Row(
children: [icon, const SizedBox(width: 12), Text(entry.label)],
)
: Align(
alignment: Alignment.centerLeft,
child: icon,
),
: Align(alignment: Alignment.centerLeft, child: icon),
),
).paddingVertical(4);
}
}
class _SingleBottomNaviWidget extends StatefulWidget {
const _SingleBottomNaviWidget(
{required this.enabled,
required this.entry,
required this.onTap,
super.key});
const _SingleBottomNaviWidget({
required this.enabled,
required this.entry,
required this.onTap,
super.key,
});
final bool enabled;
@@ -482,8 +507,9 @@ class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
Widget buildContent() {
final value = controller.value;
final colorScheme = Theme.of(context).colorScheme;
final icon =
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
final icon = Icon(
widget.enabled ? widget.entry.activeIcon : widget.entry.icon,
);
return Center(
child: Container(
width: 64,
@@ -570,8 +596,11 @@ class NaviObserver extends NavigatorObserver implements Listenable {
}
class _NaviPopScope extends StatelessWidget {
const _NaviPopScope(
{required this.child, this.popGesture = false, required this.action});
const _NaviPopScope({
required this.child,
this.popGesture = false,
required this.action,
});
final Widget child;
final bool popGesture;
@@ -581,32 +610,25 @@ class _NaviPopScope extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget res = App.isIOS
? child
: PopScope(
canPop: App.isAndroid ? false : true,
onPopInvokedWithResult: (value, result) {
action();
},
child: child,
);
Widget res = child;
if (popGesture) {
res = GestureDetector(
onPanStart: (details) {
if (details.globalPosition.dx < 64) {
panStartAtEdge = true;
onPanStart: (details) {
if (details.globalPosition.dx < 64) {
panStartAtEdge = true;
}
},
onPanEnd: (details) {
if (details.velocity.pixelsPerSecond.dx < 0 ||
details.velocity.pixelsPerSecond.dx > 0) {
if (panStartAtEdge) {
action();
}
},
onPanEnd: (details) {
if (details.velocity.pixelsPerSecond.dx < 0 ||
details.velocity.pixelsPerSecond.dx > 0) {
if (panStartAtEdge) {
action();
}
}
panStartAtEdge = false;
},
child: res);
}
panStartAtEdge = false;
},
child: res,
);
}
return res;
}

View File

@@ -117,16 +117,25 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
_futurePosition ??= currentLocation;
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
_futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k;
var beforeOffset = (_futurePosition! - currentLocation).abs();
_futurePosition = _futurePosition!.clamp(
_controller.position.minScrollExtent,
_controller.position.maxScrollExtent,
);
var afterOffset = (_futurePosition! - currentLocation).abs();
if (_futurePosition == old) return;
var target = _futurePosition!;
var duration = _fastAnimationDuration;
if (afterOffset < beforeOffset) {
duration = duration * (afterOffset / beforeOffset);
if (duration < Duration(milliseconds: 10)) {
duration = Duration(milliseconds: 10);
}
}
_controller
.animateTo(
_futurePosition!,
duration: _fastAnimationDuration,
duration: duration,
curve: Curves.linear,
)
.then((_) {
@@ -228,10 +237,14 @@ class _AppScrollBarState extends State<AppScrollBar> {
double viewHeight = 0;
final _scrollIndicatorSize = App.isDesktop ? 42.0 : 64.0;
final _scrollIndicatorSize = App.isDesktop ? 36.0 : 54.0;
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
bool _isVisible = false;
Timer? _hideTimer;
static const _hideDuration = Duration(seconds: 2);
@override
void initState() {
super.initState();
@@ -239,7 +252,41 @@ class _AppScrollBarState extends State<AppScrollBar> {
_scrollController.addListener(onChanged);
Future.microtask(onChanged);
_dragGestureRecognizer = VerticalDragGestureRecognizer()
..onUpdate = onUpdate;
..onUpdate = onUpdate
..onStart = (_) {
_showScrollbar();
}
..onEnd = (_) {
_scheduleHide();
};
}
@override
void dispose() {
_hideTimer?.cancel();
_scrollController.removeListener(onChanged);
_dragGestureRecognizer.dispose();
super.dispose();
}
void _showScrollbar() {
if (!_isVisible && mounted) {
setState(() {
_isVisible = true;
});
}
_hideTimer?.cancel();
}
void _scheduleHide() {
_hideTimer?.cancel();
_hideTimer = Timer(_hideDuration, () {
if (mounted && _isVisible) {
setState(() {
_isVisible = false;
});
}
});
}
void onUpdate(DragUpdateDetails details) {
@@ -260,14 +307,24 @@ class _AppScrollBarState extends State<AppScrollBar> {
void onChanged() {
if (_scrollController.positions.isEmpty) return;
var position = _scrollController.position;
bool hasChanged = false;
if (position.minScrollExtent != minExtent ||
position.maxScrollExtent != maxExtent ||
position.pixels != this.position) {
setState(() {
minExtent = position.minScrollExtent;
maxExtent = position.maxScrollExtent;
this.position = position.pixels;
});
hasChanged = true;
minExtent = position.minScrollExtent;
maxExtent = position.maxScrollExtent;
this.position = position.pixels;
}
if (hasChanged) {
_showScrollbar();
_scheduleHide();
}
if (hasChanged && mounted) {
setState(() {});
}
}
@@ -291,29 +348,35 @@ class _AppScrollBarState extends State<AppScrollBar> {
Positioned(
top: top + widget.topPadding,
right: 0,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
_dragGestureRecognizer.addPointer(event);
},
child: SizedBox(
width: _scrollIndicatorSize/2,
height: _scrollIndicatorSize,
child: CustomPaint(
painter: _ScrollIndicatorPainter(
backgroundColor: context.colorScheme.surface,
shadowColor: context.colorScheme.shadow,
child: AnimatedOpacity(
opacity: _isVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => _showScrollbar(),
onExit: (_) => _scheduleHide(),
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
_dragGestureRecognizer.addPointer(event);
},
child: SizedBox(
width: _scrollIndicatorSize / 2,
height: _scrollIndicatorSize,
child: CustomPaint(
painter: _ScrollIndicatorPainter(
backgroundColor: context.colorScheme.surface,
shadowColor: context.colorScheme.shadow,
),
child: Column(
children: [
const Spacer(),
Icon(Icons.arrow_drop_up, size: 18),
Icon(Icons.arrow_drop_down, size: 18),
const Spacer(),
],
).paddingLeft(4),
),
child: Column(
children: [
const Spacer(),
Icon(Icons.arrow_drop_up, size: 18),
Icon(Icons.arrow_drop_down, size: 18),
const Spacer(),
],
).paddingLeft(4),
),
),
),
@@ -345,7 +408,7 @@ class _ScrollIndicatorPainter extends CustomPainter {
Offset(size.width, 0),
radius: Radius.circular(size.width),
);
canvas.drawShadow(path, shadowColor, 4, true);
canvas.drawShadow(path, shadowColor, 2, true);
var backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.4.0";
final version = "1.6.1";
bool get isAndroid => Platform.isAndroid;
@@ -30,6 +30,10 @@ class _App {
bool get isMobile => Platform.isAndroid || Platform.isIOS;
// Whether the app has been initialized.
// If current Isolate is main Isolate, this value is always true.
bool isInitialized = false;
Locale get locale {
Locale deviceLocale = PlatformDispatcher.instance.locale;
if (deviceLocale.languageCode == "zh" &&
@@ -81,6 +85,7 @@ class _App {
if (isAndroid) {
externalStoragePath = (await getExternalStorageDirectory())!.path;
}
isInitialized = true;
}
Future<void> initComponents() async {

View File

@@ -2,6 +2,8 @@ import 'dart:math';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:venera/foundation/app.dart';
const double _kBackGestureWidth = 20.0;
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
@@ -115,18 +117,26 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return SlidePageTransitionBuilder().buildTransitions(
PageTransitionsBuilder builder;
if (App.isAndroid) {
builder = PredictiveBackPageTransitionsBuilder();
} else {
builder = SlidePageTransitionBuilder();
}
return builder.buildTransitions(
this,
context,
animation,
secondaryAnimation,
enableIOSGesture
? IOSBackGestureDetector(
gestureWidth: _kBackGestureWidth,
enabledCallback: () => _isPopGestureEnabled<T>(this),
onStartPopGesture: () => _startPopGesture(this),
child: child)
: child);
enableIOSGesture && App.isIOS
? IOSBackGestureDetector(
gestureWidth: _kBackGestureWidth,
enabledCallback: () => _isPopGestureEnabled<T>(this),
onStartPopGesture: () => _startPopGesture(this),
child: child,
)
: child);
}
IOSBackGestureController _startPopGesture(PageRoute<T> route) {
@@ -193,19 +203,17 @@ class IOSBackGestureController {
}
class IOSBackGestureDetector extends StatefulWidget {
const IOSBackGestureDetector(
{required this.enabledCallback,
required this.child,
required this.gestureWidth,
required this.onStartPopGesture,
super.key});
const IOSBackGestureDetector({
required this.enabledCallback,
required this.child,
required this.gestureWidth,
required this.onStartPopGesture,
super.key,
});
final double gestureWidth;
final bool Function() enabledCallback;
final IOSBackGestureController Function() onStartPopGesture;
final Widget child;
@override
@@ -214,8 +222,22 @@ class IOSBackGestureDetector extends StatefulWidget {
class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
IOSBackGestureController? _backGestureController;
late _BackSwipeRecognizer _recognizer;
late HorizontalDragGestureRecognizer _recognizer;
@override
void initState() {
super.initState();
_recognizer = _BackSwipeRecognizer(
debugOwner: this,
gestureWidth: widget.gestureWidth,
isPointerInHorizontal: _isPointerInHorizontalScrollable,
onStart: _handleDragStart,
onUpdate: _handleDragUpdate,
onEnd: _handleDragEnd,
onCancel: _handleDragCancel,
);
}
@override
void dispose() {
@@ -223,81 +245,211 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
super.dispose();
}
@override
void initState() {
super.initState();
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel;
}
@override
Widget build(BuildContext context) {
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr
? MediaQuery.of(context).padding.left
: MediaQuery.of(context).padding.right;
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth);
return Stack(
fit: StackFit.passthrough,
children: <Widget>[
widget.child,
Positioned(
width: dragAreaWidth,
top: 0.0,
bottom: 0.0,
left: 0,
child: Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.translucent,
),
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: {
_BackSwipeRecognizer: GestureRecognizerFactoryWithHandlers<_BackSwipeRecognizer>(
() => _recognizer,
(instance) {
instance.gestureWidth = widget.gestureWidth;
},
),
],
},
child: widget.child,
);
}
void _handlePointerDown(PointerDownEvent event) {
if (widget.enabledCallback()) _recognizer.addPointer(event);
bool _isPointerInHorizontalScrollable(Offset globalPosition) {
final HitTestResult result = HitTestResult();
final binding = WidgetsBinding.instance;
binding.hitTestInView(result, globalPosition, binding.platformDispatcher.implicitView!.viewId);
for (final entry in result.path) {
final target = entry.target;
if (target is RenderViewport) {
if (target.axisDirection == AxisDirection.left ||
target.axisDirection == AxisDirection.right) {
return true;
}
}
else if (target is RenderSliver) {
if (target.constraints.axisDirection == AxisDirection.left ||
target.constraints.axisDirection == AxisDirection.right) {
return true;
}
}
else if (target.runtimeType.toString() == '_RenderSingleChildViewport') {
try {
final dynamic renderObject = target;
if (renderObject.axis == Axis.horizontal) {
return true;
}
} catch (e) {
// protected
}
}
else if (target is RenderEditable) {
return true;
}
}
return false;
}
void _handleDragCancel() {
assert(mounted);
_backGestureController?.dragEnd(0.0);
_backGestureController = null;
void _handleDragStart(DragStartDetails details) {
if (!widget.enabledCallback()) return;
if (mounted && _backGestureController == null) {
_backGestureController = widget.onStartPopGesture();
}
}
double _convertToLogical(double value) {
switch (Directionality.of(context)) {
case TextDirection.rtl:
return -value;
case TextDirection.ltr:
return value;
void _handleDragUpdate(DragUpdateDetails details) {
if (mounted && _backGestureController != null) {
_backGestureController!.dragUpdate(
_convertToLogical(details.primaryDelta! / context.size!.width));
}
}
void _handleDragEnd(DragEndDetails details) {
assert(mounted);
assert(_backGestureController != null);
_backGestureController!.dragEnd(_convertToLogical(
details.velocity.pixelsPerSecond.dx / context.size!.width));
_backGestureController = null;
if (mounted && _backGestureController != null) {
_backGestureController!.dragEnd(_convertToLogical(
details.velocity.pixelsPerSecond.dx / context.size!.width));
_backGestureController = null;
}
}
void _handleDragStart(DragStartDetails details) {
assert(mounted);
assert(_backGestureController == null);
_backGestureController = widget.onStartPopGesture();
void _handleDragCancel() {
if (mounted && _backGestureController != null) {
_backGestureController?.dragEnd(0.0);
_backGestureController = null;
}
}
void _handleDragUpdate(DragUpdateDetails details) {
assert(mounted);
assert(_backGestureController != null);
_backGestureController!.dragUpdate(
_convertToLogical(details.primaryDelta! / context.size!.width));
double _convertToLogical(double value) {
switch (Directionality.of(context)) {
case TextDirection.rtl: return -value;
case TextDirection.ltr: return value;
}
}
}
class _BackSwipeRecognizer extends OneSequenceGestureRecognizer {
_BackSwipeRecognizer({
required this.isPointerInHorizontal,
required this.gestureWidth,
required this.onStart,
required this.onUpdate,
required this.onEnd,
required this.onCancel,
super.debugOwner,
});
final bool Function(Offset globalPosition) isPointerInHorizontal;
double gestureWidth;
final ValueSetter<DragStartDetails> onStart;
final ValueSetter<DragUpdateDetails> onUpdate;
final ValueSetter<DragEndDetails> onEnd;
final VoidCallback onCancel;
Offset? _startGlobal;
bool _accepted = false;
bool _startedInHorizontal = false;
bool _startedNearLeftEdge = false;
VelocityTracker? _velocityTracker;
static const double _minDistance = 5.0;
@override
void addPointer(PointerDownEvent event) {
startTrackingPointer(event.pointer);
_startGlobal = event.position;
_accepted = false;
_startedInHorizontal = isPointerInHorizontal(event.position);
_startedNearLeftEdge = event.position.dx <= gestureWidth;
_velocityTracker = VelocityTracker.withKind(event.kind);
_velocityTracker?.addPosition(event.timeStamp, event.position);
}
@override
void handleEvent(PointerEvent event) {
if (event is PointerMoveEvent || event is PointerUpEvent) {
_velocityTracker?.addPosition(event.timeStamp, event.position);
}
if (event is PointerMoveEvent) {
if (_startGlobal == null) return;
final delta = event.position - _startGlobal!;
final dx = delta.dx;
final dy = delta.dy.abs();
if (!_accepted) {
if (delta.distance < _minDistance) return;
final isRight = dx > 0;
final isHorizontal = dx.abs() > dy * 1.5;
final bool eligible = _startedNearLeftEdge || (!_startedInHorizontal);
if (isRight && isHorizontal && eligible) {
_accepted = true;
resolve(GestureDisposition.accepted);
onStart(DragStartDetails(
globalPosition: _startGlobal!,
localPosition: event.localPosition
));
} else {
resolve(GestureDisposition.rejected);
stopTrackingPointer(event.pointer);
_startGlobal = null;
_velocityTracker = null;
}
}
if (_accepted) {
onUpdate(DragUpdateDetails(
globalPosition: event.position,
localPosition: event.localPosition,
primaryDelta: event.delta.dx,
delta: event.delta,
));
}
} else if (event is PointerUpEvent) {
if (_accepted) {
final Velocity velocity = _velocityTracker?.getVelocity() ?? Velocity.zero;
onEnd(DragEndDetails(
velocity: velocity,
primaryVelocity: velocity.pixelsPerSecond.dx
));
}
_reset();
} else if (event is PointerCancelEvent) {
if (_accepted) {
onCancel();
}
_reset();
}
}
void _reset() {
stopTrackingPointer(0);
_accepted = false;
_startGlobal = null;
_startedInHorizontal = false;
_startedNearLeftEdge = false;
_velocityTracker = null;
}
@override
String get debugDescription => 'IOSBackSwipe';
@override
void didStopTrackingLastPointer(int pointer) {}
}
class SlidePageTransitionBuilder extends PageTransitionsBuilder {
@override
Widget buildTransitions<T>(
@@ -306,30 +458,31 @@ class SlidePageTransitionBuilder extends PageTransitionsBuilder {
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child) {
final Animation<double> primaryAnimation = App.isIOS
? animation
: CurvedAnimation(parent: animation, curve: Curves.ease);
final Animation<double> secondaryCurve = App.isIOS
? secondaryAnimation
: CurvedAnimation(parent: secondaryAnimation, curve: Curves.ease);
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(primaryAnimation),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.ease,
)),
child: SlideTransition(
position: Tween<Offset>(
begin: Offset.zero,
end: const Offset(-0.4, 0),
).animate(CurvedAnimation(
parent: secondaryAnimation,
curve: Curves.ease,
)),
child: PhysicalModel(
color: Colors.transparent,
borderRadius: BorderRadius.zero,
clipBehavior: Clip.hardEdge,
elevation: 6,
child: Material(child: child,),
),
)
begin: Offset.zero,
end: const Offset(-0.4, 0),
).animate(secondaryCurve),
child: PhysicalModel(
color: Colors.transparent,
borderRadius: BorderRadius.zero,
clipBehavior: Clip.hardEdge,
elevation: 6,
child: Material(child: child),
),
),
);
}
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart';
@@ -22,11 +23,27 @@ class Appdata with Init {
}
_isSavingData = true;
try {
var data = jsonEncode(toJson());
var futures = <Future>[];
var json = toJson();
var data = jsonEncode(json);
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
await file.writeAsString(data);
}
finally {
futures.add(file.writeAsString(data));
var disableSyncFields = json["settings"]["disableSyncFields"] as String;
if (disableSyncFields.isNotEmpty){
var json4sync = jsonDecode(data);
List<String> customDisableSync = splitField(disableSyncFields);
for (var field in customDisableSync) {
json4sync["settings"].remove(field);
}
var data4sync = jsonEncode(json4sync);
var file4sync = File(FilePath.join(App.dataPath, 'syncdata.json'));
futures.add(file4sync.writeAsString(data4sync));
}
await Future.wait(futures);
} finally {
_isSavingData = false;
}
if (sync) {
@@ -56,10 +73,15 @@ class Appdata with Init {
}
Map<String, dynamic> toJson() {
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.
@@ -68,14 +90,19 @@ class Appdata with Init {
"authorizationRequired",
"customImageProcessing",
"webdav",
"disableSyncFields",
];
/// Sync data from another device
void syncData(Map<String, dynamic> data) {
if (data['settings'] is Map) {
var settings = data['settings'] as Map<String, dynamic>;
List<String> customDisableSync = splitField(this.settings["disableSyncFields"] as String);
for (var key in settings.keys) {
if (!_disableSync.contains(key)) {
if (!_disableSync.contains(key) &&
!customDisableSync.contains(key)) {
this.settings[key] = settings[key];
}
}
@@ -94,8 +121,7 @@ class Appdata with Init {
try {
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
await file.writeAsString(jsonEncode(implicitData));
}
finally {
} finally {
_isSavingData = false;
}
}
@@ -103,28 +129,33 @@ class Appdata with Init {
@override
Future<void> doInit() async {
var dataPath = (await getApplicationSupportDirectory()).path;
var file = File(FilePath.join(
dataPath,
'appdata.json',
));
var file = File(FilePath.join(dataPath, 'appdata.json'));
if (!await file.exists()) {
return;
}
var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
try {
var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
}
}
searchHistory = List.from(json['searchHistory']);
} catch (e) {
Log.error("Appdata", "Failed to load appdata", e);
Log.info("Appdata", "Resetting appdata");
file.deleteIgnoreError();
}
searchHistory = List.from(json['searchHistory']);
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
try {
try {
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
}
catch(_) {
// ignore
}
} catch (e) {
Log.error("Appdata", "Failed to load implicit data", e);
Log.info("Appdata", "Resetting implicit data");
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
implicitDataFile.deleteIgnoreError();
}
}
}
@@ -149,6 +180,7 @@ class Settings with ChangeNotifier {
'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false,
'blockedWords': [],
'blockedCommentWords': [],
'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
@@ -165,6 +197,7 @@ class Settings with ChangeNotifier {
'checkUpdateOnStart': false,
'limitImageWidth': true,
'webdav': [], // empty means not configured
"disableSyncFields": "", // "field1, field2, ..."
'dataVersion': 0,
'quickFavorite': null,
'enableTurnPageByVolumeKey': true,
@@ -178,13 +211,22 @@ class Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl':
"https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'comicSourceListUrl': _defaultSourceListUrl,
'preloadImageCount': 4,
'followUpdatesFolder': null,
'initialPage': '0',
'comicListDisplayMode': 'paging', // paging, continuous
'showPageNumberInReader': true,
'showSingleImageOnFirstPage': false,
'enableDoubleTapToZoom': true,
'reverseChapterOrder': false,
'showSystemStatusBar': false,
'comicSpecificSettings': <String, Map<String, dynamic>>{},
'ignoreBadCertificate': false,
'readerScrollSpeed': 1.0, // 0.5 - 3.0
'localFavoritesFirst': true,
'autoCloseFavoritePanel': false,
'showChapterComments': true, // show chapter comments in reader
};
operator [](String key) {
@@ -193,6 +235,50 @@ class Settings with ChangeNotifier {
operator []=(String key, dynamic value) {
_data[key] = value;
if (key != "dataVersion") {
notifyListeners();
}
}
void setEnabledComicSpecificSettings(
String comicId,
String sourceKey,
bool enabled,
) {
setReaderSetting(comicId, sourceKey, "enabled", enabled);
}
bool isComicSpecificSettingsEnabled(String? comicId, String? sourceKey) {
if (comicId == null || sourceKey == null) {
return false;
}
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] ==
true;
}
dynamic getReaderSetting(String comicId, String sourceKey, String key) {
if (!isComicSpecificSettingsEnabled(comicId, sourceKey)) {
return _data[key];
}
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?[key] ??
_data[key];
}
void setReaderSetting(
String comicId,
String sourceKey,
String key,
dynamic value,
) {
(_data['comicSpecificSettings'] as Map<String, dynamic>).putIfAbsent(
"$comicId@$sourceKey",
() => <String, dynamic>{},
)[key] = value;
notifyListeners();
}
void resetComicReaderSettings(String key) {
(_data['comicSpecificSettings'] as Map).remove(key);
notifyListeners();
}
@@ -219,3 +305,6 @@ function processImage(image, cid, eid, page, sourceKey) {
return futureImage;
}
''';
const _defaultSourceListUrl =
"https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json";

View File

@@ -1,5 +1,7 @@
import 'dart:ffi';
import 'dart:isolate';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/utils/io.dart';
@@ -21,7 +23,52 @@ class CacheManager {
int _limitSize = 2 * 1024 * 1024 * 1024;
CacheManager._create(){
static Future<int> _scanDir(Pointer<void> dbP, String dir) async {
var res = await Isolate.run(() async {
int totalSize = 0;
List<String> unmanagedFiles = [];
var db = sqlite3.fromPointer(dbP);
await for (var file in Directory(dir).list(recursive: true)) {
if (file is File) {
var size = await file.length();
var segments = file.uri.pathSegments;
var name = segments.last;
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
var res = db.select('''
SELECT * FROM cache
WHERE dir = ? AND name = ?
''', [dir, name]);
if (res.isEmpty) {
unmanagedFiles.add(file.path);
} else {
totalSize += size;
}
}
}
return {
'totalSize': totalSize,
'unmanagedFiles': unmanagedFiles,
};
});
// delete unmanaged files
// Only modify the database in the main isolate to avoid deadlock
for (var filePath in res['unmanagedFiles'] as List<String>) {
var file = File(filePath);
if (await file.exists()) {
await file.delete();
}
var segments = file.uri.pathSegments;
var name = segments.last;
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
CacheManager()._db.execute('''
DELETE FROM cache
WHERE dir = ? AND name = ?
''', [dir, name]);
}
return res['totalSize'] as int;
}
CacheManager._create() {
Directory(cachePath).createSync(recursive: true);
_db = sqlite3.open('${App.dataPath}/cache.db');
_db.execute('''
@@ -33,100 +80,103 @@ class CacheManager {
type TEXT
)
''');
compute((path) => Directory(path).size, cachePath)
.then((value) => _currentSize = value);
_scanDir(_db.handle, cachePath).then((value) {
_currentSize = value;
checkCache();
});
}
/// Get the singleton instance of CacheManager.
factory CacheManager() => instance ??= CacheManager._create();
/// set cache size limit in MB
void setLimitSize(int size){
void setLimitSize(int size) {
_limitSize = size * 1024 * 1024;
}
void setType(String key, String? type){
_db.execute('''
UPDATE cache
SET type = ?
WHERE key = ?
''', [type, key]);
}
String? getType(String key){
var res = _db.select('''
SELECT type FROM cache
WHERE key = ?
''', [key]);
if(res.isEmpty){
return null;
}
return res.first[0];
}
Future<void> writeCache(String key, List<int> data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
/// Write cache to disk.
Future<void> writeCache(String key, List<int> data,
[int duration = 7 * 24 * 60 * 60 * 1000]) async {
await delete(key);
this.dir++;
this.dir %= 100;
var dir = this.dir;
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
var name = md5.convert(key.codeUnits).toString();
var file = File('$cachePath/$dir/$name');
while(await file.exists()){
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
file = File('$cachePath/$dir/$name');
}
await file.create(recursive: true);
await file.writeAsBytes(data);
var expires = DateTime.now().millisecondsSinceEpoch + duration;
_db.execute('''
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
''', [key, dir.toString(), name, expires]);
if(_currentSize != null) {
if (_currentSize != null) {
_currentSize = _currentSize! + data.length;
}
checkCacheIfRequired();
}
Future<CachingFile> openWrite(String key) async{
this.dir++;
this.dir %= 100;
var dir = this.dir;
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
var file = File('$cachePath/$dir/$name');
while(await file.exists()){
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
file = File('$cachePath/$dir/$name');
}
await file.create(recursive: true);
return CachingFile._(key, dir.toString(), name, file);
}
Future<File?> findCache(String key) async{
/// Find cache by key.
/// If cache is expired, it will be deleted and return null.
/// If cache is not found, it will return null.
/// If cache is found, it will return the file, and update the expires time.
Future<File?> findCache(String key) async {
var res = _db.select('''
SELECT * FROM cache
WHERE key = ?
''', [key]);
if(res.isEmpty){
if (res.isEmpty) {
return null;
}
var row = res.first;
var dir = row[1] as String;
var name = row[2] as String;
var expires = row[3] as int;
var file = File('$cachePath/$dir/$name');
if(await file.exists()){
var now = DateTime.now().millisecondsSinceEpoch;
if (expires < now) {
// expired
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
if (await file.exists()) {
await file.delete();
}
return null;
}
if (await file.exists()) {
// update time
var expires = now + 7 * 24 * 60 * 60 * 1000;
_db.execute('''
UPDATE cache
SET expires = ?
WHERE key = ?
''', [expires, key]);
return file;
} else {
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
}
return null;
}
bool _isChecking = false;
/// Check cache size and delete expired cache.
/// Only check cache if current size is greater than limit size.
void checkCacheIfRequired() {
if(_currentSize != null && _currentSize! > _limitSize){
if (_currentSize != null && _currentSize! > _limitSize) {
checkCache();
}
}
Future<void> checkCache() async{
if(_isChecking){
/// Check cache size and delete expired cache.
/// If current size is greater than limit size,
/// delete cache until current size is less than limit size.
Future<void> checkCache() async {
if (_isChecking) {
return;
}
_isChecking = true;
@@ -134,39 +184,42 @@ class CacheManager {
SELECT * FROM cache
WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]);
for(var row in res){
for (var row in res) {
var dir = row[1] as String;
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
if(await file.exists()){
if (await file.exists()) {
var size = await file.length();
_currentSize = _currentSize! - size;
await file.delete();
}
}
_db.execute('''
if (res.isNotEmpty) {
_db.execute('''
DELETE FROM cache
WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]);
int count = 0;
var res2 = _db.select('''
SELECT COUNT(*) FROM cache
''');
if(res2.isNotEmpty){
count = res2.first[0] as int;
}
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){
while (_currentSize != null && _currentSize! > _limitSize) {
var res = _db.select('''
SELECT * FROM cache
ORDER BY expires ASC
limit 10
''');
for(var row in res){
if (res.isEmpty) {
// There are many files unmanaged by the cache manager.
// Clear all cache.
await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true);
break;
}
for (var row in res) {
var key = row[0] as String;
var dir = row[1] as String;
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
if(await file.exists()){
if (await file.exists()) {
var size = await file.length();
await file.delete();
_db.execute('''
@@ -174,7 +227,7 @@ class CacheManager {
WHERE key = ?
''', [key]);
_currentSize = _currentSize! - size;
if(_currentSize! <= _limitSize){
if (_currentSize! <= _limitSize) {
break;
}
} else {
@@ -183,18 +236,18 @@ class CacheManager {
WHERE key = ?
''', [key]);
}
count--;
}
}
_isChecking = false;
}
Future<void> delete(String key) async{
/// Delete cache by key.
Future<void> delete(String key) async {
var res = _db.select('''
SELECT * FROM cache
WHERE key = ?
''', [key]);
if(res.isEmpty){
if (res.isEmpty) {
return;
}
var row = res.first;
@@ -202,7 +255,7 @@ class CacheManager {
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
var fileSize = 0;
if(await file.exists()){
if (await file.exists()) {
fileSize = await file.length();
await file.delete();
}
@@ -210,11 +263,12 @@ class CacheManager {
DELETE FROM cache
WHERE key = ?
''', [key]);
if(_currentSize != null) {
if (_currentSize != null) {
_currentSize = _currentSize! - fileSize;
}
}
/// Delete all cache.
Future<void> clear() async {
await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true);
@@ -223,75 +277,4 @@ class CacheManager {
''');
_currentSize = 0;
}
Future<void> deleteKeyword(String keyword) async{
var res = _db.select('''
SELECT * FROM cache
WHERE key LIKE ?
''', ['%$keyword%']);
for(var row in res){
var key = row[0] as String;
var dir = row[1] as String;
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
var fileSize = 0;
if(await file.exists()){
fileSize = await file.length();
try {
await file.delete();
}
finally {}
}
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
if(_currentSize != null) {
_currentSize = _currentSize! - fileSize;
}
}
}
}
class CachingFile{
CachingFile._(this.key, this.dir, this.name, this.file);
final String key;
final String dir;
final String name;
final File file;
final List<int> _buffer = [];
Future<void> writeBytes(List<int> data) async{
_buffer.addAll(data);
if(_buffer.length > 1024 * 1024){
await file.writeAsBytes(_buffer, mode: FileMode.append);
_buffer.clear();
}
}
Future<void> close() async{
if(_buffer.isNotEmpty){
await file.writeAsBytes(_buffer, mode: FileMode.append);
}
CacheManager()._db.execute('''
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
CacheManager().checkCacheIfRequired();
}
Future<void> cancel() async{
await file.deleteIgnoreError();
}
void reset() {
_buffer.clear();
if(file.existsSync()) {
file.deleteSync();
}
}
}

View File

@@ -61,8 +61,10 @@ class ComicSourceManager with ChangeNotifier, Init {
await for (var entity in Directory(path).list()) {
if (entity is File && entity.path.endsWith(".js")) {
try {
var source = await ComicSourceParser()
.parse(await entity.readAsString(), entity.absolute.path);
var source = await ComicSourceParser().parse(
await entity.readAsString(),
entity.absolute.path,
);
_sources.add(source);
} catch (e, s) {
Log.error("ComicSource", "$e\n$s");
@@ -154,7 +156,7 @@ class ComicSource {
final GetImageLoadingConfigFunc? getImageLoadingConfig;
final Map<String, dynamic> Function(String imageKey)?
getThumbnailLoadingConfig;
getThumbnailLoadingConfig;
var data = <String, dynamic>{};
@@ -170,6 +172,10 @@ class ComicSource {
final SendCommentFunc? sendCommentFunc;
final ChapterCommentsLoader? chapterCommentsLoader;
final SendChapterCommentFunc? sendChapterCommentFunc;
final RegExp? idMatcher;
final LikeOrUnlikeComicFunc? likeOrUnlikeComic;
@@ -184,6 +190,9 @@ class ComicSource {
final HandleClickTagEvent? handleClickTagEvent;
/// Callback when a tag suggestion is selected in search.
final TagSuggestionSelectFunc? onTagSuggestionSelected;
final LinkHandler? linkHandler;
final bool enableTagsSuggestions;
@@ -253,12 +262,15 @@ class ComicSource {
this.version,
this.commentsLoader,
this.sendCommentFunc,
this.chapterCommentsLoader,
this.sendChapterCommentFunc,
this.likeOrUnlikeComic,
this.voteCommentFunc,
this.likeCommentFunc,
this.idMatcher,
this.translations,
this.handleClickTagEvent,
this.onTagSuggestionSelected,
this.linkHandler,
this.enableTagsSuggestions,
this.enableTagsTranslate,
@@ -363,11 +375,19 @@ enum ExplorePageType {
override,
}
typedef SearchFunction = Future<Res<List<Comic>>> Function(
String keyword, int page, List<String> searchOption);
typedef SearchFunction =
Future<Res<List<Comic>>> Function(
String keyword,
int page,
List<String> searchOption,
);
typedef SearchNextFunction = Future<Res<List<Comic>>> Function(
String keyword, String? next, List<String> searchOption);
typedef SearchNextFunction =
Future<Res<List<Comic>>> Function(
String keyword,
String? next,
List<String> searchOption,
);
class SearchPageData {
/// If this is not null, the default value of search options will be first element.
@@ -394,12 +414,25 @@ class SearchOptions {
String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? "";
}
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
String category, String? param, List<String> options, int page);
typedef CategoryComicsLoader =
Future<Res<List<Comic>>> Function(
String category,
String? param,
List<String> options,
int page,
);
typedef CategoryOptionsLoader =
Future<Res<List<CategoryComicsOptions>>> Function(
String category,
String? param,
);
class CategoryComicsData {
/// options
final List<CategoryComicsOptions> options;
final List<CategoryComicsOptions>? options;
final CategoryOptionsLoader? optionsLoader;
/// [category] is the one clicked by the user on the category page.
///
@@ -410,7 +443,12 @@ class CategoryComicsData {
final RankingData? rankingData;
const CategoryComicsData(this.options, this.load, {this.rankingData});
const CategoryComicsData({
this.options,
this.optionsLoader,
required this.load,
this.rankingData,
});
}
class RankingData {
@@ -419,12 +457,15 @@ class RankingData {
final Future<Res<List<Comic>>> Function(String option, int page)? load;
final Future<Res<List<Comic>>> Function(String option, String? next)?
loadWithNext;
loadWithNext;
const RankingData(this.options, this.load, this.loadWithNext);
}
class CategoryComicsOptions {
// The label will not be displayed if it is empty.
final String label;
/// Use a [LinkedHashMap] to describe an option list.
/// key is for loading comics, value is the name displayed on screen.
/// Default value will be the first of the Map.
@@ -435,7 +476,12 @@ class CategoryComicsOptions {
final List<String>? showWhen;
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
const CategoryComicsOptions(
this.label,
this.options,
this.notShowWhen,
this.showWhen,
);
}
class LinkHandler {

View File

@@ -116,6 +116,26 @@ class Comic {
toString() => "$sourceKey@$id";
}
class ComicID {
final ComicType type;
final String id;
const ComicID(this.type, this.id);
@override
bool operator ==(Object other) {
if (other is! ComicID) return false;
return other.type == type && other.id == id;
}
@override
int get hashCode => type.hashCode ^ id.hashCode;
@override
String toString() => "$type@$id";
}
class ComicDetails with HistoryMixin {
@override
final String title;
@@ -521,7 +541,7 @@ class PageJumpTarget {
text: attributes?["text"] ?? attributes?["keyword"] ?? "",
sourceKey: sourceKey,
options: List.from(attributes?["options"] ?? []),
),
)
);
} else if (page == "category") {
var key = ComicSource.find(sourceKey)!.categoryData!.key;

View File

@@ -64,8 +64,13 @@ class ComicSourceParser {
if (file.existsSync()) {
int i = 0;
while (file.existsSync()) {
file = File(FilePath.join(App.dataPath, "comic_source",
"${fileName.split('.').first}($i).js"));
file = File(
FilePath.join(
App.dataPath,
"comic_source",
"${fileName.split('.').first}($i).js",
),
);
i++;
}
}
@@ -80,8 +85,9 @@ class ComicSourceParser {
Future<ComicSource> parse(String js, String filePath) async {
js = js.replaceAll("\r\n", "\n");
var line1 =
js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class "));
var line1 = js
.split('\n')
.firstWhereOrNull((e) => e.trim().startsWith("class "));
if (line1 == null ||
!line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) {
@@ -89,24 +95,27 @@ class ComicSourceParser {
}
var className = line1.split("class")[1].split("extends ComicSource").first;
className = className.trim();
JsEngine().runCode("""
(() => { $js
JsEngine().runCode("""(() => { $js
this['temp'] = new $className()
}).call()
""", className);
_name = JsEngine().runCode("this['temp'].name") ??
_name =
JsEngine().runCode("this['temp'].name") ??
(throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") ??
var key =
JsEngine().runCode("this['temp'].key") ??
(throw ComicSourceParseException('key is required'));
var version = JsEngine().runCode("this['temp'].version") ??
var version =
JsEngine().runCode("this['temp'].version") ??
(throw ComicSourceParseException('version is required'));
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
var url = JsEngine().runCode("this['temp'].url");
if (minAppVersion != null) {
if (compareSemVer(minAppVersion, App.version.split('-').first)) {
throw ComicSourceParseException(
"minAppVersion @version is required"
.tlParams({"version": minAppVersion}),
"minAppVersion @version is required".tlParams({
"version": minAppVersion,
}),
);
}
}
@@ -142,12 +151,15 @@ class ComicSourceParser {
version ?? "1.0.0",
_parseCommentsLoader(),
_parseSendCommentFunc(),
_parseChapterCommentsLoader(),
_parseSendChapterCommentFunc(),
_parseLikeFunc(),
_parseVoteCommentFunc(),
_parseLikeCommentFunc(),
_parseIdMatch(),
_parseTranslation(),
_parseClickTagEvent(),
_parseTagSuggestionSelectFunc(),
_parseLinkHandler(),
_getValue("search.enableTagsSuggestions") ?? false,
_getValue("comic.enableTagsTranslate") ?? false,
@@ -174,8 +186,10 @@ class ComicSourceParser {
}
bool _checkExists(String index) {
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined");
return JsEngine().runCode(
"ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined",
);
}
dynamic _getValue(String index) {
@@ -276,16 +290,24 @@ class ComicSourceParser {
if (type == "singlePageWithMultiPart") {
loadMultiPart = () async {
try {
var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load()");
return Res(List.from(res.keys
.map((e) => ExplorePagePart(
e,
(res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!))
.toList(),
null))
.toList()));
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load()",
);
return Res(
List.from(
res.keys
.map(
(e) => ExplorePagePart(
e,
(res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!))
.toList(),
null,
),
)
.toList(),
),
);
} catch (e, s) {
Log.error("Data Analysis", "$e\n$s");
return Res.error(e.toString());
@@ -296,11 +318,15 @@ class ComicSourceParser {
loadPage = (int page) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})",
);
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -310,10 +336,13 @@ class ComicSourceParser {
loadNext = (next) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})");
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})",
);
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -325,8 +354,9 @@ class ComicSourceParser {
} else if (type == "multiPartPage") {
loadMultiPart = () async {
try {
var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load()");
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load()",
);
return Res(
List.from(
(res as List).map((e) {
@@ -349,19 +379,22 @@ class ComicSourceParser {
loadMixed = (index) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})");
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})",
);
var list = <Object>[];
for (var data in (res['data'] as List)) {
if (data is List) {
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
} else if (data is Map) {
list.add(ExplorePagePart(
data['title'],
(data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
data['viewMore'],
));
list.add(
ExplorePagePart(
data['title'],
(data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
data['viewMore'],
),
);
}
}
return Res(list, subData: res['maxPage']);
@@ -371,21 +404,25 @@ class ComicSourceParser {
}
};
}
pages.add(ExplorePageData(
title,
switch (type) {
"singlePageWithMultiPart" => ExplorePageType.singlePageWithMultiPart,
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
"multiPageComicList" => ExplorePageType.multiPageComicList,
"mixed" => ExplorePageType.mixed,
_ =>
throw ComicSourceParseException("Unknown explore page type $type")
},
loadPage,
loadNext,
loadMultiPart,
loadMixed,
));
pages.add(
ExplorePageData(
title,
switch (type) {
"singlePageWithMultiPart" =>
ExplorePageType.singlePageWithMultiPart,
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
"multiPageComicList" => ExplorePageType.multiPageComicList,
"mixed" => ExplorePageType.mixed,
_ => throw ComicSourceParseException(
"Unknown explore page type $type",
),
},
loadPage,
loadNext,
loadMultiPart,
loadMixed,
),
);
}
return pages;
}
@@ -425,18 +462,17 @@ class ComicSourceParser {
if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs!));
} else if (type == "random") {
categoryParts
.add(RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1));
categoryParts.add(
RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1),
);
} else if (type == "dynamic" && categories == null) {
var loader = c["loader"];
if (loader is! JSInvokable) {
throw "DynamicCategoryPart loader must be a function";
}
categoryParts.add(DynamicCategoryPart(
name,
JSAutoFreeFunction(loader),
_key!,
));
categoryParts.add(
DynamicCategoryPart(name, JSAutoFreeFunction(loader), _key!),
);
}
} else {
// old format
@@ -453,30 +489,16 @@ class ComicSourceParser {
for (int i = 0; i < tags.length; i++) {
PageJumpTarget target;
if (itemType == 'category') {
target = PageJumpTarget(
_key!,
'category',
{
"category": tags[i],
"param": categoryParams?.elementAtOrNull(i),
},
);
target = PageJumpTarget(_key!, 'category', {
"category": tags[i],
"param": categoryParams?.elementAtOrNull(i),
});
} else if (itemType == 'search') {
target = PageJumpTarget(
_key!,
'search',
{
"keyword": tags[i],
},
);
target = PageJumpTarget(_key!, 'search', {"keyword": tags[i]});
} else if (itemType == 'search_with_namespace') {
target = PageJumpTarget(
_key!,
'search',
{
"keyword": "$name:$tags[i]",
},
);
target = PageJumpTarget(_key!, 'search', {
"keyword": "$name:$tags[i]",
});
} else {
target = PageJumpTarget(_key!, itemType, null);
}
@@ -485,38 +507,101 @@ class ComicSourceParser {
if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs));
} else if (type == "random") {
categoryParts
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1));
categoryParts.add(
RandomCategoryPart(name, cs, c["randomNumber"] ?? 1),
);
}
}
}
return CategoryData(
title: title,
categories: categoryParts,
enableRankingPage: enableRankingPage ?? false,
key: title);
title: title,
categories: categoryParts,
enableRankingPage: enableRankingPage ?? false,
key: title,
);
}
CategoryComicsData? _loadCategoryComicsData() {
if (!_checkExists("categoryComics")) return null;
var options = <CategoryComicsOptions>[];
for (var element in _getValue("categoryComics.optionList") ?? []) {
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"]) {
if (option.isEmpty || !option.contains("-")) {
continue;
List<CategoryComicsOptions>? options;
if (_checkExists("categoryComics.optionList")) {
options = <CategoryComicsOptions>[];
for (var element in _getValue("categoryComics.optionList") ?? []) {
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"]) {
if (option.isEmpty || !option.contains("-")) {
continue;
}
var split = option.split("-");
var key = split.removeAt(0);
var value = split.join("-");
map[key] = value;
}
var split = option.split("-");
var key = split.removeAt(0);
var value = split.join("-");
map[key] = value;
options.add(
CategoryComicsOptions(
element["label"] ?? "",
map,
List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"]),
),
);
}
options.add(CategoryComicsOptions(
map,
List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"])));
}
CategoryOptionsLoader? optionLoader;
if (_checkExists("categoryComics.optionLoader")) {
optionLoader = (category, param) async {
try {
dynamic res = JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.optionLoader(
${jsonEncode(category)}, ${jsonEncode(param)})
""");
if (res is Future) {
res = await res;
}
if (res is! List) {
return Res.error(
"Invalid data:\nExpected: List\nGot: ${res.runtimeType}",
);
}
var options = <CategoryComicsOptions>[];
for (var element in res) {
if (element is! Map) {
return Res.error(
"Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}",
);
}
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"] ?? []) {
if (option.isEmpty || !option.contains("-")) {
continue;
}
var split = option.split("-");
var key = split.removeAt(0);
var value = split.join("-");
map[key] = value;
}
options.add(
CategoryComicsOptions(
element["label"] ?? "",
map,
List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null
? null
: List.from(element["showWhen"]),
),
);
}
return Res(options);
} catch (e) {
Log.error("Data Analysis", "Failed to load category options.\n$e");
return Res.error(e.toString());
}
};
}
RankingData? rankingData;
if (_checkExists("categoryComics.ranking")) {
var options = <String, String>{};
@@ -531,7 +616,7 @@ class ComicSourceParser {
}
Future<Res<List<Comic>>> Function(String option, int page)? load;
Future<Res<List<Comic>>> Function(String option, String? next)?
loadWithNext;
loadWithNext;
if (_checkExists("categoryComics.ranking.load")) {
load = (option, page) async {
try {
@@ -540,9 +625,12 @@ class ComicSourceParser {
${jsonEncode(option)}, ${jsonEncode(page)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -556,8 +644,10 @@ class ComicSourceParser {
${jsonEncode(option)}, ${jsonEncode(next)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -568,25 +658,38 @@ class ComicSourceParser {
}
rankingData = RankingData(options, load, loadWithNext);
}
return CategoryComicsData(options, (category, param, options, page) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load(
${jsonEncode(category)},
${jsonEncode(param)},
${jsonEncode(options)},
${jsonEncode(page)}
)
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
}, rankingData: rankingData);
if (options == null && optionLoader == null) {
options = [];
}
return CategoryComicsData(
options: options,
optionsLoader: optionLoader,
load: (category, param, options, page) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load(
${jsonEncode(category)},
${jsonEncode(param)},
${jsonEncode(options)},
${jsonEncode(page)}
)
""");
return Res(
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
},
rankingData: rankingData,
);
}
SearchPageData? _loadSearchData() {
@@ -603,12 +706,14 @@ class ComicSourceParser {
var value = split.join("-");
map[key] = value;
}
options.add(SearchOptions(
map,
element["label"],
element['type'] ?? 'select',
element['default'] == null ? null : jsonEncode(element['default']),
));
options.add(
SearchOptions(
map,
element["label"],
element['type'] ?? 'select',
element['default'] == null ? null : jsonEncode(element['default']),
),
);
}
SearchFunction? loadPage;
@@ -623,9 +728,12 @@ class ComicSourceParser {
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -639,8 +747,10 @@ class ComicSourceParser {
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -689,8 +799,9 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic =
_getValue("favorites.singleFolderForSingleComic");
final bool? singleFolderForSingleComic = _getValue(
"favorites.singleFolderForSingleComic",
);
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) {
@@ -743,9 +854,12 @@ class ComicSourceParser {
${jsonEncode(page)}, ${jsonEncode(folder)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -765,8 +879,10 @@ class ComicSourceParser {
${jsonEncode(next)}, ${jsonEncode(folder)})
""");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -857,8 +973,9 @@ class ComicSourceParser {
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
""");
return Res(
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
subData: res["maxPage"]);
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -895,6 +1012,54 @@ class ComicSourceParser {
};
}
ChapterCommentsLoader? _parseChapterCommentsLoader() {
if (!_checkExists("comic.loadChapterComments")) return null;
return (comicId, epId, page, replyTo) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.loadChapterComments(
${jsonEncode(comicId)}, ${jsonEncode(epId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
""");
return Res(
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
};
}
SendChapterCommentFunc? _parseSendChapterCommentFunc() {
if (!_checkExists("comic.sendChapterComment")) return null;
return (comicId, epId, content, replyTo) async {
Future<Res<bool>> func() async {
try {
await JsEngine().runCode("""
ComicSource.sources.$_key.comic.sendChapterComment(
${jsonEncode(comicId)}, ${jsonEncode(epId)}, ${jsonEncode(content)}, ${jsonEncode(replyTo)})
""");
return const Res(true);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
}
var res = await func();
if (res.error && res.errorMessage!.contains("Login expired")) {
var reLoginRes = await ComicSource.find(_key!)!.reLogin();
if (!reLoginRes) {
return const Res.error("Login expired and re-login failed");
} else {
return func();
}
}
return res;
};
}
GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc() {
if (!_checkExists("comic.onImageLoad")) {
return null;
@@ -1057,6 +1222,19 @@ class ComicSourceParser {
};
}
TagSuggestionSelectFunc? _parseTagSuggestionSelectFunc() {
if (!_checkExists("search.onTagSuggestionSelected")) {
return null;
}
return (namespace, tag) {
var res = JsEngine().runCode("""
ComicSource.sources.$_key.search.onTagSuggestionSelected(
${jsonEncode(namespace)}, ${jsonEncode(tag)})
""");
return res is String ? res : "$namespace:$tag";
};
}
LinkHandler? _parseLinkHandler() {
if (!_checkExists("comic.link")) {
return null;
@@ -1100,7 +1278,8 @@ class ComicSourceParser {
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
""");
return Res(
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList());
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList(),
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());

View File

@@ -4,45 +4,90 @@ part of 'comic_source.dart';
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
String? next);
typedef ComicListBuilderWithNext =
Future<Res<List<Comic>>> Function(String? next);
typedef LoginFunction = Future<Res<bool>> Function(String, String);
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
String id, String? ep);
typedef LoadComicPagesFunc =
Future<Res<List<String>>> Function(String id, String? ep);
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
String id, String? subId, int page, String? replyTo);
typedef CommentsLoader =
Future<Res<List<Comment>>> Function(
String id,
String? subId,
int page,
String? replyTo,
);
typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo);
typedef ChapterCommentsLoader =
Future<Res<List<Comment>>> Function(
String comicId,
String epId,
int page,
String? replyTo,
);
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef SendCommentFunc =
Future<Res<bool>> Function(
String id,
String? subId,
String content,
String? replyTo,
);
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
String comicId, String? next);
typedef SendChapterCommentFunc =
Future<Res<bool>> Function(
String comicId,
String epId,
String content,
String? replyTo,
);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
String comicId, bool isLiking);
typedef GetImageLoadingConfigFunc =
Future<Map<String, dynamic>> Function(
String imageKey,
String comicId,
String epId,
)?;
typedef GetThumbnailLoadingConfigFunc =
Map<String, dynamic> Function(String imageKey)?;
typedef ComicThumbnailLoader =
Future<Res<List<String>>> Function(String comicId, String? next);
typedef LikeOrUnlikeComicFunc =
Future<Res<bool>> Function(String comicId, bool isLiking);
/// [isLiking] is true if the user is liking the comment, false if unliking.
/// return the new likes count or null.
typedef LikeCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isLiking);
typedef LikeCommentFunc =
Future<Res<int?>> Function(
String comicId,
String? subId,
String commentId,
bool isLiking,
);
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
/// return the new vote count or null.
typedef VoteCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef VoteCommentFunc =
Future<Res<int?>> Function(
String comicId,
String? subId,
String commentId,
bool isUp,
bool isCancel,
);
typedef HandleClickTagEvent = PageJumpTarget? Function(
String namespace, String tag);
typedef HandleClickTagEvent =
PageJumpTarget? Function(String namespace, String tag);
/// Handle tag suggestion selection event. Should return the text to insert
/// into the search field.
typedef TagSuggestionSelectFunc = String Function(String namespace, String tag);
/// [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();
}
Future<T?> to<T>(Widget Function() builder) {
return Navigator.of(this)
.push<T>(AppPageRoute(builder: (context) => builder()));
Future<T?> to<T>(Widget Function() builder,) {
return Navigator.of(this).push<T>(AppPageRoute(
builder: (context) => builder()));
}
Future<void> toReplacement<T>(Widget Function() builder) {
return Navigator.of(this)
.pushReplacement(AppPageRoute(builder: (context) => builder()));
return Navigator.of(this).pushReplacement(AppPageRoute(
builder: (context) => builder()));
}
double get width => MediaQuery.of(this).size.width;

View File

@@ -1,4 +1,7 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart';
@@ -209,7 +212,20 @@ class LocalFavoritesManager with ChangeNotifier {
late Database _db;
late Map<String, int> counts;
var _hashedIds = <int, int>{};
int get totalComics {
return _hashedIds.length;
}
int folderComics(String folder) {
return counts[folder] ?? 0;
}
Future<void> init() async {
counts = {};
_db = sqlite3.open("${App.dataPath}/local_favorite.db");
_db.execute("""
create table if not exists folder_order (
@@ -234,7 +250,7 @@ class LocalFavoritesManager with ChangeNotifier {
alter table "$folder"
add column translated_tags TEXT;
""");
var comics = getAllComics(folder);
var comics = getFolderComics(folder);
for (var comic in comics) {
var translatedTags = _translateTags(comic.tags);
_db.execute("""
@@ -256,6 +272,55 @@ class LocalFavoritesManager with ChangeNotifier {
} else {
appdata.settings['followUpdatesFolder'] = null;
}
initCounts();
}
void initCounts() {
for (var folder in folderNames) {
counts[folder] = count(folder);
}
_initHashedIds(folderNames, _db.handle).then((value) {
_hashedIds = value;
notifyListeners();
});
}
void refreshHashedIds() {
_initHashedIds(folderNames, _db.handle).then((value) {
_hashedIds = value;
notifyListeners();
});
}
void reduceHashedId(String id, int type) {
var hash = id.hashCode ^ type;
if (_hashedIds.containsKey(hash)) {
if (_hashedIds[hash]! > 1) {
_hashedIds[hash] = _hashedIds[hash]! - 1;
} else {
_hashedIds.remove(hash);
}
}
}
static Future<Map<int, int>> _initHashedIds(
List<String> folders, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var hashedIds = <int, int>{};
for (var folder in folders) {
var rows = db.select("""
select id, type from "$folder";
""");
for (var row in rows) {
var id = row["id"] as String;
var type = row["type"] as int;
var hash = id.hashCode ^ type;
hashedIds[hash] = (hashedIds[hash] ?? 0) + 1;
}
}
return hashedIds;
});
}
List<String> find(String id, ComicType type) {
@@ -349,7 +414,7 @@ class LocalFavoritesManager with ChangeNotifier {
""").firstOrNull?["min_value"] ?? 0;
}
List<FavoriteItem> getAllComics(String folder) {
List<FavoriteItem> getFolderComics(String folder) {
var rows = _db.select("""
select * from "$folder"
ORDER BY display_order;
@@ -357,6 +422,54 @@ class LocalFavoritesManager with ChangeNotifier {
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
}
static Future<List<FavoriteItem>> _getFolderComicsAsync(
String folder, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var rows = db.select("""
select * from "$folder"
ORDER BY display_order;
""");
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
});
}
/// Start a new isolate to get the comics in the folder
Future<List<FavoriteItem>> getFolderComicsAsync(String folder) {
return _getFolderComicsAsync(folder, _db.handle);
}
List<FavoriteItem> getAllComics() {
var res = <FavoriteItem>{};
for (final folder in folderNames) {
var comics = _db.select("""
select * from "$folder";
""");
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
}
return res.toList();
}
static Future<List<FavoriteItem>> _getAllComicsAsync(
List<String> folders, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var res = <FavoriteItem>{};
for (final folder in folders) {
var comics = db.select("""
select * from "$folder";
""");
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
}
return res.toList();
});
}
/// Start a new isolate to get all the comics
Future<List<FavoriteItem>> getAllComicsAsync() {
return _getAllComicsAsync(folderNames, _db.handle);
}
void addTagTo(String folder, String id, String tag) {
_db.execute("""
update "$folder"
@@ -422,6 +535,7 @@ class LocalFavoritesManager with ChangeNotifier {
);
""");
notifyListeners();
counts[name] = 0;
return name;
}
@@ -486,7 +600,6 @@ class LocalFavoritesManager with ChangeNotifier {
/// return true if success, false if already exists
bool addComic(String folder, FavoriteItem comic,
[int? order, String? updateTime]) {
_modifiedAfterLastCache = true;
if (!existsFolder(folder)) {
throw Exception("Folder does not exists");
}
@@ -536,14 +649,19 @@ class LocalFavoritesManager with ChangeNotifier {
""", [updateTime, comic.id, comic.type.value]);
}
}
if (counts[folder] == null) {
counts[folder] = count(folder);
} else {
counts[folder] = counts[folder]! + 1;
}
var hash = comic.id.hashCode ^ comic.type.value;
_hashedIds[hash] = (_hashedIds[hash] ?? 0) + 1;
notifyListeners();
return true;
}
void moveFavorite(
String sourceFolder, String targetFolder, String id, ComicType type) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
@@ -575,9 +693,89 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners();
}
void batchMoveFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
_db.execute("BEGIN TRANSACTION");
var displayOrder = maxValue(targetFolder) + 1;
try {
for (var item in items) {
_db.execute("""
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
select id, name, author, type, tags, cover_path, time, ?
from "$sourceFolder"
where id == ? and type == ?;
""", [displayOrder, item.id, item.type.value]);
_db.execute("""
delete from "$sourceFolder"
where id == ? and type == ?;
""", [item.id, item.type.value]);
displayOrder++;
}
notifyListeners();
} catch (e) {
Log.error("Batch Move Favorites", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
// Update counts
counts[targetFolder] = count(targetFolder);
counts[sourceFolder] = count(sourceFolder);
refreshHashedIds();
notifyListeners();
}
void batchCopyFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
_db.execute("BEGIN TRANSACTION");
var displayOrder = maxValue(targetFolder) + 1;
try {
for (var item in items) {
_db.execute("""
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
select id, name, author, type, tags, cover_path, time, ?
from "$sourceFolder"
where id == ? and type == ?;
""", [displayOrder, item.id, item.type.value]);
displayOrder++;
}
notifyListeners();
} catch (e) {
Log.error("Batch Copy Favorites", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
// Update counts
counts[targetFolder] = count(targetFolder);
refreshHashedIds();
notifyListeners();
}
/// delete a folder
void deleteFolder(String name) {
_modifiedAfterLastCache = true;
_db.execute("""
drop table "$name";
""");
@@ -585,21 +783,77 @@ class LocalFavoritesManager with ChangeNotifier {
delete from folder_order
where folder_name == ?;
""", [name]);
counts.remove(name);
refreshHashedIds();
notifyListeners();
}
void deleteComic(String folder, FavoriteItem comic) {
_modifiedAfterLastCache = true;
deleteComicWithId(folder, comic.id, comic.type);
}
void deleteComicWithId(String folder, String id, ComicType type) {
_modifiedAfterLastCache = true;
LocalFavoriteImageProvider.delete(id, type.value);
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [id, type.value]);
if (counts[folder] != null) {
counts[folder] = counts[folder]! - 1;
} else {
counts[folder] = count(folder);
}
reduceHashedId(id, type.value);
notifyListeners();
}
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
_db.execute("BEGIN TRANSACTION");
try {
for (var comic in comics) {
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
}
if (counts[folder] != null) {
counts[folder] = counts[folder]! - comics.length;
} else {
counts[folder] = count(folder);
}
} catch (e) {
Log.error("Batch Delete Comics", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
for (var comic in comics) {
reduceHashedId(comic.id, comic.type.value);
}
notifyListeners();
}
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
_db.execute("BEGIN TRANSACTION");
var folderNames = _getFolderNamesWithDB();
try {
for (var comic in comics) {
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
for (var folder in folderNames) {
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
}
}
} catch (e) {
Log.error("Batch Delete Comics in All Folders", e.toString());
_db.execute("ROLLBACK");
return;
}
initCounts();
_db.execute("COMMIT");
for (var comic in comics) {
var hash = comic.id.hashCode ^ comic.type.value;
_hashedIds.remove(hash);
}
notifyListeners();
}
@@ -630,11 +884,26 @@ class LocalFavoritesManager with ChangeNotifier {
if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found");
}
deleteFolder(folder);
createFolder(folder);
for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], i);
_db.execute("BEGIN TRANSACTION");
try {
for (int i = 0; i < newFolder.length; i++) {
_db.execute("""
update "$folder"
set display_order = ?
where id == ? and type == ?;
""", [
i,
newFolder[i].id,
newFolder[i].type.value
]);
}
}
catch (e) {
Log.error("Reorder", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
notifyListeners();
}
@@ -659,6 +928,8 @@ class LocalFavoritesManager with ChangeNotifier {
set folder_name = ?
where folder_name == ?;
""", [after, before]);
counts[after] = counts[before] ?? 0;
counts.remove(before);
notifyListeners();
}
@@ -667,7 +938,6 @@ class LocalFavoritesManager with ChangeNotifier {
markAsRead(id, type);
return;
}
_modifiedAfterLastCache = true;
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
for (final folder in folderNames) {
var rows = _db.select("""
@@ -736,10 +1006,10 @@ class LocalFavoritesManager with ChangeNotifier {
return comics;
}
List<FavoriteItemWithFolderInfo> search(String keyword) {
List<FavoriteItem> search(String keyword) {
var keywordList = keyword.split(" ");
keyword = keywordList.first;
var comics = <FavoriteItemWithFolderInfo>[];
var comics = <FavoriteItem>{};
for (var table in folderNames) {
keyword = "%$keyword%";
var res = _db.select("""
@@ -747,15 +1017,18 @@ class LocalFavoritesManager with ChangeNotifier {
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword, keyword]);
for (var comic in res) {
comics.add(
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
comics.add(FavoriteItem.fromRow(comic));
}
if (comics.length > 200) {
break;
}
}
bool test(FavoriteItemWithFolderInfo comic, String keyword) {
bool test(FavoriteItem comic, String keyword) {
keyword = keyword.trim();
if (keyword.isEmpty) {
return true;
}
if (comic.name.contains(keyword)) {
return true;
} else if (comic.author.contains(keyword)) {
@@ -766,12 +1039,14 @@ class LocalFavoritesManager with ChangeNotifier {
return false;
}
for (var i = 1; i < keywordList.length; i++) {
comics =
comics.where((element) => test(element, keywordList[i])).toList();
}
return comics;
return comics.where((element) {
for (var i = 1; i < keywordList.length; i++) {
if (!test(element, keywordList[i])) {
return false;
}
}
return true;
}).toList();
}
void editTags(String id, String folder, List<String> tags) {
@@ -783,28 +1058,9 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners();
}
final _cachedFavoritedIds = <String, bool>{};
bool isExist(String id, ComicType type) {
if (_modifiedAfterLastCache) {
_cacheFavoritedIds();
}
return _cachedFavoritedIds.containsKey("$id@${type.value}");
}
bool _modifiedAfterLastCache = true;
void _cacheFavoritedIds() {
_modifiedAfterLastCache = false;
_cachedFavoritedIds.clear();
for (var folder in folderNames) {
var rows = _db.select("""
select id, type from "$folder";
""");
for (var row in rows) {
_cachedFavoritedIds["${row["id"]}@${row["type"]}"] = true;
}
}
var hash = id.hashCode ^ type.value;
return _hashedIds.containsKey(hash);
}
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {

View File

@@ -0,0 +1,191 @@
import 'dart:async';
import 'dart:convert';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/channel.dart';
class ComicUpdateResult {
final bool updated;
final String? errorMessage;
ComicUpdateResult(this.updated, this.errorMessage);
}
Future<ComicUpdateResult> updateComic(
FavoriteItemWithUpdateInfo c, String folder) async {
int retries = 3;
while (true) {
try {
var comicSource = c.type.comicSource;
if (comicSource == null) {
return ComicUpdateResult(false, "Comic source not found");
}
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
var item = FavoriteItem(
id: c.id,
name: newInfo.title,
coverPath: newInfo.cover,
author: newInfo.subTitle ??
newInfo.tags['author']?.firstOrNull ??
c.author,
type: c.type,
tags: newTags,
);
LocalFavoritesManager().updateInfo(folder, item, false);
var updated = false;
var updateTime = newInfo.findUpdateTime();
if (updateTime != null && updateTime != c.updateTime) {
LocalFavoritesManager().updateUpdateTime(
folder,
c.id,
c.type,
updateTime,
);
updated = true;
} else {
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
}
return ComicUpdateResult(updated, null);
} catch (e, s) {
Log.error("Check Updates", e, s);
await Future.delayed(const Duration(seconds: 2));
retries--;
if (retries == 0) {
return ComicUpdateResult(false, e.toString());
}
}
}
}
class UpdateProgress {
final int total;
final int current;
final int errors;
final int updated;
final FavoriteItemWithUpdateInfo? comic;
final String? errorMessage;
UpdateProgress(this.total, this.current, this.errors, this.updated,
[this.comic, this.errorMessage]);
}
void updateFolderBase(
String folder,
StreamController<UpdateProgress> stream,
bool ignoreCheckTime,
) async {
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
int total = comics.length;
int current = 0;
int errors = 0;
int updated = 0;
stream.add(UpdateProgress(total, current, errors, updated));
var comicsToUpdate = <FavoriteItemWithUpdateInfo>[];
for (var comic in comics) {
if (!ignoreCheckTime) {
var lastCheckTime = comic.lastCheckTime;
if (lastCheckTime != null &&
DateTime.now().difference(lastCheckTime).inDays < 1) {
current++;
stream.add(UpdateProgress(total, current, errors, updated));
continue;
}
}
comicsToUpdate.add(comic);
}
total = comicsToUpdate.length;
current = 0;
stream.add(UpdateProgress(total, current, errors, updated));
var channel = Channel<FavoriteItemWithUpdateInfo>(10);
// Producer
() async {
var c = 0;
for (var comic in comicsToUpdate) {
await channel.push(comic);
c++;
// Throttle
if (c % 5 == 0) {
var delay = c % 100 + 1;
if (delay > 10) {
delay = 10;
}
await Future.delayed(Duration(seconds: delay));
}
}
channel.close();
}();
// Consumers
var updateFutures = <Future>[];
for (var i = 0; i < 5; i++) {
var f = () async {
while (true) {
var comic = await channel.pop();
if (comic == null) {
break;
}
var result = await updateComic(comic, folder);
current++;
if (result.updated) {
updated++;
}
if (result.errorMessage != null) {
errors++;
}
stream.add(UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
}
}();
updateFutures.add(f);
}
await Future.wait(updateFutures);
if (updated > 0) {
LocalFavoritesManager().notifyChanges();
}
stream.close();
}
Stream<UpdateProgress> updateFolder(String folder, bool ignoreCheckTime) {
var stream = StreamController<UpdateProgress>();
updateFolderBase(folder, stream, ignoreCheckTime);
return stream.stream;
}
Future<String> getUpdatedComicsAsJson(String folder) async {
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
var updatedComics = comics.where((c) => c.hasNewUpdate).toList();
var jsonList = updatedComics.map((c) => {
'id': c.id,
'name': c.name,
'coverUrl': c.coverPath,
'author': c.author,
'type': c.type.sourceKey,
'updateTime': c.updateTime,
'tags': c.tags,
}).toList();
return jsonEncode(jsonList);
}

View File

@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart';
@@ -132,6 +133,11 @@ class History implements Comic {
@override
String get description {
var res = "";
if (group != null){
res += "${"Group @group".tlParams({
"group": group!,
})} - ";
}
if (ep >= 1) {
res += "Chapter @ep".tlParams({
"ep": ep,
@@ -305,6 +311,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners();
}
void clearUnfavoritedHistory() {
_db.execute('BEGIN TRANSACTION;');
try {
final idAndTypes = _db.select("""
select id, type from history;
""");
for (var element in idAndTypes) {
final id = element["id"] as String;
final type = ComicType(element["type"] as int);
if (!LocalFavoritesManager().isExist(id, type)) {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [id, type.value]);
}
}
_db.execute('COMMIT;');
} catch (e) {
_db.execute('ROLLBACK;');
rethrow;
}
updateCache();
notifyListeners();
}
void remove(String id, ComicType type) async {
_db.execute("""
delete from history
@@ -380,4 +411,23 @@ class HistoryManager with ChangeNotifier {
isInitialized = false;
_db.dispose();
}
void batchDeleteHistories(List<ComicID> histories) {
if (histories.isEmpty) return;
_db.execute('BEGIN TRANSACTION;');
try {
for (var history in histories) {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [history.id, history.type.value]);
}
_db.execute('COMMIT;');
} catch (e) {
_db.execute('ROLLBACK;');
rethrow;
}
updateCache();
notifyListeners();
}
}

View File

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

View File

@@ -1,6 +1,8 @@
import 'dart:async' show Future;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart';
@@ -11,7 +13,12 @@ class CachedImageProvider
/// Image provider for normal image.
///
/// [url] is the url of the image. Local file path is also supported.
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
const CachedImageProvider(this.url, {
this.headers,
this.sourceKey,
this.cid,
this.fallbackToLocalCover = false,
});
final String url;
@@ -21,6 +28,9 @@ class CachedImageProvider
final String? cid;
// Use local cover if network image fails to load.
final bool fallbackToLocalCover;
static int loadingCount = 0;
static const _kMaxLoadingCount = 8;
@@ -49,6 +59,24 @@ class CachedImageProvider
}
throw "Error: Empty response body.";
}
catch(e) {
if (fallbackToLocalCover && sourceKey != null && cid != null) {
final localComic = LocalManager().find(
cid!,
ComicType.fromKey(sourceKey!),
);
if (localComic != null) {
var file = localComic.coverFile;
if (await file.exists()) {
var data = await file.readAsBytes();
if (data.isNotEmpty) {
return data;
}
}
}
}
rethrow;
}
finally {
loadingCount--;
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:dio/io.dart';
import 'package:enough_convert/enough_convert.dart';
import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html;
@@ -23,8 +24,10 @@ import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart';
import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/js_pool.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/init.dart';
import 'comic_source/comic_source.dart';
@@ -66,6 +69,12 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain, validateStatus: (status) => true));
}
static Uint8List? _jsInitCache;
static void cacheJsInit(Uint8List jsInit) {
_jsInitCache = jsInit;
}
@override
@protected
Future<void> doInit() async {
@@ -73,9 +82,11 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
return;
}
try {
if (App.isInitialized) {
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
}
_dio ??= AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true));
_cookieJar ??= SingleInstanceCookieJar.instance!;
_closed = false;
_engine = FlutterQjs();
_engine!.dispatch();
@@ -84,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
setGlobalFunc(["appVersion", App.version]);
setGlobalFunc.free();
var jsInit = await rootBundle.load("assets/init.js");
Uint8List jsInit;
if (_jsInitCache != null) {
jsInit = _jsInitCache!;
} else {
var buffer = await rootBundle.load("assets/init.js");
jsInit = buffer.buffer.asUint8List();
}
_engine!
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>");
.evaluate(utf8.decode(jsInit), name: "<init>");
} catch (e, s) {
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
}
@@ -95,6 +112,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
Object? _messageReceiver(dynamic message) {
try {
if (message is Map<dynamic, dynamic>) {
if (message["method"] == null) return null;
String method = message["method"] as String;
switch (method) {
case "log":
@@ -170,6 +188,20 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
var res = await Clipboard.getData(Clipboard.kTextPlain);
return res?.text;
});
case "compute":
final func = message["function"];
final args = message["args"];
if (func is JSInvokable) {
func.free();
throw "Function must be a string";
}
if (func is! String) {
throw "Function must be a string";
}
if (args != null && args is! List) {
throw "Args must be a list";
}
return JSPool().execute(func, args ?? []);
}
}
return null;
@@ -185,6 +217,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
try {
var headers = Map<String, dynamic>.from(req["headers"] ?? {});
var extra = Map<String, dynamic>.from(req["extra"] ?? {});
if (headers["user-agent"] == null && headers["User-Agent"] == null) {
headers["User-Agent"] = webUA;
}
@@ -194,7 +227,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain,
validateStatus: (status) => true,
));
var proxy = await AppDio.getProxy();
var proxy = await getProxy();
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
@@ -212,7 +245,10 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: req["bytes"] == true
? ResponseType.bytes
: ResponseType.plain,
headers: headers));
headers: headers,
extra: extra,
)
);
} catch (e) {
error = e.toString();
}
@@ -371,6 +407,11 @@ mixin class _JSEngineApi {
switch (type) {
case "utf8":
return isEncode ? utf8.encode(value) : utf8.decode(value);
case "gbk":
final codec = const GbkCodec();
return isEncode
? Uint8List.fromList(codec.encode(value))
: codec.decode(value);
case "base64":
return isEncode ? base64Encode(value) : base64Decode(value);
case "md5":
@@ -399,83 +440,72 @@ mixin class _JSEngineApi {
return Uint8List.fromList(hmac.convert(value).bytes);
}
case "aes-ecb":
if (!isEncode) {
var key = data["key"];
var cipher = ECBBlockCipher(AESEngine());
cipher.init(
false,
KeyParameter(key),
var key = data["key"];
var cipher = ECBBlockCipher(AESEngine());
cipher.init(
isEncode,
KeyParameter(key),
);
var offset = 0;
var result = Uint8List(value.length);
while (offset < value.length) {
offset += cipher.processBlock(
value,
offset,
result,
offset,
);
var offset = 0;
var result = Uint8List(value.length);
while (offset < value.length) {
offset += cipher.processBlock(
value,
offset,
result,
offset,
);
}
return result;
}
return null;
return result;
case "aes-cbc":
if (!isEncode) {
var key = data["key"];
var iv = data["iv"];
var cipher = CBCBlockCipher(AESEngine());
cipher.init(false, ParametersWithIV(KeyParameter(key), iv));
var offset = 0;
var result = Uint8List(value.length);
while (offset < value.length) {
offset += cipher.processBlock(
value,
offset,
result,
offset,
);
}
return result;
var key = data["key"];
var iv = data["iv"];
var cipher = CBCBlockCipher(AESEngine());
cipher.init(isEncode, ParametersWithIV(KeyParameter(key), iv));
var offset = 0;
var result = Uint8List(value.length);
while (offset < value.length) {
offset += cipher.processBlock(
value,
offset,
result,
offset,
);
}
return null;
return result;
case "aes-cfb":
if (!isEncode) {
var key = data["key"];
var blockSize = data["blockSize"];
var cipher = CFBBlockCipher(AESEngine(), blockSize);
cipher.init(false, KeyParameter(key));
var offset = 0;
var result = Uint8List(value.length);
while (offset < value.length) {
offset += cipher.processBlock(
value,
offset,
result,
offset,
);
}
return result;
var key = data["key"];
var iv = data["iv"];
var blockSize = data["blockSize"];
var cipher = CFBBlockCipher(AESEngine(), blockSize);
cipher.init(isEncode, ParametersWithIV(KeyParameter(key), iv));
var offset = 0;
var result = Uint8List(value.length);
while (offset < value.length) {
offset += cipher.processBlock(
value,
offset,
result,
offset,
);
}
return null;
return result;
case "aes-ofb":
if (!isEncode) {
var key = data["key"];
var blockSize = data["blockSize"];
var cipher = OFBBlockCipher(AESEngine(), blockSize);
cipher.init(false, KeyParameter(key));
var offset = 0;
var result = Uint8List(value.length);
while (offset < value.length) {
offset += cipher.processBlock(
value,
offset,
result,
offset,
);
}
return result;
var key = data["key"];
var blockSize = data["blockSize"];
var cipher = OFBBlockCipher(AESEngine(), blockSize);
cipher.init(isEncode, KeyParameter(key));
var offset = 0;
var result = Uint8List(value.length);
while (offset < value.length) {
offset += cipher.processBlock(
value,
offset,
result,
offset,
);
}
return null;
return result;
case "rsa":
if (!isEncode) {
var key = data["key"];

163
lib/foundation/js_pool.dart Normal file
View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/log.dart';
class JSPool {
static final int _maxInstances = 4;
final List<IsolateJsEngine> _instances = [];
bool _isInitializing = false;
static final JSPool _singleton = JSPool._internal();
factory JSPool() {
return _singleton;
}
JSPool._internal();
Future<void> init() async {
if (_isInitializing) return;
_isInitializing = true;
var jsInitBuffer = await rootBundle.load("assets/init.js");
var jsInit = jsInitBuffer.buffer.asUint8List();
for (int i = 0; i < _maxInstances; i++) {
_instances.add(IsolateJsEngine(jsInit));
}
_isInitializing = false;
}
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
await init();
var selectedInstance = _instances[0];
for (var instance in _instances) {
if (instance.pendingTasks < selectedInstance.pendingTasks) {
selectedInstance = instance;
}
}
return selectedInstance.execute(jsFunction, args);
}
}
class _IsolateJsEngineInitParam {
final SendPort sendPort;
final Uint8List jsInit;
_IsolateJsEngineInitParam(this.sendPort, this.jsInit);
}
class IsolateJsEngine {
Isolate? _isolate;
SendPort? _sendPort;
ReceivePort? _receivePort;
int _counter = 0;
final Map<int, Completer<dynamic>> _tasks = {};
bool _isClosed = false;
int get pendingTasks => _tasks.length;
IsolateJsEngine(Uint8List jsInit) {
_receivePort = ReceivePort();
_receivePort!.listen(_onMessage);
Isolate.spawn(_run, _IsolateJsEngineInitParam(_receivePort!.sendPort, jsInit));
}
void _onMessage(dynamic message) {
if (message is SendPort) {
_sendPort = message;
} else if (message is TaskResult) {
final completer = _tasks.remove(message.id);
if (completer != null) {
if (message.error != null) {
completer.completeError(message.error!);
} else {
completer.complete(message.result);
}
}
} else if (message is Exception) {
Log.error("IsolateJsEngine", message.toString());
for (var completer in _tasks.values) {
completer.completeError(message);
}
_tasks.clear();
close();
}
}
static void _run(_IsolateJsEngineInitParam params) async {
var sendPort = params.sendPort;
final port = ReceivePort();
sendPort.send(port.sendPort);
final engine = JsEngine();
try {
JsEngine.cacheJsInit(params.jsInit);
await engine.init();
}
catch(e, s) {
sendPort.send(Exception("Failed to initialize JS engine: $e\n$s"));
return;
}
await for (final message in port) {
if (message is Task) {
try {
final jsFunc = engine.runCode(message.jsFunction);
if (jsFunc is! JSInvokable) {
throw Exception("The provided code does not evaluate to a function.");
}
final result = jsFunc.invoke(message.args);
jsFunc.free();
sendPort.send(TaskResult(message.id, result, null));
} catch (e) {
sendPort.send(TaskResult(message.id, null, e.toString()));
}
}
}
}
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
if (_isClosed) {
throw Exception("IsolateJsEngine is closed.");
}
while (_sendPort == null) {
await Future.delayed(const Duration(milliseconds: 10));
}
final completer = Completer<dynamic>();
final taskId = _counter++;
_tasks[taskId] = completer;
final task = Task(taskId, jsFunction, args);
_sendPort?.send(task);
return completer.future;
}
void close() async {
if (!_isClosed) {
_isClosed = true;
while (_tasks.isNotEmpty) {
await Future.delayed(const Duration(milliseconds: 100));
}
_receivePort?.close();
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
}
}
}
class Task {
final int id;
final String jsFunction;
final List<dynamic> args;
const Task(this.id, this.jsFunction, this.args);
}
class TaskResult {
final int id;
final Object? result;
final String? error;
const TaskResult(this.id, this.result, this.error);
}

View File

@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:flutter_saf/flutter_saf.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -107,15 +109,42 @@ class LocalComic with HistoryMixin implements Comic {
void read() {
var history = HistoryManager().find(id, comicType);
int? firstDownloadedChapter;
int? firstDownloadedChapterGroup;
if (downloadedChapters.isNotEmpty && chapters != null) {
final chapters = this.chapters!;
if (chapters.isGrouped) {
for (int i=0; i<chapters.groupCount; i++) {
var group = chapters.getGroupByIndex(i);
var keys = group.keys.toList();
for (int j=0; j<keys.length; j++) {
var chapterId = keys[j];
if (downloadedChapters.contains(chapterId)) {
firstDownloadedChapter = j + 1;
firstDownloadedChapterGroup = i + 1;
break;
}
}
}
} else {
var keys = chapters.allChapters.keys;
for (int i = 0; i < keys.length; i++) {
if (downloadedChapters.contains(keys.elementAt(i))) {
firstDownloadedChapter = i + 1;
break;
}
}
}
}
App.rootContext.to(
() => Reader(
type: comicType,
cid: id,
name: title,
chapters: chapters,
initialChapter: history?.ep,
initialChapter: history?.ep ?? firstDownloadedChapter,
initialPage: history?.page,
initialChapterGroup: history?.group,
initialChapterGroup: history?.group ?? firstDownloadedChapterGroup,
history: history ??
History.fromModel(
model: this,
@@ -124,7 +153,7 @@ class LocalComic with HistoryMixin implements Comic {
),
author: subtitle,
tags: tags,
),
)
);
}
@@ -394,6 +423,7 @@ class LocalManager with ChangeNotifier {
if (comic.hasChapters) {
var cid =
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
cid = getChapterDirectoryName(cid);
directory = Directory(FilePath.join(directory.path, cid));
}
var files = <File>[];
@@ -461,7 +491,7 @@ class LocalManager with ChangeNotifier {
if (comic != null) {
return Directory(FilePath.join(path, comic.directory));
}
const comicDirectoryMaxLength = 128;
const comicDirectoryMaxLength = 80;
if (name.length > comicDirectoryMaxLength) {
name = name.substring(0, comicDirectoryMaxLength);
}
@@ -546,6 +576,117 @@ class LocalManager with ChangeNotifier {
remove(c.id, c.comicType);
notifyListeners();
}
void deleteComicChapters(LocalComic c, List<String> chapters) {
if (chapters.isEmpty) {
return;
}
var newDownloadedChapters = c.downloadedChapters
.where((e) => !chapters.contains(e))
.toList();
if (newDownloadedChapters.isNotEmpty) {
_db.execute(
'UPDATE comics SET downloadedChapters = ? WHERE id = ? AND comic_type = ?;',
[
jsonEncode(newDownloadedChapters),
c.id,
c.comicType.value,
],
);
} else {
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[c.id, c.comicType.value],
);
}
var shouldRemovedDirs = <Directory>[];
for (var chapter in chapters) {
var dir = Directory(FilePath.join(
c.baseDir,
getChapterDirectoryName(chapter),
));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
}
if (shouldRemovedDirs.isNotEmpty) {
_deleteDirectories(shouldRemovedDirs);
}
notifyListeners();
}
void batchDeleteComics(List<LocalComic> comics, [bool removeFileOnDisk = true, bool removeFavoriteAndHistory = true]) {
if (comics.isEmpty) {
return;
}
var shouldRemovedDirs = <Directory>[];
_db.execute('BEGIN TRANSACTION;');
try {
for (var c in comics) {
if (removeFileOnDisk) {
var dir = Directory(FilePath.join(path, c.directory));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
}
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[c.id, c.comicType.value],
);
}
}
catch(e, s) {
Log.error("LocalManager", "Failed to batch delete comics: $e", s);
_db.execute('ROLLBACK;');
return;
}
_db.execute('COMMIT;');
var comicIDs = comics.map((e) => ComicID(e.comicType, e.id)).toList();
if (removeFavoriteAndHistory) {
LocalFavoritesManager().batchDeleteComicsInAllFolders(comicIDs);
HistoryManager().batchDeleteHistories(comicIDs);
}
notifyListeners();
if (removeFileOnDisk) {
_deleteDirectories(shouldRemovedDirs);
}
}
/// Deletes the directories in a separate isolate to avoid blocking the UI thread.
static void _deleteDirectories(List<Directory> directories) {
Isolate.run(() async {
await SAFTaskWorker().init();
for (var dir in directories) {
try {
if (dir.existsSync()) {
await dir.delete(recursive: true);
}
} catch (e) {
continue;
}
}
});
}
static String getChapterDirectoryName(String name) {
var builder = StringBuffer();
for (var i = 0; i < name.length; i++) {
var char = name[i];
if (char == '/' || char == '\\' || char == ':' || char == '*' ||
char == '?'
|| char == '"' || char == '<' || char == '>' || char == '|') {
builder.write('_');
} else {
builder.write(char);
}
}
return builder.toString();
}
}
enum LocalSortType {

View File

@@ -28,6 +28,8 @@ class Log {
static bool ignoreLimitation = false;
static bool isMuted = false;
static void printWarning(String text) {
debugPrint('\x1B[33m$text\x1B[0m');
}
@@ -39,7 +41,8 @@ class Log {
static IOSink? _file;
static void addLog(LogLevel level, String title, String content) {
if (_file == null) {
if (isMuted) return;
if (_file == null && App.isInitialized) {
Directory dir;
if (App.isAndroid) {
dir = Directory(App.externalStoragePath!);

244
lib/headless.dart Normal file
View File

@@ -0,0 +1,244 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/init.dart';
import 'package:venera/foundation/follow_updates.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart';
void cliPrint(Map<String, dynamic> data) {
print('[CLI PRINT] ${jsonEncode(data)}');
}
Future<void> runHeadlessMode(List<String> args) async {
WidgetsFlutterBinding.ensureInitialized();
if (args.contains('--ignore-disheadless-log')) {
Log.isMuted = true;
}
if(Platform.isLinux || Platform.isMacOS){
Directory.current = Platform.environment['HOME']!;
}
// The first arg is '--headless', so we look at the next ones.
var commandIndex = args.indexOf('--headless') + 1;
if (commandIndex >= args.length) {
cliPrint({'status': 'error', 'message': 'No command provided for headless mode.'});
exit(1);
}
// Need to initialize the app for some features to work
await init();
var command = args[commandIndex];
var subCommand = (commandIndex + 1 < args.length) ? args[commandIndex + 1] : null;
switch (command) {
case 'webdav':
if (subCommand == 'up') {
cliPrint({'status': 'running', 'message': 'Uploading WebDAV data...'});
await DataSync().uploadData();
cliPrint({'status': 'success', 'message': 'Upload complete.'});
} else if (subCommand == 'down') {
cliPrint({'status': 'running', 'message': 'Downloading WebDAV data...'});
await DataSync().downloadData();
cliPrint({'status': 'success', 'message': 'Download complete.'});
} else {
cliPrint({'status': 'error', 'message': 'Invalid webdav command. Use "up" or "down".'});
exit(1);
}
break;
case 'updatescript':
if (subCommand == 'all') {
cliPrint({'status': 'running', 'message': 'Checking for comic source script updates...'});
await ComicSourcePage.checkComicSourceUpdate();
var updates = ComicSourceManager().availableUpdates;
if (updates.isEmpty) {
cliPrint({'status': 'success', 'message': 'No updates found.'});
} else {
var total = updates.length;
var current = 0;
var errors = 0;
var updated = 0;
cliPrint({
'status': 'running',
'message': 'Updating all comic source scripts...',
'data': {
'total': total,
'current': 0,
'updated': 0,
'errors': 0,
}
});
for (var key in updates.keys) {
var source = ComicSource.find(key);
if (source != null) {
current++;
var data = {
'current': current,
'total': total,
'source': {
'key': source.key,
'name': source.name,
'version': source.version,
'url': source.url,
}
};
try {
await ComicSourcePage.update(source, false);
updated++;
cliPrint({
'status': 'running',
'message': 'Progress',
'data': data,
});
} catch (e) {
errors++;
cliPrint({
'status': 'running',
'message': 'ProgressError',
'data': {
...data,
'error': e.toString(),
},
});
}
}
}
cliPrint({
'status': 'success',
'message': 'All scripts updated.',
'data': {
'total': total,
'updated': updated,
'errors': errors,
}
});
}
} else {
cliPrint({'status': 'error', 'message': 'Invalid updatescript command. Use "all".'});
exit(1);
}
break;
case 'updatesubscribe':
cliPrint({'status': 'running', 'message': 'Updating subscribed comics...'});
var folder = appdata.settings["followUpdatesFolder"];
if (folder == null) {
cliPrint({'status': 'error', 'message': 'Follow updates folder is not configured.'});
exit(1);
}
var updateIndex = args.indexOf('--update-comic-by-id-type');
if (updateIndex != -1) {
var id = args[updateIndex + 1];
var type = args[updateIndex + 2];
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
var comic = comics.firstWhere((c) => c.id == id && c.type.sourceKey == type);
var result = await updateComic(comic, folder);
Map<String, dynamic> data = {
'current': 1,
'total': 1,
'comic': {
'id': comic.id,
'name': comic.name,
'coverUrl': comic.coverPath,
'author': comic.author,
'type': comic.type.sourceKey,
'updateTime': comic.updateTime,
'tags': comic.tags,
}
};
var message = 'Progress';
if (result.errorMessage != null) {
message = 'ProgressError';
data['error'] = result.errorMessage;
}
cliPrint({
'status': 'running',
'message': message,
'data': data,
});
cliPrint({
'status': 'running',
'message': 'Update check complete.',
'data': {
'total': 1,
'updated': result.updated ? 1 : 0,
'errors': result.errorMessage != null ? 1 : 0,
}
});
await Future.delayed(const Duration(milliseconds: 500));
var json = await getUpdatedComicsAsJson(folder);
cliPrint({
'status': result.errorMessage != null ? 'error' : 'success',
'message': 'Updated comics list.',
'data': jsonDecode(json),
});
} else {
int total = 0;
int updated = 0;
int errors = 0;
await for (var progress in updateFolder(folder, true)) {
total = progress.total;
updated = progress.updated;
errors = progress.errors;
Map<String, dynamic> data = {
'current': progress.current,
'total': progress.total,
};
if (progress.comic != null) {
data['comic'] = {
'id': progress.comic!.id,
'name': progress.comic!.name,
'coverUrl': progress.comic!.coverPath,
'author': progress.comic!.author,
'type': progress.comic!.type.sourceKey,
'updateTime': progress.comic!.updateTime,
'tags': progress.comic!.tags,
};
}
var message = 'Progress';
if (progress.errorMessage != null) {
message = 'ProgressError';
data['error'] = progress.errorMessage;
}
cliPrint({
'status': 'running',
'message': message,
'data': data,
});
}
cliPrint({
'status': 'running',
'message': 'Update check complete.',
'data': {
'total': total,
'updated': updated,
'errors': errors,
}
});
await Future.delayed(const Duration(milliseconds: 500));
var json = await getUpdatedComicsAsJson(folder);
cliPrint({
'status': errors > 0 ? 'error' : 'success',
'message': 'Updated comics list.',
'data': jsonDecode(json),
});
}
break;
default:
cliPrint({'status': 'error', 'message': 'Unknown command: $command'});
exit(1);
}
// Exit after command execution
exit(0);
}

View File

@@ -1,4 +1,8 @@
import 'dart:async';
import 'package:display_mode/display_mode.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_saf/flutter_saf.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/app.dart';
@@ -12,6 +16,7 @@ import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/handle_text_share.dart';
import 'package:venera/utils/opencc.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart';
@@ -32,25 +37,43 @@ extension _FutureInit<T> on Future<T> {
Future<void> init() async {
await App.init().wait();
await SingleInstanceCookieJar.createInstance();
var futures = [
Rhttp.init(),
App.initComponents(),
SAFTaskWorker().init().wait(),
AppTranslation.init().wait(),
TagsTranslation.readData().wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
];
await Future.wait(futures);
try {
var futures = [
Rhttp.init(),
App.initComponents(),
SAFTaskWorker().init().wait(),
AppTranslation.init().wait(),
TagsTranslation.readData().wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
OpenCC.init(),
];
await Future.wait(futures);
} catch (e, s) {
Log.error("init", "$e\n$s");
}
CacheManager().setLimitSize(appdata.settings['cacheSize']);
_checkOldConfigs();
if (App.isAndroid) {
handleLinks();
handleTextShare();
try {
await FlutterDisplayMode.setHighRefreshRate();
} catch(e) {
Log.error("Display Mode", "Failed to set high refresh rate: $e");
}
}
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
if (App.isWindows) {
// Report to the monitor thread that the app is running
// https://github.com/venera-app/venera/issues/343
Timer.periodic(const Duration(seconds: 1), (_) {
const methodChannel = MethodChannel('venera/method_channel');
methodChannel.invokeMethod("heartBeat");
});
}
}
void _checkOldConfigs() {
@@ -84,8 +107,7 @@ Future<void> _checkAppUpdates() async {
appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
await checkUpdateUi(false, true);
}
}

View File

@@ -14,9 +14,14 @@ import 'components/components.dart';
import 'components/window_frame.dart';
import 'foundation/app.dart';
import 'foundation/appdata.dart';
import 'headless.dart';
import 'init.dart';
void main(List<String> args) {
if (args.contains('--headless')) {
runHeadlessMode(args);
return;
}
if (runWebViewTitleBarWidget(args)) return;
overrideIO(() {
runZonedGuarded(() async {
@@ -194,6 +199,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
tertiary = light.tertiary;
}
return MaterialApp(
title: "venera",
home: home,
debugShowCheckedModeBanner: false,
theme: getTheme(primary, secondary, tertiary, Brightness.light),
@@ -237,6 +243,27 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
);
};
if (widget != null) {
/// 如果无法检测到状态栏高度设定指定高度
/// https://github.com/flutter/flutter/issues/161086
var isPaddingCheckError =
MediaQuery.of(context).viewPadding.top <= 0 ||
MediaQuery.of(context).viewPadding.top > 200;
if (isPaddingCheckError && Platform.isAndroid) {
widget = MediaQuery(
data: MediaQuery.of(context).copyWith(
viewPadding: const EdgeInsets.only(
top: 15,
bottom: 15,
),
padding: const EdgeInsets.only(
top: 15,
bottom: 15,
),
),
child: widget);
}
widget = OverlayWidget(widget);
if (App.isDesktop) {
widget = Shortcuts(

View File

@@ -7,7 +7,7 @@ import 'package:rhttp/rhttp.dart' as rhttp;
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cache.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/network/proxy.dart';
import '../foundation/app.dart';
import 'cloudflare.dart';
@@ -96,9 +96,28 @@ class MyLogInterceptor implements Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
Log.info("Network", "${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n"
"data:\n${options.data}");
const String headerMask = "********";
const String dataMask = "****** DATA_PROTECTED ******";
Log.info(
"Network",
"${options.method} ${options.uri}\n"
"headers:\n${
options.extra.containsKey("maskHeadersInLog")
? options.headers.map((key, value) =>
MapEntry(
key,
options.extra["maskHeadersInLog"].contains(key)
? headerMask
: value
))
: options.headers
}\n"
"data:\n${
options.extra["maskDataInLog"] == true
? dataMask
: options.data
}"
);
options.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15);
@@ -107,62 +126,15 @@ class MyLogInterceptor implements Interceptor {
}
class AppDio with DioMixin {
String? _proxy = proxy;
AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions();
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor());
}
static String? proxy;
static Future<String?> getProxy() async {
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
return null;
httpClientAdapter = RHttpAdapter();
if (App.isInitialized) {
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor());
}
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
String res;
if (!App.isLinux) {
const channel = MethodChannel("venera/method_channel");
try {
res = await channel.invokeMethod("getProxy");
} catch (e) {
return null;
}
} else {
res = "No Proxy";
}
if (res == "No Proxy") return null;
if (res.contains(";")) {
var proxies = res.split(";");
for (String proxy in proxies) {
proxy = proxy.removeAllBlank;
if (proxy.startsWith('https=')) {
return proxy.substring(6);
}
}
}
final RegExp regex = RegExp(
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
caseSensitive: false,
multiLine: false,
);
if (!regex.hasMatch(res)) {
return null;
}
return res;
}
static final Map<String, bool> _requests = {};
@@ -184,16 +156,6 @@ class AppDio with DioMixin {
_requests[path] = true;
options!.headers!.remove('prevent-parallel');
}
proxy = await getProxy();
if (_proxy != proxy) {
Log.info("Network", "Proxy changed to $proxy");
_proxy = proxy;
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
}
try {
return super.request<T>(
path,
@@ -213,7 +175,27 @@ class AppDio with DioMixin {
}
class RHttpAdapter implements HttpClientAdapter {
rhttp.ClientSettings settings;
Future<rhttp.ClientSettings> get settings async {
var proxy = await getProxy();
return rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy),
redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings(
connectTimeout: Duration(seconds: 15),
keepAliveTimeout: Duration(seconds: 60),
keepAlivePing: Duration(seconds: 30),
),
throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
verifyCertificates: appdata.settings['ignoreBadCertificate'] != true,
),
);
}
static Map<String, List<String>> _getOverrides() {
if (!appdata.settings['enableDnsOverrides'] == true) {
@@ -231,22 +213,6 @@ class RHttpAdapter implements HttpClientAdapter {
return result;
}
RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
settings = settings.copyWith(
redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings(
connectTimeout: Duration(seconds: 15),
keepAliveTimeout: Duration(seconds: 60),
keepAlivePing: Duration(seconds: 30),
),
throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
),
);
}
@override
void close({bool force = false}) {}
@@ -256,10 +222,15 @@ class RHttpAdapter implements HttpClientAdapter {
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
if (options.headers['User-Agent'] == null &&
options.headers['user-agent'] == null) {
options.headers['User-Agent'] = "venera/v${App.version}";
}
var res = await rhttp.Rhttp.request(
method: rhttp.HttpMethod(options.method),
url: options.uri.toString(),
settings: settings,
settings: await settings,
expectBody: rhttp.HttpExpectBody.stream,
body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream),
headers: rhttp.HttpHeaders.rawMap(
@@ -289,7 +260,7 @@ class RHttpAdapter implements HttpClientAdapter {
}
static String _getStatusMessage(int statusCode) {
return switch(statusCode) {
return switch (statusCode) {
200 => "OK",
201 => "Created",
202 => "Accepted",
@@ -299,9 +270,11 @@ class RHttpAdapter implements HttpClientAdapter {
302 => "Found",
400 => "Invalid Status Code 400: The Request is invalid.",
401 => "Invalid Status Code 401: The Request is unauthorized.",
403 => "Invalid Status Code 403: No permission to access the resource. Check your account or network.",
403 =>
"Invalid Status Code 403: No permission to access the resource. Check your account or network.",
404 => "Invalid Status Code 404: Not found.",
429 => "Invalid Status Code 429: Too many requests. Please try again later.",
429 =>
"Invalid Status Code 429: Too many requests. Please try again later.",
_ => "Invalid Status Code $statusCode",
};
}

View File

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

View File

@@ -202,9 +202,13 @@ class SingleInstanceCookieJar extends CookieJarSql {
static SingleInstanceCookieJar? instance;
static Future<void> createInstance() async {
static Future<SingleInstanceCookieJar> createInstance() async {
if (instance != null) {
return instance!;
}
var dataPath = (await getApplicationSupportDirectory()).path;
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
return instance!;
}
}

View File

@@ -107,7 +107,21 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
var local = LocalManager().find(id, comicType);
if (path != null) {
if (local == null) {
Directory(path!).deleteIgnoreError(recursive: true);
Future.sync(() async {
var tasks = this.tasks.values.toList();
for (var i = 0; i < tasks.length; i++) {
if (!tasks[i].isComplete) {
tasks[i].cancel();
await tasks[i].wait();
}
}
try {
await Directory(path!).delete(recursive: true);
}
catch(e) {
Log.error("Download", "Failed to delete directory: $e");
}
});
} else if (chapters != null) {
for (var c in chapters!) {
var dir = Directory(FilePath.join(path!, c));
@@ -197,7 +211,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (comic!.chapters != null) {
saveTo = Directory(FilePath.join(
path!,
_images!.keys.elementAt(_chapter),
LocalManager.getChapterDirectoryName(
_images!.keys.elementAt(_chapter),
),
));
if (!saveTo.existsSync()) {
saveTo.createSync(recursive: true);
@@ -552,7 +568,7 @@ class _ImageDownloadWrapper {
void start() async {
int lastBytes = 0;
try {
await for (var p in ImageDownloader.loadComicImage(
await for (var p in ImageDownloader.loadComicImageUnwrapped(
image, task.source.key, task.comicId, chapter)) {
if (isCancelled) {
return;

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:dio/io.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/ext.dart';
class FileDownloader {
@@ -105,7 +106,7 @@ class FileDownloader {
void _download(StreamController<DownloadingStatus> resultStream) async {
try {
var proxy = await AppDio.getProxy();
var proxy = await getProxy();
_dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_qjs/flutter_qjs.dart';
@@ -8,7 +9,7 @@ import 'package:venera/utils/image.dart';
import 'app_dio.dart';
class ImageDownloader {
abstract class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey,
[String? cid]) async* {
@@ -51,7 +52,11 @@ class ImageDownloader {
responseType: ResponseType.stream,
));
var req = await dio.request<ResponseBody>(configs['url'] ?? url,
String requestUrl = configs['url'] ?? url;
if (requestUrl.startsWith('//')) {
requestUrl = 'https:$requestUrl';
}
var req = await dio.request<ResponseBody>(requestUrl,
data: configs['data']);
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
int? expectedBytes = req.data!.contentLength;
@@ -70,7 +75,8 @@ class ImageDownloader {
}
if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
final uint8List = Uint8List.fromList(buffer);
buffer = (configs['onResponse'] as JSInvokable)([uint8List]);
(configs['onResponse'] as JSInvokable).free();
}
@@ -82,7 +88,40 @@ class ImageDownloader {
);
}
static final _loadingImages = <String, _StreamWrapper<ImageDownloadProgress>>{};
/// Cancel all loading images.
static void cancelAllLoadingImages() {
for (var wrapper in _loadingImages.values) {
wrapper.cancel();
}
_loadingImages.clear();
}
/// Load a comic image from the network or cache.
/// The function will prevent multiple requests for the same image.
static Stream<ImageDownloadProgress> loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
if (_loadingImages.containsKey(cacheKey)) {
return _loadingImages[cacheKey]!.stream;
}
final stream = _StreamWrapper<ImageDownloadProgress>(
_loadComicImage(imageKey, sourceKey, cid, eid),
(wrapper) {
_loadingImages.remove(cacheKey);
},
);
_loadingImages[cacheKey] = stream;
return stream.stream;
}
static Stream<ImageDownloadProgress> loadComicImageUnwrapped(
String imageKey, String? sourceKey, String cid, String eid) {
return _loadComicImage(imageKey, sourceKey, cid, eid);
}
static Stream<ImageDownloadProgress> _loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) async* {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
final cache = await CacheManager().findCache(cacheKey);
@@ -146,12 +185,25 @@ class ImageDownloader {
}
if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
dynamic result = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
if (result is Future) {
result = await result;
}
if (result is List<int>) {
buffer = result;
} else {
throw "Error: Invalid onResponse result.";
}
(configs['onResponse'] as JSInvokable).free();
}
var data = Uint8List.fromList(buffer);
buffer.clear();
Uint8List data;
if (buffer is Uint8List) {
data = buffer;
} else {
data = Uint8List.fromList(buffer);
buffer.clear();
}
if (configs['modifyImage'] != null) {
var newData = await modifyImageWithScript(
@@ -189,6 +241,74 @@ class ImageDownloader {
}
}
/// A wrapper class for a stream that
/// allows multiple listeners to listen to the same stream.
class _StreamWrapper<T> {
final Stream<T> _stream;
final List<StreamController> controllers = [];
final void Function(_StreamWrapper<T> wrapper) onClosed;
bool isClosed = false;
_StreamWrapper(this._stream, this.onClosed) {
_listen();
}
void _listen() async {
try {
await for (var data in _stream) {
if (isClosed) {
break;
}
for (var controller in controllers) {
if (!controller.isClosed) {
controller.add(data);
}
}
}
}
catch (e) {
for (var controller in controllers) {
if (!controller.isClosed) {
controller.addError(e);
}
}
}
finally {
for (var controller in controllers) {
if (!controller.isClosed) {
controller.close();
}
}
}
controllers.clear();
isClosed = true;
onClosed(this);
}
Stream<T> get stream {
if (isClosed) {
throw Exception('Stream is closed');
}
var controller = StreamController<T>();
controllers.add(controller);
controller.onCancel = () {
controllers.remove(controller);
};
return controller.stream;
}
void cancel() {
for (var controller in controllers) {
controller.close();
}
controllers.clear();
isClosed = true;
}
}
class ImageDownloadProgress {
final int currentBytes;

60
lib/network/proxy.dart Normal file
View File

@@ -0,0 +1,60 @@
import 'package:flutter/services.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/utils/ext.dart';
String? _cachedProxy;
DateTime? _cachedProxyTime;
Future<String?> getProxy() async {
if (_cachedProxyTime != null &&
DateTime.now().difference(_cachedProxyTime!).inSeconds < 1) {
return _cachedProxy;
}
String? proxy = await _getProxy();
_cachedProxy = proxy;
_cachedProxyTime = DateTime.now();
return proxy;
}
Future<String?> _getProxy() async {
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
return null;
}
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
String res;
if (!App.isLinux) {
const channel = MethodChannel("venera/method_channel");
try {
res = await channel.invokeMethod("getProxy");
} catch (e) {
return null;
}
} else {
res = "No Proxy";
}
if (res == "No Proxy") return null;
if (res.contains(";")) {
var proxies = res.split(";");
for (String proxy in proxies) {
proxy = proxy.removeAllBlank;
if (proxy.startsWith('https=')) {
return proxy.substring(6);
}
}
}
final RegExp regex = RegExp(
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
caseSensitive: false,
multiLine: false,
);
if (!regex.hasMatch(res)) {
return null;
}
return res;
}

View File

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

View File

@@ -27,9 +27,11 @@ class CategoryComicsPage extends StatefulWidget {
class _CategoryComicsPageState extends State<CategoryComicsPage> {
late final CategoryComicsData data;
late final List<CategoryComicsOptions> options;
late List<CategoryComicsOptions>? options;
late final CategoryOptionsLoader? optionsLoader;
late List<String> optionsValue;
late String sourceKey;
String? error;
void findData() {
for (final source in ComicSource.all()) {
@@ -38,24 +40,23 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
throw "The comic source ${source.name} does not support category comics";
}
data = source.categoryComicsData!;
options = data.options.where((element) {
if (element.notShowWhen.contains(widget.category)) {
return false;
} else if (element.showWhen != null) {
return element.showWhen!.contains(widget.category);
}
return true;
}).toList();
var defaultOptionsValue =
options.map((e) => e.options.keys.first).toList();
if (optionsValue.length != options.length) {
var newOptionsValue = List<String>.filled(options.length, "");
for (var i = 0; i < options.length; i++) {
newOptionsValue[i] =
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
}
optionsValue = newOptionsValue;
if (data.options != null) {
options = data.options!.where((element) {
if (element.notShowWhen.contains(widget.category)) {
return false;
} else if (element.showWhen != null) {
return element.showWhen!.contains(widget.category);
}
return true;
}).toList();
} else {
options = null;
}
if (data.optionsLoader != null) {
optionsLoader = data.optionsLoader;
loadOptions();
}
resetOptionsValue();
sourceKey = source.key;
return;
}
@@ -63,6 +64,36 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
throw "${widget.categoryKey} Not found";
}
void resetOptionsValue() {
if (options == null) return;
var defaultOptionsValue = options!
.map((e) => e.options.keys.first)
.toList();
if (optionsValue.length != options!.length) {
var newOptionsValue = List<String>.filled(options!.length, "");
for (var i = 0; i < options!.length; i++) {
newOptionsValue[i] =
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
}
optionsValue = newOptionsValue;
}
}
void loadOptions() async {
final res = await optionsLoader!(widget.category, widget.param);
if (res.error) {
setState(() {
error = res.errorMessage;
});
} else {
setState(() {
options = res.data;
resetOptionsValue();
error = null;
});
}
}
@override
void initState() {
if (widget.options != null) {
@@ -77,27 +108,44 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
@override
Widget build(BuildContext context) {
var topPadding = context.padding.top + 56.0;
Widget body;
if (options == null) {
body = Center(child: CircularProgressIndicator());
} else if (error != null) {
body = NetworkError(
message: error!,
retry: () {
setState(() {
error = null;
});
loadOptions();
},
);
} else {
body = ComicList(
key: Key(widget.category + optionsValue.toString()),
errorLeading: buildOptions().paddingTop(topPadding),
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
loadPage: (i) =>
data.load(widget.category, widget.param, optionsValue, i),
);
}
return Scaffold(
extendBodyBehindAppBar: true,
appBar: Appbar(
title: Text(widget.category),
),
body: ComicList(
key: Key(widget.category + optionsValue.toString()),
errorLeading: SizedBox(height: topPadding),
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
loadPage: (i) => data.load(
widget.category,
widget.param,
optionsValue,
i,
),
),
appBar: Appbar(title: Text(widget.category)),
body: body,
);
}
Widget buildOptionItem(
String text, String value, int group, BuildContext context) {
String text,
String value,
int group,
BuildContext context,
) {
return OptionChip(
text: text.ts(sourceKey),
isSelected: value == optionsValue[group],
@@ -112,23 +160,57 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
Widget buildOptions() {
List<Widget> children = [];
for (var optionList in options) {
children.add(Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var option in optionList.options.entries)
buildOptionItem(
option.value.tl,
option.key,
options.indexOf(optionList),
context,
)
],
));
if (options.last != optionList) {
var group = 0;
for (var optionList in options!) {
if (optionList.label.isNotEmpty) {
children.add(Padding(
padding: const EdgeInsets.only(
bottom: 8.0,
left: 4.0,
),
child: Text(
optionList.label.ts(sourceKey),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
));
}
if (optionList.options.length <= 8) {
children.add(
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var option in optionList.options.entries)
buildOptionItem(
option.value.tl,
option.key,
group,
context,
),
],
),
);
} else {
var g = group;
children.add(Select(
current: optionList.options[optionsValue[g]],
values: optionList.options.values.toList(),
onTap: (i) {
var key = optionList.options.keys.elementAt(i);
if (key == optionsValue[g]) return;
setState(() {
optionsValue[g] = key;
});
},
));
}
if (options!.last != optionList) {
children.add(const SizedBox(height: 8));
}
group++;
}
return Column(
mainAxisSize: MainAxisSize.min,

View File

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

View File

@@ -27,7 +27,7 @@ class _NormalComicChapters extends StatefulWidget {
class _NormalComicChaptersState extends State<_NormalComicChapters> {
late _ComicPageState state;
bool reverse = false;
late bool reverse;
bool showAll = false;
@@ -38,6 +38,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
@override
void initState() {
super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history;
}
@@ -176,7 +177,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
with SingleTickerProviderStateMixin {
late _ComicPageState state;
bool reverse = false;
late bool reverse;
bool showAll = false;
@@ -191,6 +192,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
@override
void initState() {
super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history;
if (history?.group != null) {
index = history!.group! - 1;

View File

@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:collection';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart';
import 'package:shimmer_animation/shimmer_animation.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -17,10 +20,12 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/network/cache.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
@@ -38,6 +43,8 @@ part 'comments_preview.dart';
part 'actions.dart';
part 'cover_viewer.dart';
class ComicPage extends StatefulWidget {
const ComicPage({
super.key,
@@ -77,8 +84,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override
void onReadEnd() {
history ??=
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
history ??= HistoryManager().find(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
update();
}
@@ -93,6 +102,32 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
);
}
@override
Widget buildError() {
final isDownloaded = LocalManager().isDownloaded(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
Widget? action;
if (isDownloaded) {
action = FilledButton.tonal(
child: Text("Read".tl),
onPressed: () {
final localComic = LocalManager().find(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
if (localComic == null) {
context.showMessage(message: "Local comic not found".tl);
return;
}
localComic.read();
},
);
}
return NetworkError(message: error!, retry: retry, action: action);
}
@override
void initState() {
scrollController.addListener(onScroll);
@@ -114,7 +149,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ComicDetails get comic => data!;
void onScroll() {
var offset = scrollController.position.pixels -
var offset =
scrollController.position.pixels -
scrollController.position.minScrollExtent;
var showFAB = offset > 0;
if (showFAB != this.showFAB) {
@@ -145,9 +181,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
floatingActionButton: showFAB
? FloatingActionButton(
onPressed: () {
scrollController.animateTo(0,
duration: const Duration(milliseconds: 200),
curve: Curves.ease);
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
child: const Icon(Icons.arrow_upward),
)
@@ -164,7 +202,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildThumbnails(),
buildRecommend(),
SliverPadding(
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB
padding: EdgeInsets.only(
bottom: context.padding.bottom + 80,
), // Add additional padding for FAB
),
],
),
@@ -190,12 +230,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
initialPage: history?.page,
initialChapter: history?.ep,
initialChapterGroup: history?.group,
history: history ??
History.fromModel(
model: localComic,
ep: 0,
page: 0,
),
history:
history ??
History.fromModel(model: localComic, ep: 0, page: 0),
author: localComic.subTitle ?? '',
tags: localComic.tags,
);
@@ -215,8 +252,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
widget.id,
ComicType(widget.sourceKey.hashCode),
);
history =
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
history = HistoryManager().find(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
return comicSource.loadComicInfo!(widget.id);
}
@@ -224,12 +263,20 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
Future<void> onDataLoaded() async {
isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false;
// For sources with multi-folder favorites, prefer querying folders to get accurate favorite status
// Some sources may not set isFavorite reliably when multi-folder is enabled
if (comicSource.favoriteData?.loadFolders != null && comicSource.isLogged) {
var res = await comicSource.favoriteData!.loadFolders!(comic.id);
if (!res.error) {
if (res.subData is List) {
var list = List<String>.from(res.subData);
isFavorite = list.isNotEmpty;
update();
}
}
}
if (comic.chapters == null) {
isDownloaded = LocalManager().isDownloaded(
comic.id,
comic.comicType,
0,
);
isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
}
}
@@ -242,7 +289,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
),
actions: [
IconButton(
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
onPressed: showMoreActions,
icon: const Icon(Icons.more_horiz),
),
],
);
@@ -253,31 +302,35 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Hero(
tag: "cover${widget.heroID}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
GestureDetector(
onTap: () => _viewCover(context),
onLongPress: () => _saveCover(context),
child: Hero(
tag: "cover${widget.heroID}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
),
width: double.infinity,
height: double.infinity,
),
width: double.infinity,
height: double.infinity,
),
),
),
@@ -288,8 +341,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
children: [
SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14)
.paddingVertical(4),
SelectableText(
comic.subTitle!,
style: ts.s14,
).paddingVertical(4),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
@@ -338,10 +393,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
icon: const Icon(Icons.favorite_border),
activeIcon: const Icon(Icons.favorite),
isActive: isLiked,
text: ((data!.likesCount != null)
? (data!.likesCount! + (isLiked ? 1 : 0))
: (isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(),
text:
((data!.likesCount != null)
? (data!.likesCount! + (isLiked ? 1 : 0))
: (isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(),
isLoading: isLiking,
onPressed: likeOrUnlike,
iconColor: context.useTextColor(Colors.red),
@@ -383,9 +439,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
Expanded(
child: hasHistory
? FilledButton(
onPressed: continueRead, child: Text("Continue".tl))
onPressed: continueRead,
child: Text("Continue".tl),
)
: FilledButton(onPressed: read, child: Text("Read".tl)),
)
),
],
).paddingHorizontal(16).paddingVertical(8),
if (history != null)
@@ -410,20 +468,27 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
String text;
if (haveChapter) {
var epName = "E$ep";
String? groupName;
try {
epName = group == null
? comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
)
: comic.chapters!
.getGroupByIndex(group - 1)
.values
.elementAt(ep - 1);
}
catch(e) {
if (group == null) {
epName = comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
);
} else {
groupName = comic.chapters!.groups.elementAt(
group - 1,
);
epName = comic.chapters!
.getGroupByIndex(group - 1)
.values
.elementAt(ep - 1);
}
} catch (e) {
// ignore
}
text = "${"Last Reading".tl}: $epName P$page";
text = groupName == null
? "${"Last Reading".tl}: $epName P$page"
: "${"Last Reading".tl}: $groupName $epName P$page";
} else {
text = "${"Last Reading".tl}: P$page";
}
@@ -447,9 +512,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
return SliverLazyToBoxAdapter(
child: Column(
children: [
ListTile(
title: Text("Description".tl),
),
ListTile(title: Text("Description".tl)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!).fixWidth(double.infinity),
@@ -533,10 +596,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
);
} else {
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: borderRadius,
),
decoration: BoxDecoration(color: color, borderRadius: borderRadius),
child: Text(text).padding(padding),
);
}
@@ -546,13 +606,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (int.tryParse(time) != null) {
var t = int.tryParse(time);
if (t! > 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(t)
.toString()
.substring(0, 19);
return DateTime.fromMillisecondsSinceEpoch(
t,
).toString().substring(0, 19);
} else {
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
.toString()
.substring(0, 19);
return DateTime.fromMillisecondsSinceEpoch(
t * 1000,
).toString().substring(0, 19);
}
}
if (time.contains('T') || time.contains('Z')) {
@@ -577,17 +637,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text("Information".tl),
),
ListTile(title: Text("Information".tl)),
if (comic.stars != null)
Row(
children: [
StarRating(
value: comic.stars!,
size: 24,
onTap: starRating,
),
StarRating(value: comic.stars!, size: 24, onTap: starRating),
const SizedBox(width: 8),
Text(comic.stars!.toStringAsFixed(2)),
],
@@ -665,24 +719,67 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.recommend == null || comic.recommend!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Related".tl),
),
),
SliverGridComics(comics: comic.recommend!),
]);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(child: ListTile(title: Text("Related".tl))),
SliverGridComics(comics: comic.recommend!),
],
);
}
Widget buildComments() {
if (comic.comments == null || comic.comments!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return _CommentsPart(
comments: comic.comments!,
showMore: showComments,
return _CommentsPart(comments: comic.comments!, showMore: showComments);
}
void _viewCover(BuildContext context) {
final imageProvider = CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
context.to(
() => _CoverViewer(
imageProvider: imageProvider,
title: comic.title,
heroTag: "cover${widget.heroID}",
),
);
}
void _saveCover(BuildContext context) async {
try {
final imageProvider = CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<Uint8List>();
imageStream.addListener(
ImageStreamListener((ImageInfo info, bool _) async {
final byteData = await info.image.toByteData(
format: ImageByteFormat.png,
);
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
}
}),
);
final data = await completer.future;
final fileType = detectFileType(data);
await saveFile(filename: "cover${fileType.ext}", data: data);
} catch (e) {
if (context.mounted) {
context.showMessage(message: "Error".tl);
}
}
}
}
@@ -786,20 +883,21 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
itemCount: widget.eps.length,
itemBuilder: (context, i) {
return CheckboxListTile(
title: Text(widget.eps[i]),
value: selected.contains(i) ||
widget.downloadedEps.contains(i),
onChanged: widget.downloadedEps.contains(i)
? null
: (v) {
setState(() {
if (selected.contains(i)) {
selected.remove(i);
} else {
selected.add(i);
}
});
title: Text(widget.eps[i]),
value:
selected.contains(i) || widget.downloadedEps.contains(i),
onChanged: widget.downloadedEps.contains(i)
? null
: (v) {
setState(() {
if (selected.contains(i)) {
selected.remove(i);
} else {
selected.add(i);
}
});
},
);
},
),
),
@@ -807,9 +905,7 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
height: 50,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
),
top: BorderSide(color: context.colorScheme.outlineVariant),
),
),
child: Row(
@@ -874,8 +970,12 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
@override
Widget build(BuildContext context) {
Widget buildContainer(double? width, double? height,
{Color? color, double? radius}) {
Widget buildContainer(
double? width,
double? height, {
Color? color,
double? radius,
}) {
return Container(
height: height,
width: width,
@@ -917,13 +1017,9 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
if (context.width < changePoint)
Row(
children: [
Expanded(
child: buildContainer(null, 36, radius: 18),
),
Expanded(child: buildContainer(null, 36, radius: 18)),
const SizedBox(width: 16),
Expanded(
child: buildContainer(null, 36, radius: 18),
),
Expanded(child: buildContainer(null, 36, radius: 18)),
],
).paddingHorizontal(16),
const Divider(),
@@ -932,7 +1028,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
child: CircularProgressIndicator(
strokeWidth: 2.4,
).fixHeight(24).fixWidth(24),
)
),
],
),
);
@@ -942,11 +1038,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
Widget child;
if (cover != null) {
child = AnimatedImage(
image: CachedImageProvider(
cover!,
sourceKey: sourceKey,
cid: cid,
),
image: CachedImageProvider(cover!, sourceKey: sourceKey, cid: cid),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,

View File

@@ -1,5 +1,18 @@
part of 'comic_page.dart';
bool _shouldBlockComment(Comment comment) {
var blockedWords = appdata.settings["blockedCommentWords"] as List;
if (blockedWords.isEmpty) return false;
var content = comment.content.toLowerCase();
for (var word in blockedWords) {
if (content.contains(word.toString().toLowerCase())) {
return true;
}
}
return false;
}
class CommentsPage extends StatefulWidget {
const CommentsPage({
super.key,
@@ -36,8 +49,9 @@ class _CommentsPageState extends State<CommentsPage> {
_loading = false;
});
} else if (mounted) {
var filteredComments = res.data.where((c) => !_shouldBlockComment(c)).toList();
setState(() {
_comments = res.data;
_comments = filteredComments;
_loading = false;
maxPage = res.subData;
});
@@ -54,8 +68,9 @@ class _CommentsPageState extends State<CommentsPage> {
if (res.error) {
context.showMessage(message: res.errorMessage ?? "Unknown Error");
} else {
var filteredComments = res.data.where((c) => !_shouldBlockComment(c)).toList();
setState(() {
_comments!.addAll(res.data);
_comments!.addAll(filteredComments);
_page++;
if (maxPage == null && res.data.isEmpty) {
maxPage = _page;

View File

@@ -21,7 +21,7 @@ class _CommentsPartState extends State<_CommentsPart> {
@override
void initState() {
comments = widget.comments;
comments = widget.comments.where((c) => !_shouldBlockComment(c)).toList();
super.initState();
}

View File

@@ -0,0 +1,140 @@
part of 'comic_page.dart';
class _CoverViewer extends StatefulWidget {
const _CoverViewer({
required this.imageProvider,
required this.title,
required this.heroTag,
});
final ImageProvider imageProvider;
final String title;
final String heroTag;
@override
State<_CoverViewer> createState() => _CoverViewerState();
}
class _CoverViewerState extends State<_CoverViewer> {
bool isAppBarShow = true;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
child: Scaffold(
backgroundColor: context.colorScheme.surface,
body: Stack(
children: [
Positioned.fill(
child: PhotoView(
imageProvider: widget.imageProvider,
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 3.0,
backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface,
),
loadingBuilder: (context, event) => Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
value: event == null || event.expectedTotalBytes == null
? null
: event.cumulativeBytesLoaded /
event.expectedTotalBytes!,
),
),
),
onTapUp: (context, details, controllerValue) {
setState(() {
isAppBarShow = !isAppBarShow;
});
},
heroAttributes: PhotoViewHeroAttributes(tag: widget.heroTag),
),
),
AnimatedPositioned(
top: isAppBarShow ? 0 : -(context.padding.top + 52),
left: 0,
right: 0,
duration: const Duration(milliseconds: 180),
child: _buildAppBar(),
),
],
),
),
);
}
Widget _buildAppBar() {
return Material(
color: context.colorScheme.surface.toOpacity(0.72),
child: BlurEffect(
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.5,
),
),
),
height: 52,
child: Row(
children: [
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
},
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.title,
style: const TextStyle(fontSize: 18),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.save_alt),
onPressed: _saveCover,
),
const SizedBox(width: 8),
],
),
).paddingTop(context.padding.top),
),
);
}
void _saveCover() async {
try {
final imageStream = widget.imageProvider.resolve(
const ImageConfiguration(),
);
final completer = Completer<Uint8List>();
imageStream.addListener(
ImageStreamListener((ImageInfo info, bool _) async {
final byteData = await info.image.toByteData(
format: ImageByteFormat.png,
);
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
}
}),
);
final data = await completer.future;
final fileType = detectFileType(data);
await saveFile(filename: "cover_${widget.title}${fileType.ext}", data: data);
} catch (e) {
if (mounted) {
context.showMessage(message: "Error".tl);
}
}
}
}

View File

@@ -33,198 +33,122 @@ class _FavoritePanelState extends State<_FavoritePanel>
with SingleTickerProviderStateMixin {
late ComicSource comicSource;
late TabController tabController;
late bool hasNetwork;
late List<String> localFolders;
late List<String> added;
@override
void initState() {
comicSource = widget.type.comicSource!;
localFolders = LocalFavoritesManager().folderNames;
added = LocalFavoritesManager().find(widget.cid, widget.type);
hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
var initIndex = 0;
if (appdata.implicitData['favoritePanelIndex'] is int) {
initIndex = appdata.implicitData['favoritePanelIndex'];
}
initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0);
tabController = TabController(
initialIndex: initIndex,
length: hasNetwork ? 2 : 1,
vsync: this,
);
super.initState();
}
@override
void dispose() {
var currentIndex = tabController.index;
appdata.implicitData['favoritePanelIndex'] = currentIndex;
appdata.writeImplicitData();
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text("Favorite".tl),
appBar: Appbar(title: Text("Favorite".tl)),
body: _FavoriteList(
cid: widget.cid,
type: widget.type,
isFavorite: widget.isFavorite,
onFavorite: widget.onFavorite,
favoriteItem: widget.favoriteItem,
updateTime: widget.updateTime,
comicSource: comicSource,
hasNetwork: hasNetwork,
localFolders: localFolders,
added: added,
),
body: Column(
children: [
TabBar(
controller: tabController,
tabs: [
Tab(text: "Local".tl),
if (hasNetwork) Tab(text: "Network".tl),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
buildLocal(),
if (hasNetwork) buildNetwork(),
],
),
),
],
),
);
}
late List<String> localFolders;
late List<String> added;
var selectedLocalFolders = <String>{};
Widget buildLocal() {
var isRemove = selectedLocalFolders.isNotEmpty &&
added.contains(selectedLocalFolders.first);
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: localFolders.length + 1,
itemBuilder: (context, index) {
if (index == localFolders.length) {
return SizedBox(
height: 36,
child: Center(
child: TextButton(
onPressed: () {
newFolder().then((v) {
setState(() {
localFolders = LocalFavoritesManager().folderNames;
});
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 4),
Text("New Folder".tl)
],
),
),
),
);
}
var folder = localFolders[index];
var disabled = false;
if (selectedLocalFolders.isNotEmpty) {
if (added.contains(folder) &&
!added.contains(selectedLocalFolders.first)) {
disabled = true;
} else if (!added.contains(folder) &&
added.contains(selectedLocalFolders.first)) {
disabled = true;
}
}
return CheckboxListTile(
title: Row(
children: [
Text(folder),
const SizedBox(width: 8),
if (added.contains(folder))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
value: selectedLocalFolders.contains(folder),
onChanged: disabled
? null
: (v) {
setState(() {
if (v!) {
selectedLocalFolders.add(folder);
} else {
selectedLocalFolders.remove(folder);
}
});
},
);
},
),
),
Center(
child: FilledButton(
onPressed: () {
if (selectedLocalFolders.isEmpty) {
return;
}
if (isRemove) {
for (var folder in selectedLocalFolders) {
LocalFavoritesManager()
.deleteComicWithId(folder, widget.cid, widget.type);
}
widget.onFavorite(false, null);
} else {
for (var folder in selectedLocalFolders) {
LocalFavoritesManager().addComic(
folder,
widget.favoriteItem,
null,
widget.updateTime,
);
}
widget.onFavorite(true, null);
}
context.pop();
},
child: isRemove ? Text("Remove".tl) : Text("Add".tl),
).paddingVertical(8),
),
],
);
}
Widget buildNetwork() {
return _NetworkFavorites(
cid: widget.cid,
comicSource: comicSource,
isFavorite: widget.isFavorite,
onFavorite: (network) {
widget.onFavorite(null, network);
},
);
}
}
class _NetworkFavorites extends StatefulWidget {
const _NetworkFavorites({
class _FavoriteList extends StatefulWidget {
const _FavoriteList({
required this.cid,
required this.type,
required this.isFavorite,
required this.onFavorite,
required this.favoriteItem,
this.updateTime,
required this.comicSource,
required this.hasNetwork,
required this.localFolders,
required this.added,
});
final String cid;
final ComicType type;
final bool? isFavorite;
final void Function(bool?, bool?) onFavorite;
final FavoriteItem favoriteItem;
final String? updateTime;
final ComicSource comicSource;
final bool hasNetwork;
final List<String> localFolders;
final List<String> added;
@override
State<_FavoriteList> createState() => _FavoriteListState();
}
class _FavoriteListState extends State<_FavoriteList> {
@override
Widget build(BuildContext context) {
final localFavoritesFirst = appdata.settings['localFavoritesFirst'] ?? true;
final localSection = _LocalSection(
cid: widget.cid,
type: widget.type,
favoriteItem: widget.favoriteItem,
updateTime: widget.updateTime,
localFolders: widget.localFolders,
added: widget.added,
onFavorite: (local) {
widget.onFavorite(local, null);
},
);
final networkSection = widget.hasNetwork
? _NetworkSection(
cid: widget.cid,
comicSource: widget.comicSource,
isFavorite: widget.isFavorite,
onFavorite: (network) {
widget.onFavorite(null, network);
},
)
: null;
final divider = widget.hasNetwork
? Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: context.colorScheme.outlineVariant.withValues(alpha: 0.3),
)
: null;
return ListView(
children: [
if (localFavoritesFirst) ...[
localSection,
if (widget.hasNetwork) ...[divider!, networkSection!],
] else ...[
if (widget.hasNetwork) ...[networkSection!, divider!],
localSection,
],
],
);
}
}
class _NetworkSection extends StatefulWidget {
const _NetworkSection({
required this.cid,
required this.comicSource,
required this.isFavorite,
@@ -232,82 +156,56 @@ class _NetworkFavorites extends StatefulWidget {
});
final String cid;
final ComicSource comicSource;
final bool? isFavorite;
final void Function(bool) onFavorite;
@override
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
State<_NetworkSection> createState() => _NetworkSectionState();
}
class _NetworkFavoritesState extends State<_NetworkFavorites> {
@override
Widget build(BuildContext context) {
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
}
class _NetworkSectionState extends State<_NetworkSection> {
bool isLoading = false;
Widget buildSingleFolder() {
var isFavorite = widget.isFavorite ?? false;
return Column(
children: [
Expanded(
child: Center(
child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl),
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
setState(() {
isLoading = true;
});
var res = await widget.comicSource.favoriteData!
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
if (res.success) {
widget.onFavorite(!isFavorite);
context.pop();
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl);
} else {
setState(() {
isLoading = false;
});
context.showMessage(message: res.errorMessage!);
}
},
child: isFavorite ? Text("Remove".tl) : Text("Add".tl),
).paddingVertical(8),
),
],
);
}
Map<String, String>? folders;
var addedFolders = <String>{};
var isLoadingFolders = true;
bool? localIsFavorite;
final Map<String, bool> _itemLoading = {};
late List<double> _skeletonWidths;
// for network favorites, only one selection is allowed
String? selected;
@override
void initState() {
super.initState();
localIsFavorite = widget.isFavorite;
_skeletonWidths = List.generate(3, (_) => 0.3 + math.Random().nextDouble() * 0.5);
if (widget.comicSource.favoriteData!.loadFolders != null) {
loadFolders();
} else {
isLoadingFolders = false;
}
}
void loadFolders() async {
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
if (res.error) {
context.showMessage(message: res.errorMessage!);
setState(() {
isLoadingFolders = false;
});
} else {
folders = res.data;
if (res.subData is List) {
addedFolders = List<String>.from(res.subData).toSet();
final list = List<String>.from(res.subData);
if (list.isNotEmpty) {
addedFolders = list.toSet();
localIsFavorite = true;
} else {
addedFolders.clear();
localIsFavorite = false;
}
} else {
addedFolders.clear();
localIsFavorite = false;
}
setState(() {
isLoadingFolders = false;
@@ -315,118 +213,414 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
}
}
Widget buildMultiFolder() {
if (widget.isFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
return Column(
children: [
Expanded(
child: Center(
child: Text("Added to favorites".tl),
Widget _buildLoadingSkeleton() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
setState(() {
isLoading = true;
});
var res = await widget.comicSource.favoriteData!
.addOrDelFavorite!(widget.cid, '', false, null);
if (res.success) {
widget.onFavorite(false);
context.pop();
App.rootContext.showMessage(message: "Removed".tl);
} else {
setState(() {
isLoading = false;
});
context.showMessage(message: res.errorMessage!);
}
},
child: Text("Remove".tl),
).paddingVertical(8),
),
],
);
}
if (isLoadingFolders) {
loadFolders();
return const Center(child: CircularProgressIndicator());
} else {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: folders!.length,
itemBuilder: (context, index) {
var name = folders!.values.elementAt(index);
var id = folders!.keys.elementAt(index);
return CheckboxListTile(
title: Row(
children: [
Text(name),
const SizedBox(width: 8),
if (addedFolders.contains(id))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
Shimmer(
child: Column(
children: List.generate(3, (index) {
return ListTile(
title: Container(
height: 20,
width: double.infinity,
margin: const EdgeInsets.only(right: 16),
child: FractionallySizedBox(
widthFactor: _skeletonWidths[index],
alignment: Alignment.centerLeft,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(4),
),
),
),
value: selected == id,
onChanged: (v) {
setState(() {
selected = id;
});
},
);
},
),
),
trailing: Container(
height: 28,
width: 60 + (index * 2),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
),
);
}),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
if (selected == null) {
return;
}
setState(() {
isLoading = true;
});
var res =
await widget.comicSource.favoriteData!.addOrDelFavorite!(
widget.cid,
selected!,
!addedFolders.contains(selected!),
null,
);
if (res.success) {
context.showMessage(message: "Success".tl);
context.pop();
} else {
context.showMessage(message: res.errorMessage!);
setState(() {
isLoading = false;
});
}
},
child: selected != null && addedFolders.contains(selected!)
? Text("Remove".tl)
: Text("Add".tl),
).paddingVertical(8),
),
],
);
),
],
);
}
@override
Widget build(BuildContext context) {
if (isLoadingFolders) {
return _buildLoadingSkeleton();
}
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
if (isMultiFolder) {
return _buildMultiFolder();
} else {
return _buildSingleFolder();
}
}
Widget _buildSingleFolder() {
var isFavorite = localIsFavorite ?? false;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
ListTile(
title: Row(
children: [
Text("Network Favorites".tl),
const SizedBox(width: 8),
if (isFavorite)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
trailing: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _HoverButton(
isFavorite: isFavorite,
onTap: () async {
setState(() {
isLoading = true;
});
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
if (res.success) {
setState(() {
localIsFavorite = !isFavorite;
});
widget.onFavorite(!isFavorite);
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl,
);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
isLoading = false;
});
},
),
),
],
);
}
Widget _buildMultiFolder() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
...folders!.entries.map((entry) {
var name = entry.value;
var id = entry.key;
var isAdded = addedFolders.contains(id);
// When `singleFolderForSingleComic` is `false`, all add and remove buttons are clickable.
// When `singleFolderForSingleComic` is `true`, the remove button is always clickable,
// while the add button is only clickable if the comic has not been added to any list.
var enabled = !(widget.comicSource.favoriteData!.singleFolderForSingleComic && addedFolders.isNotEmpty && !isAdded);
return ListTile(
title: Row(
children: [
Text(name),
const SizedBox(width: 8),
if (isAdded)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
trailing: (_itemLoading[id] ?? false)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _HoverButton(
isFavorite: isAdded,
enabled: enabled,
onTap: () async {
setState(() {
_itemLoading[id] = true;
});
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, id, !isAdded, null);
if (res.success) {
// Invalidate network cache so folders/pages reload with fresh data
NetworkCacheManager().clear();
setState(() {
if (isAdded) {
addedFolders.remove(id);
} else {
addedFolders.add(id);
}
// sync local flag for single-folder-per-comic logic and parent
localIsFavorite = addedFolders.isNotEmpty;
});
// notify parent so page state updates when closing and reopening panel
widget.onFavorite(addedFolders.isNotEmpty);
context.showMessage(message: "Success".tl);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
_itemLoading[id] = false;
});
},
),
);
}),
],
);
}
}
class _LocalSection extends StatefulWidget {
const _LocalSection({
required this.cid,
required this.type,
required this.favoriteItem,
this.updateTime,
required this.localFolders,
required this.added,
required this.onFavorite,
});
final String cid;
final ComicType type;
final FavoriteItem favoriteItem;
final String? updateTime;
final List<String> localFolders;
final List<String> added;
final void Function(bool) onFavorite;
@override
State<_LocalSection> createState() => _LocalSectionState();
}
class _LocalSectionState extends State<_LocalSection> {
late List<String> localFolders;
late Set<String> localAdded;
@override
void initState() {
super.initState();
localFolders = widget.localFolders;
localAdded = widget.added.toSet();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Local Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
...localFolders.map((folder) {
var isAdded = localAdded.contains(folder);
return ListTile(
title: Row(
children: [
Text(folder),
const SizedBox(width: 8),
if (isAdded)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
trailing: _HoverButton(
isFavorite: isAdded,
onTap: () {
if (isAdded) {
LocalFavoritesManager().deleteComicWithId(
folder,
widget.cid,
widget.type,
);
setState(() {
localAdded.remove(folder);
});
widget.onFavorite(false);
} else {
LocalFavoritesManager().addComic(
folder,
widget.favoriteItem,
null,
widget.updateTime,
);
setState(() {
localAdded.add(folder);
});
widget.onFavorite(true);
}
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
},
),
);
}),
// New folder button
ListTile(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 4),
Text("New Folder".tl),
],
),
onTap: () {
newFolder().then((v) {
setState(() {
localFolders = LocalFavoritesManager().folderNames;
});
});
},
),
],
);
}
}
class _HoverButton extends StatefulWidget {
const _HoverButton({
required this.isFavorite,
required this.onTap,
this.enabled = true,
});
final bool isFavorite;
final VoidCallback onTap;
final bool enabled;
@override
State<_HoverButton> createState() => _HoverButtonState();
}
class _HoverButtonState extends State<_HoverButton> {
bool isHovered = false;
@override
Widget build(BuildContext context) {
final removeColor = context.colorScheme.error;
final removeHoverColor = Color.lerp(removeColor, Colors.black, 0.2)!;
final addColor = context.colorScheme.primary;
final addHoverColor = Color.lerp(addColor, Colors.black, 0.2)!;
return MouseRegion(
onEnter: widget.enabled ? (_) => setState(() => isHovered = true) : null,
onExit: widget.enabled ? (_) => setState(() => isHovered = false) : null,
child: GestureDetector(
onTap: widget.enabled ? widget.onTap : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: widget.enabled
? (widget.isFavorite
? (isHovered ? removeHoverColor : removeColor)
: (isHovered ? addHoverColor : addColor))
: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.isFavorite ? "Remove".tl : "Add".tl,
style: ts.s12.copyWith(
color: widget.enabled
? context.colorScheme.onPrimary
: context.colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}

View File

@@ -18,6 +18,57 @@ import 'package:venera/utils/translations.dart';
class ComicSourcePage extends StatelessWidget {
const ComicSourcePage({super.key});
static Future<void> update(
ComicSource source, [
bool showLoading = true,
]) async {
if (!source.url.isURL) {
if (showLoading) {
App.rootContext.showMessage(message: "Invalid url config");
return;
} else {
throw Exception("Invalid url config");
}
}
ComicSourceManager().remove(source.key);
bool cancel = false;
LoadingDialogController? controller;
if (showLoading) {
controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
}
try {
var res = await AppDio().get<String>(
source.url,
options: Options(
responseType: ResponseType.plain,
headers: {"cache-time": "no"},
),
);
if (cancel) return;
controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await io.File(source.filePath).writeAsString(res.data!);
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
ComicSourceManager().availableUpdates.remove(source.key);
}
} catch (e) {
if (cancel) return;
if (showLoading) {
App.rootContext.showMessage(message: e.toString());
} else {
rethrow;
}
}
await ComicSourceManager().reload();
if (showLoading) {
App.forceRebuild();
}
}
static Future<int> checkComicSourceUpdate() async {
if (ComicSource.all().isEmpty) {
return 0;
@@ -51,9 +102,7 @@ class ComicSourcePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: const _Body(),
);
return Scaffold(body: const _Body());
}
}
@@ -87,10 +136,7 @@ class _BodyState extends State<_Body> {
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text('Comic Source'.tl),
style: AppbarStyle.shadow,
),
SliverAppbar(title: Text('Comic Source'.tl), style: AppbarStyle.shadow),
buildCard(context),
for (var source in ComicSource.all())
_SliverComicSource(
@@ -109,9 +155,7 @@ class _BodyState extends State<_Body> {
showConfirmDialog(
context: App.rootContext,
title: "Delete".tl,
content: "Delete comic source '@n' ?".tlParams({
"n": source.name,
}),
content: "Delete comic source '@n' ?".tlParams({"n": source.name}),
btnColor: context.colorScheme.error,
onConfirm: () {
var file = File(source.filePath);
@@ -133,14 +177,16 @@ class _BodyState extends State<_Body> {
title: const Text("Reload Configs"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("cancel")),
onPressed: () => Navigator.pop(context),
child: const Text("cancel"),
),
TextButton(
onPressed: () async {
await ComicSourceManager().reload();
App.forceRebuild();
},
child: const Text("continue")),
onPressed: () async {
await ComicSourceManager().reload();
App.forceRebuild();
},
child: const Text("continue"),
),
],
),
);
@@ -157,49 +203,11 @@ class _BodyState extends State<_Body> {
);
}
static Future<void> update(ComicSource source,
[bool showLoading = true]) async {
if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config");
return;
}
ComicSourceManager().remove(source.key);
bool cancel = false;
LoadingDialogController? controller;
if (showLoading) {
controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
}
try {
var res = await AppDio().get<String>(source.url,
options: Options(responseType: ResponseType.plain));
if (cancel) return;
controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!);
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
ComicSourceManager().availableUpdates.remove(source.key);
}
} catch (e) {
if (cancel) return;
App.rootContext.showMessage(message: e.toString());
}
await ComicSourceManager().reload();
App.forceRebuild();
void update(ComicSource source, [bool showLoading = true]) {
ComicSourcePage.update(source, showLoading);
}
Widget buildCard(BuildContext context) {
Widget buildButton(
{required Widget child, required VoidCallback onPressed}) {
return Button.normal(
onPressed: onPressed,
child: child,
).fixHeight(32);
}
return SliverToBoxAdapter(
child: SizedBox(
width: double.infinity,
@@ -213,47 +221,46 @@ class _BodyState extends State<_Body> {
),
TextField(
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton(
onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check))),
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton(
onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check),
),
),
onChanged: (value) {
url = value;
},
onSubmitted: handleAddSource,
).paddingHorizontal(16).paddingBottom(8),
ListTile(
title: Text("Comic Source list".tl),
trailing: buildButton(
child: Text("View".tl),
onPressed: () {
showPopUpWidget(
App.rootContext,
_ComicSourceList(handleAddSource),
);
},
),
),
ListTile(
title: Text("Use a config file".tl),
trailing: buildButton(
onPressed: _selectFile,
child: Text("Select".tl),
),
),
ListTile(
title: Text("Help".tl),
trailing: buildButton(
onPressed: help,
child: Text("Open".tl),
),
),
ListTile(
title: Text("Check updates".tl),
trailing: _CheckUpdatesButton(),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
icon: Icon(Icons.article_outlined),
label: Text("Comic Source list".tl),
onPressed: () {
showPopUpWidget(
App.rootContext,
_ComicSourceList(handleAddSource),
);
},
),
FilledButton.tonalIcon(
icon: Icon(Icons.file_open_outlined),
label: Text("Use a config file".tl),
onPressed: _selectFile,
),
FilledButton.tonalIcon(
icon: Icon(Icons.help_outline),
label: Text("Help".tl),
onPressed: help,
),
_CheckUpdatesButton(),
],
).paddingHorizontal(12).paddingVertical(8),
const SizedBox(height: 8),
],
),
@@ -277,7 +284,8 @@ class _BodyState extends State<_Body> {
void help() {
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
);
}
Future<void> handleAddSource(String url) async {
@@ -288,11 +296,19 @@ class _BodyState extends State<_Body> {
splits.removeWhere((element) => element == "");
var fileName = splits.last;
bool cancel = false;
var controller = showLoadingDialog(App.rootContext,
onCancel: () => cancel = true, barrierDismissible: false);
var controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
try {
var res = await AppDio()
.get<String>(url, options: Options(responseType: ResponseType.plain));
var res = await AppDio().get<String>(
url,
options: Options(
responseType: ResponseType.plain,
headers: {"cache-time": "no"},
),
);
if (cancel) return;
controller.close();
await addSource(res.data!, fileName);
@@ -322,127 +338,178 @@ class _ComicSourceList extends StatefulWidget {
}
class _ComicSourceListState extends State<_ComicSourceList> {
bool loading = true;
List? json;
bool changed = false;
var controller = TextEditingController();
void load() async {
var dio = AppDio();
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
if (res.statusCode != 200) {
context.showMessage(message: "Network error".tl);
if (json != null) {
setState(() {
json = null;
});
}
if (controller.text.isEmpty) {
setState(() {
json = [];
});
return;
}
setState(() {
json = jsonDecode(res.data!);
loading = false;
});
var dio = AppDio();
try {
var res = await dio.get<String>(controller.text);
if (res.statusCode != 200) {
throw "error";
}
if (mounted) {
setState(() {
json = jsonDecode(res.data!);
});
}
} catch (e) {
context.showMessage(message: "Network error".tl);
if (mounted) {
setState(() {
json = [];
});
}
}
}
@override
void initState() {
super.initState();
controller.text = appdata.settings['comicSourceListUrl'];
load();
}
@override
void dispose() {
super.dispose();
if (changed) {
appdata.settings['comicSourceListUrl'] = controller.text;
appdata.saveData();
}
}
@override
Widget build(BuildContext context) {
return PopUpWidgetScaffold(
title: "Comic Source".tl,
tailing: [
IconButton(
icon: Icon(Icons.settings),
onPressed: () async {
await showInputDialog(
context: context,
title: "Set comic source list url".tl,
initialValue: appdata.settings['comicSourceListUrl'],
onConfirm: (value) {
appdata.settings['comicSourceListUrl'] = value;
appdata.saveData();
setState(() {
loading = true;
json = null;
});
return null;
},
);
},
)
],
body: buildBody(),
);
return PopUpWidgetScaffold(title: "Comic Source".tl, body: buildBody());
}
Widget buildBody() {
if (loading) {
load();
return const Center(child: CircularProgressIndicator());
} else {
var currentKey = ComicSource.all().map((e) => e.key).toList();
return ListView.builder(
itemCount: json!.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: context.colorScheme.primaryContainer,
var currentKey = ComicSource.all().map((e) => e.key).toList();
return ListView.builder(
itemCount: (json?.length ?? 1) + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
child: Row(
children: [
const Icon(Icons.info_outline),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Do not report any issues related to sources to App repo.".tl),
Text("Click the setting icon to change the source list url.".tl),
],
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.source_outlined),
title: Text("Repo URL".tl),
),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
),
],
),
);
}
index--;
var key = json![index]["key"];
var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8)
: Button.filled(
child: Text("Add".tl),
onPressed: () async {
var fileName = json![index]["fileName"];
var url = json![index]["url"];
if (url == null || !(url.toString()).isURL) {
var listUrl =
appdata.settings['comicSourceListUrl'] as String;
if (listUrl
.replaceFirst("https://", "")
.replaceFirst("http://", "")
.contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
onChanged: (value) {
changed = true;
},
).fixHeight(32);
var description = json![index]["version"];
if (json![index]["description"] != null) {
description = "$description\n${json![index]["description"]}";
}
return ListTile(
title: Text(json![index]["name"]),
subtitle: Text(description),
trailing: action,
).paddingHorizontal(16).paddingBottom(8),
Text(
"The URL should point to a 'index.json' file".tl,
).paddingLeft(16),
Text(
"Do not report any issues related to sources to App repo.".tl,
).paddingLeft(16),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
);
},
child: Text("Help".tl),
),
FilledButton.tonal(
onPressed: load,
child: Text("Refresh".tl),
),
const SizedBox(width: 16),
],
),
const SizedBox(height: 16),
],
),
);
},
);
}
}
if (index == 1 && json == null) {
return Center(
child: CircularProgressIndicator(
strokeWidth: 2,
).fixWidth(24).fixHeight(24),
);
}
index--;
var key = json![index]["key"];
var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8)
: Button.filled(
child: Text("Add".tl),
onPressed: () async {
var fileName = json![index]["fileName"];
var url = json![index]["url"];
if (url == null || !(url.toString()).isURL) {
var listUrl =
appdata.settings['comicSourceListUrl'] as String;
if (listUrl
.replaceFirst("https://", "")
.replaceFirst("http://", "")
.contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
},
).fixHeight(32);
var description = json![index]["version"];
if (json![index]["description"] != null) {
description = "$description\n${json![index]["description"]}";
}
return ListTile(
title: Text(json![index]["name"]),
subtitle: Text(description),
trailing: action,
);
},
);
}
}
@@ -510,8 +577,7 @@ void _addAllPagesWithComicSource(ComicSource source) {
!networkFavorites.contains(source.favoriteData!.key)) {
networkFavorites.add(source.favoriteData!.key);
}
if (source.searchPageData != null &&
!searchPages.contains(source.key)) {
if (source.searchPageData != null && !searchPages.contains(source.key)) {
searchPages.add(source.key);
}
@@ -553,15 +619,10 @@ class __EditFilePageState extends State<_EditFilePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text("Edit".tl),
),
appBar: Appbar(title: Text("Edit".tl)),
body: Column(
children: [
Container(
height: 0.6,
color: context.colorScheme.outlineVariant,
),
Container(height: 0.6, color: context.colorScheme.outlineVariant),
Expanded(
child: CodeEditor(
initialValue: current,
@@ -602,9 +663,11 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
}
void showUpdateDialog() async {
var text = ComicSourceManager().availableUpdates.entries.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}";
}).join("\n");
var text = ComicSourceManager().availableUpdates.entries
.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}";
})
.join("\n");
bool doUpdate = false;
await showDialog(
context: App.rootContext,
@@ -636,7 +699,7 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
for (var key in shouldUpdate) {
var source = ComicSource.find(key)!;
await _BodyState.update(source, false);
await ComicSourcePage.update(source, false);
current++;
loadingController.setProgress(current / total);
}
@@ -649,11 +712,17 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
@override
Widget build(BuildContext context) {
return Button.normal(
return FilledButton.tonalIcon(
icon: isLoading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(Icons.update),
label: Text("Check updates".tl),
onPressed: check,
isLoading: isLoading,
child: Text("Check".tl),
).fixHeight(32);
);
}
}
@@ -742,10 +811,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
child: ListTile(
title: Row(
children: [
Text(
source.name,
style: ts.s18,
),
Text(source.name, style: ts.s18),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
@@ -778,7 +844,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
style: const TextStyle(fontSize: 13),
),
),
).paddingLeft(4)
).paddingLeft(4),
],
),
trailing: Row(
@@ -823,15 +889,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
),
),
SliverToBoxAdapter(
child: Column(
children: buildSourceSettings().toList(),
),
),
SliverToBoxAdapter(
child: Column(
children: _buildAccount().toList(),
),
child: Column(children: buildSourceSettings().toList()),
),
SliverToBoxAdapter(child: Column(children: _buildAccount().toList())),
],
);
}
@@ -857,8 +917,10 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
}
}
} else {
current = item.value['options']
.firstWhere((e) => e['value'] == current)['text'] ??
current =
item.value['options'].firstWhere(
(e) => e['value'] == current,
)['text'] ??
current;
}
yield ListTile(
@@ -866,8 +928,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: Select(
current: (current as String).ts(source.key),
values: (item.value['options'] as List)
.map<String>((e) =>
((e['text'] ?? e['value']) as String).ts(source.key))
.map<String>(
(e) => ((e['text'] ?? e['value']) as String).ts(source.key),
)
.toList(),
onTap: (i) {
source.data['settings'][key] =
@@ -895,8 +958,11 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
subtitle:
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
current,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
@@ -937,10 +1003,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: const Icon(Icons.arrow_right),
onTap: () async {
await context.to(
() => _LoginPage(
config: source.account!,
source: source,
),
() => _LoginPage(config: source.account!, source: source),
);
source.saveData();
setState(() {});
@@ -986,9 +1049,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: loading
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
);
@@ -1029,9 +1090,7 @@ class _LoginPageState extends State<_LoginPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const Appbar(
title: Text(''),
),
appBar: const Appbar(title: Text('')),
body: Center(
child: Container(
padding: const EdgeInsets.all(16),
@@ -1159,8 +1218,9 @@ class _LoginPageState extends State<_LoginPage> {
setState(() {
loading = true;
});
var cookies =
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
var cookies = widget.config.cookieFields!
.map((e) => _cookies[e] ?? '')
.toList();
widget.config.validateCookies!(cookies).then((value) {
if (value) {
widget.source.data['account'] = 'ok';
@@ -1185,6 +1245,15 @@ class _LoginPageState extends State<_LoginPage> {
if (widget.config.checkLoginStatus != null &&
widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
var localStorageItems = await c.webStorage.localStorage.getItems();
var mappedLocalStorage = <String, dynamic>{};
for (var item in localStorageItems) {
if (item.key != null) {
mappedLocalStorage[item.key!] = item.value;
}
}
widget.source.data['_localStorage'] = mappedLocalStorage;
await widget.source.saveData();
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
@@ -1246,6 +1315,20 @@ class _LoginPageState extends State<_LoginPage> {
Uri.parse(url),
cookies,
);
var localStorageJson = await webview.evaluateJavascript(
"JSON.stringify(window.localStorage);",
);
var localStorage = <String, dynamic>{};
try {
var decoded = jsonDecode(localStorageJson ?? '');
if (decoded is Map<String, dynamic>) {
localStorage = decoded;
}
} catch (e) {
Log.error("ComicSourcePage", "Failed to parse localStorage JSON\n$e");
}
widget.source.data['_localStorage'] = localStorage;
await widget.source.saveData();
success = true;
widget.config.onLoginWithWebviewSuccess?.call();
webview.close();

View File

@@ -133,7 +133,7 @@ void addFavorite(List<Comic> comics) {
}
Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var comics = LocalFavoritesManager().getAllComics(folder);
var comics = LocalFavoritesManager().getFolderComics(folder);
Future<void> updateSingleComic(int index) async {
int retry = 3;

View File

@@ -11,21 +11,25 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/network/cache.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/opencc.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
part 'favorite_actions.dart';
part 'side_bar.dart';
part 'local_favorites_page.dart';
part 'network_favorites_page.dart';
part 'local_search_page.dart';
const _kLeftBarWidth = 256.0;
@@ -65,6 +69,11 @@ class _FavoritesPageState extends State<FavoritesPage> {
folder = data['name'];
isNetwork = data['isNetwork'] ?? false;
}
if (folder != null
&& !isNetwork
&& !LocalFavoritesManager().existsFolder(folder!)) {
folder = null;
}
super.initState();
}

View File

@@ -1,5 +1,11 @@
part of 'favorites_page.dart';
const _localAllFolderLabel = '^_^[%local_all%]^_^';
/// If the number of comics in a folder exceeds this limit, it will be
/// fetched asynchronously.
const _asyncDataFetchLimit = 500;
class _LocalFavoritesPage extends StatefulWidget {
const _LocalFavoritesPage({required this.folder, super.key});
@@ -24,6 +30,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
late List<String> added = [];
String keyword = "";
bool searchHasUpper = false;
bool searchMode = false;
@@ -31,25 +38,159 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
int? lastSelectedIndex;
void updateComics() {
if (keyword.isEmpty) {
setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder);
});
} else {
setState(() {
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword);
});
}
bool get isAllFolder => widget.folder == _localAllFolderLabel;
LocalFavoritesManager get manager => LocalFavoritesManager();
bool isLoading = false;
late String readFilterSelect;
var searchResults = <FavoriteItem>[];
void updateSearchResult() {
setState(() {
if (keyword.trim().isEmpty) {
searchResults = comics;
} else {
searchResults = [];
for (var comic in comics) {
if (matchKeyword(keyword, comic) ||
matchKeywordT(keyword, comic) ||
matchKeywordS(keyword, comic)) {
searchResults.add(comic);
}
}
}
});
}
void updateComics() {
if (isLoading) return;
if (isAllFolder) {
var totalComics = manager.totalComics;
if (totalComics < _asyncDataFetchLimit) {
comics = manager.getAllComics();
} else {
isLoading = true;
manager
.getAllComicsAsync()
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
} else {
var folderComics = manager.folderComics(widget.folder);
if (folderComics < _asyncDataFetchLimit) {
comics = manager.getFolderComics(widget.folder);
} else {
isLoading = true;
manager
.getFolderComicsAsync(widget.folder)
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
}
setState(() {});
}
List<FavoriteItem> filterComics(List<FavoriteItem> curComics) {
return curComics.where((comic) {
var history =
HistoryManager().find(comic.id, ComicType(comic.sourceKey.hashCode));
if (readFilterSelect == "UnCompleted") {
return history == null || history.page != history.maxPage;
} else if (readFilterSelect == "Completed") {
return history != null && history.page == history.maxPage;
}
return true;
}).toList();
}
bool matchKeyword(String keyword, FavoriteItem comic) {
var list = keyword.split(" ");
for (var k in list) {
if (k.isEmpty) continue;
if (checkKeyWordMatch(k, comic.title, false)) {
continue;
} else if (comic.subtitle != null && checkKeyWordMatch(k, comic.subtitle!, false)) {
continue;
} else if (comic.tags.any((tag) {
if (checkKeyWordMatch(k, tag, true)) {
return true;
} else if (tag.contains(':') && checkKeyWordMatch(k, tag.split(':')[1], true)) {
return true;
} else if (App.locale.languageCode != 'en' &&
checkKeyWordMatch(k, tag.translateTagsToCN, true)) {
return true;
}
return false;
})) {
continue;
} else if (checkKeyWordMatch(k, comic.author, true)) {
continue;
}
return false;
}
return true;
}
bool checkKeyWordMatch(String keyword, String compare, bool needEqual) {
String temp = compare;
// 没有大写的话, 就转成小写比较, 避免搜索需要注意大小写
if (!searchHasUpper) {
temp = temp.toLowerCase();
}
if (needEqual) {
return keyword == temp;
}
return temp.contains(keyword);
}
// Convert keyword to traditional Chinese to match comics
bool matchKeywordT(String keyword, FavoriteItem comic) {
if (!OpenCC.hasChineseSimplified(keyword)) {
return false;
}
keyword = OpenCC.simplifiedToTraditional(keyword);
return matchKeyword(keyword, comic);
}
// Convert keyword to simplified Chinese to match comics
bool matchKeywordS(String keyword, FavoriteItem comic) {
if (!OpenCC.hasChineseTraditional(keyword)) {
return false;
}
keyword = OpenCC.traditionalToSimplified(keyword);
return matchKeyword(keyword, comic);
}
@override
void initState() {
readFilterSelect = appdata.implicitData["local_favorites_read_filter"] ??
readFilterList[0];
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
comics = LocalFavoritesManager().getAllComics(widget.folder);
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a;
networkFolder = b;
if (!isAllFolder) {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a;
networkFolder = b;
} else {
networkSource = null;
networkFolder = null;
}
comics = [];
updateComics();
LocalFavoritesManager().addListener(updateComics);
super.initState();
}
@@ -62,16 +203,33 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
void selectAll() {
setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
if (searchMode) {
selectedComics = searchResults.asMap().map((k, v) => MapEntry(v, true));
} else {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
}
});
}
void invertSelection() {
setState(() {
comics.asMap().forEach((k, v) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
});
selectedComics.removeWhere((k, v) => !v);
if (searchMode) {
for (var c in searchResults) {
if (selectedComics.containsKey(c)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
} else {
for (var c in comics) {
if (selectedComics.containsKey(c)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
}
});
}
@@ -113,6 +271,11 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
@override
Widget build(BuildContext context) {
var title = favPage.folder ?? "Unselected".tl;
if (title == _localAllFolderLabel) {
title = "All".tl;
}
Widget body = SmoothCustomScrollView(
controller: scrollController,
slivers: [
@@ -135,10 +298,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
onTap: context.width < _kTwoPanelChangeWidth
? favPage.showFolderSelector
: null,
child: Text(favPage.folder ?? "Unselected".tl),
child: Text(title),
),
actions: [
if (networkSource != null)
if (networkSource != null && !isAllFolder)
Tooltip(
message: "Sync".tl,
child: Flyout(
@@ -185,20 +348,48 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}),
),
),
Tooltip(
message: "Filter".tl,
child: IconButton(
icon: const Icon(Icons.sort_rounded),
color: readFilterSelect != readFilterList[0]
? context.colorScheme.primaryContainer
: null,
onPressed: () {
showDialog(
context: context,
builder: (context) {
return _LocalFavoritesFilterDialog(
initReadFilterSelect: readFilterSelect,
updateConfig: (readFilter) {
setState(() {
readFilterSelect = readFilter;
});
updateComics();
},
);
},
);
},
),
),
Tooltip(
message: "Search".tl,
child: IconButton(
icon: const Icon(Icons.search),
onPressed: () {
setState(() {
keyword = "";
searchMode = true;
updateSearchResult();
});
},
),
),
MenuButton(
entries: [
MenuEntry(
if (!isAllFolder)
MenuButton(
entries: [
MenuEntry(
icon: Icons.edit_outlined,
text: "Rename".tl,
onClick: () {
@@ -220,8 +411,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return null;
},
);
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.reorder,
text: "Reorder".tl,
onClick: () {
@@ -241,8 +433,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}
},
);
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.upload_file,
text: "Export".tl,
onClick: () {
@@ -253,8 +446,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
data: utf8.encode(json),
filename: "${widget.folder}.json",
);
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.update,
text: "Update Comics Info".tl,
onClick: () {
@@ -265,8 +459,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
});
}
});
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Folder".tl,
color: context.colorScheme.error,
@@ -284,9 +479,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
favPage.folderList?.updateFolders();
},
);
}),
],
),
},
),
],
),
],
)
else if (multiSelectMode)
@@ -310,14 +506,16 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
"Selected @c comics".tlParams({"c": selectedComics.length})),
actions: [
MenuButton(entries: [
MenuEntry(
icon: Icons.drive_file_move,
text: "Move to folder".tl,
onClick: () => favoriteOption('move')),
MenuEntry(
icon: Icons.copy,
text: "Copy to folder".tl,
onClick: () => favoriteOption('add')),
if (!isAllFolder)
MenuEntry(
icon: Icons.drive_file_move,
text: "Move to folder".tl,
onClick: () => favoriteOption('move')),
if (!isAllFolder)
MenuEntry(
icon: Icons.copy,
text: "Copy to folder".tl,
onClick: () => favoriteOption('add')),
MenuEntry(
icon: Icons.select_all,
text: "Select All".tl,
@@ -330,22 +528,23 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: Icons.flip,
text: "Invert Selection".tl,
onClick: invertSelection),
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Comic".tl,
color: context.colorScheme.error,
onClick: () {
showConfirmDialog(
context: context,
title: "Delete".tl,
content: "Delete @c comics?"
.tlParams({"c": selectedComics.length}),
btnColor: context.colorScheme.error,
onConfirm: () {
_deleteComicWithId();
},
);
}),
if (!isAllFolder)
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Comic".tl,
color: context.colorScheme.error,
onClick: () {
showConfirmDialog(
context: context,
title: "Delete".tl,
content: "Delete @c comics?"
.tlParams({"c": selectedComics.length}),
btnColor: context.colorScheme.error,
onConfirm: () {
_deleteComicWithId();
},
);
}),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
@@ -366,6 +565,32 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
);
},
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.chrome_reader_mode_outlined,
text: "Read".tl,
onClick: () {
final c = selectedComics.keys.first as FavoriteItem;
App.rootContext.to(() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
)
);
},
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.arrow_forward_ios,
text: "Jump to Detail".tl,
onClick: () {
final c = selectedComics.keys.first as FavoriteItem;
App.mainNavigatorKey?.currentContext?.to(() => ComicPage(
id: c.id,
sourceKey: c.sourceKey,
)
);
},
),
]),
],
)
@@ -380,9 +605,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
searchMode = false;
keyword = "";
updateComics();
setState(() {
searchMode = false;
});
});
},
),
@@ -391,131 +616,147 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
autofocus: true,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
border: UnderlineInputBorder(),
),
onChanged: (v) {
keyword = v;
updateComics();
searchHasUpper = keyword.contains(RegExp(r'[A-Z]'));
updateSearchResult();
},
),
).paddingBottom(8).paddingRight(8),
),
SliverGridComics(
comics: comics,
selections: selectedComics,
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
if (isLoading)
SliverToBoxAdapter(
child: SizedBox(
height: 200,
child: const Center(
child: CircularProgressIndicator(),
),
MenuEntry(
icon: Icons.check,
text: "Select".tl,
onClick: () {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
}
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
onClick: () {
downloadComic(c as FavoriteItem);
context.showMessage(
message: "Download started".tl,
);
},
),
if (appdata.settings["onClickFavorite"] == "viewDetail")
),
)
else
SliverGridComics(
comics: searchMode ? searchResults : filterComics(comics),
selections: selectedComics,
menuBuilder: (c) {
return [
if (!isAllFolder)
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
),
MenuEntry(
icon: Icons.menu_book_outlined,
text: "Read".tl,
icon: Icons.check,
text: "Select".tl,
onClick: () {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
}
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
onClick: () {
downloadComic(c as FavoriteItem);
context.showMessage(
message: "Download started".tl,
);
},
),
];
},
onTap: (c) {
if (multiSelectMode) {
setState(() {
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
} else {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
}
},
onLongPressed: (c) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
} else {
if (lastSelectedIndex != null) {
int start = lastSelectedIndex!;
int end = comics.indexOf(c as FavoriteItem);
if (start > end) {
int temp = start;
start = end;
end = temp;
if (appdata.settings["onClickFavorite"] == "viewDetail")
MenuEntry(
icon: Icons.menu_book_outlined,
text: "Read".tl,
onClick: () {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
)
);
},
),
];
},
onTap: (c, heroID) {
if (multiSelectMode) {
setState(() {
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
App.mainNavigatorKey?.currentContext?.to(
() => ComicPage(
id: c.id,
sourceKey: c.sourceKey,
cover: c.cover,
title: c.title,
heroID: heroID,
)
);
} else {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(id: c.id, sourceKey: c.sourceKey),
);
}
},
onLongPressed: (c, heroID) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
} else {
if (lastSelectedIndex != null) {
int start = lastSelectedIndex!;
int end = comics.indexOf(c as FavoriteItem);
if (start > end) {
int temp = start;
start = end;
end = temp;
}
for (int i = start; i <= end; i++) {
if (i == lastSelectedIndex) continue;
for (int i = start; i <= end; i++) {
if (i == lastSelectedIndex) continue;
var comic = comics[i];
if (selectedComics.containsKey(comic)) {
selectedComics.remove(comic);
} else {
selectedComics[comic] = true;
var comic = comics[i];
if (selectedComics.containsKey(comic)) {
selectedComics.remove(comic);
} else {
selectedComics[comic] = true;
}
}
}
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
}
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
}
_checkExitSelectMode();
});
},
),
_checkExitSelectMode();
});
},
),
],
);
body = AppScrollBar(
@@ -638,32 +879,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return;
}
if (option == 'move') {
for (var c in selectedComics.keys) {
for (var s in selectedLocalFolders) {
LocalFavoritesManager().moveFavorite(
favPage.folder as String,
s,
c.id,
(c as FavoriteItem).type);
}
var comics = selectedComics.keys
.map((e) => e as FavoriteItem)
.toList();
for (var f in selectedLocalFolders) {
LocalFavoritesManager().batchMoveFavorites(
favPage.folder as String,
f,
comics,
);
}
} else {
for (var c in selectedComics.keys) {
for (var s in selectedLocalFolders) {
LocalFavoritesManager().addComic(
s,
FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
author: c.subtitle ?? '',
type: ComicType((c.sourceKey == 'local'
? 0
: c.sourceKey.hashCode)),
tags: c.tags ?? [],
),
);
}
var comics = selectedComics.keys
.map((e) => e as FavoriteItem)
.toList();
for (var f in selectedLocalFolders) {
LocalFavoritesManager().batchCopyFavorites(
favPage.folder as String,
f,
comics,
);
}
}
App.rootContext.pop();
@@ -699,13 +934,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}
void _deleteComicWithId() {
for (var c in selectedComics.keys) {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
}
var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList();
LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted);
_cancel();
}
}
@@ -725,7 +955,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
final _key = GlobalKey();
var reorderWidgetKey = UniqueKey();
final _scrollController = ScrollController();
late var comics = LocalFavoritesManager().getAllComics(widget.name);
late var comics = LocalFavoritesManager().getFolderComics(widget.name);
bool changed = false;
static int _floatToInt8(double x) {
@@ -746,7 +976,10 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
@override
void dispose() {
if (changed) {
LocalFavoritesManager().reorder(comics, widget.name);
// Delay to ensure navigation is completed
Future.delayed(const Duration(milliseconds: 200), () {
LocalFavoritesManager().reorder(comics, widget.name);
});
}
super.dispose();
}
@@ -781,27 +1014,31 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
appBar: Appbar(
title: Text("Reorder".tl),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showInfoDialog(
context: context,
title: "Reorder".tl,
content: "Long press and drag to reorder.".tl,
);
},
),
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
});
},
Tooltip(
message: "Information".tl,
child: IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showInfoDialog(
context: context,
title: "Reorder".tl,
content: "Long press and drag to reorder.".tl,
);
},
),
),
Tooltip(
message: "Reverse".tl,
child: IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
});
},
),
)
],
),
body: ReorderableBuilder<FavoriteItem>(
@@ -910,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

@@ -1,41 +0,0 @@
part of 'favorites_page.dart';
class LocalSearchPage extends StatefulWidget {
const LocalSearchPage({super.key});
@override
State<LocalSearchPage> createState() => _LocalSearchPageState();
}
class _LocalSearchPageState extends State<LocalSearchPage> {
String keyword = '';
var comics = <FavoriteItemWithFolderInfo>[];
late final SearchBarController controller;
@override
void initState() {
super.initState();
controller = SearchBarController(onSearch: (text) {
keyword = text;
comics = LocalFavoritesManager().search(keyword);
setState(() {});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(slivers: [
SliverSearchBar(controller: controller),
SliverGridComics(
comics: comics,
badgeBuilder: (c) {
return (c as FavoriteItemWithFolderInfo).folder;
},
),
]),
);
}
}

View File

@@ -36,6 +36,8 @@ Future<bool> _deleteComic(
favId,
);
if (res.success) {
// Invalidate network cache so next loads fetch fresh data
NetworkCacheManager().clear();
context.showMessage(message: "Deleted".tl);
result = true;
context.pop();
@@ -115,6 +117,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
child: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
// Force refresh bypassing cache
NetworkCacheManager().clear();
comicListKey.currentState!.refresh();
},
),

View File

@@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
folders = LocalFavoritesManager().folderNames;
findNetworkFolders();
appdata.settings.addListener(updateFolders);
LocalFavoritesManager().addListener(updateFolders);
super.initState();
}
@@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
void dispose() {
super.dispose();
appdata.settings.removeListener(updateFolders);
LocalFavoritesManager().removeListener(updateFolders);
}
@override
@@ -86,58 +88,14 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
padding: widget.withAppbar
? EdgeInsets.zero
: EdgeInsets.only(top: context.padding.top),
itemCount: folders.length + networkFolders.length + 2,
itemCount: folders.length + networkFolders.length + 3,
itemBuilder: (context, index) {
if (index == 0) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
Icons.local_activity,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.search,
text: 'Search'.tl,
onClick: () {
context.to(() => const LocalSearchPage());
},
),
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders =
LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders =
LocalFavoritesManager().folderNames;
});
});
},
),
],
),
],
).paddingHorizontal(16),
);
return buildLocalTitle();
}
index--;
if (index == 0) {
return buildLocalFolder(_localAllFolderLabel);
}
index--;
if (index < folders.length) {
@@ -145,38 +103,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
}
index -= folders.length;
if (index == 0) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
).paddingHorizontal(16),
);
return buildNetworkTitle();
}
index--;
return buildNetworkFolder(networkFolders[index]);
@@ -188,8 +115,95 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
);
}
Widget buildLocalTitle() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
Icons.local_activity,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
],
),
],
).paddingHorizontal(16),
);
}
Widget buildNetworkTitle() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
).paddingHorizontal(16),
);
}
Widget buildLocalFolder(String name) {
bool isSelected = name == favPage.folder && !favPage.isNetwork;
int count = 0;
if (name == _localAllFolderLabel) {
count = LocalFavoritesManager().totalComics;
} else {
count = LocalFavoritesManager().folderComics(name);
}
var folderName = name == _localAllFolderLabel
? "All".tl
: getFavoriteDataOrNull(name)?.title ?? name;
return InkWell(
onTap: () {
if (isSelected) {
@@ -214,7 +228,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
),
),
padding: const EdgeInsets.only(left: 16),
child: Text(name),
child: Row(
children: [
Expanded(
child: Text(folderName),
),
Container(
margin: EdgeInsets.only(right: 8),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(count.toString()),
),
],
),
),
);
}

View File

@@ -5,10 +5,10 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/translations.dart';
import '../foundation/global_state.dart';
import 'package:venera/foundation/follow_updates.dart';
class FollowUpdatesWidget extends StatefulWidget {
const FollowUpdatesWidget({super.key});
@@ -299,6 +299,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
);
}
updateFollowUpdatesUI();
appdata.saveData();
},
);
},
@@ -460,7 +461,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
message: "Updating comics...".tl,
);
await for (var progress in _updateFolder(folder, true)) {
await for (var progress in updateFolder(folder, true)) {
if (isCanceled) {
return;
}
@@ -497,7 +498,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
int updated = 0;
await for (var progress in _updateFolder(folder!, true)) {
await for (var progress in updateFolder(folder!, true)) {
if (isCanceled) {
return;
}
@@ -532,128 +533,6 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
Object? get key => 'FollowUpdatesPage';
}
class _UpdateProgress {
final int total;
final int current;
final int errors;
final int updated;
_UpdateProgress(this.total, this.current, this.errors, this.updated);
}
void _updateFolderBase(
String folder,
StreamController<_UpdateProgress> stream,
bool ignoreCheckTime,
) async {
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
int current = 0;
int errors = 0;
int updated = 0;
var futures = <Future>[];
const maxConcurrent = 5;
for (int i = 0; i < comics.length; i++) {
if (stream.isClosed) {
return;
}
if (!ignoreCheckTime) {
var lastCheckTime = comics[i].lastCheckTime;
if (lastCheckTime != null &&
DateTime.now().difference(lastCheckTime).inDays < 1) {
current++;
stream.add(_UpdateProgress(comics.length, current, errors, updated));
continue;
}
}
if (futures.length >= maxConcurrent) {
await Future.any(futures);
}
var future = () async {
int retries = 3;
while (true) {
try {
var c = comics[i];
var comicSource = c.type.comicSource;
if (comicSource == null) return;
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
var item = FavoriteItem(
id: c.id,
name: newInfo.title,
coverPath: newInfo.cover,
author: newInfo.subTitle ??
newInfo.tags['author']?.firstOrNull ??
c.author,
type: c.type,
tags: newTags,
);
LocalFavoritesManager().updateInfo(folder, item, false);
var updateTime = newInfo.findUpdateTime();
if (updateTime != null && updateTime != c.updateTime) {
LocalFavoritesManager().updateUpdateTime(
folder,
c.id,
c.type,
updateTime,
);
} else {
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
}
updated++;
return;
} catch (e, s) {
Log.error("Check Updates", e, s);
retries--;
if (retries == 0) {
errors++;
return;
}
} finally {
current++;
stream.add(_UpdateProgress(comics.length, current, errors, updated));
}
}
}();
future.then((_) {
futures.remove(future);
});
futures.add(future);
}
await Future.wait(futures);
if (updated > 0) {
LocalFavoritesManager().notifyChanges();
}
stream.close();
}
Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) {
var stream = StreamController<_UpdateProgress>();
_updateFolderBase(folder, stream, ignoreCheckTime);
return stream.stream;
}
/// Background service for checking updates
abstract class FollowUpdatesService {
static bool _isChecking = false;
@@ -683,7 +562,7 @@ abstract class FollowUpdatesService {
int updated = 0;
try {
await for (var progress in _updateFolder(folder, false)) {
await for (var progress in updateFolder(folder, false)) {
if (isCanceled) {
return;
}

View File

@@ -140,6 +140,14 @@ class _HistoryPageState extends State<HistoryPage> {
title: 'Clear History'.tl,
content: Text('Are you sure you want to clear your history?'.tl),
actions: [
Button.outlined(
onPressed: () {
HistoryManager().clearUnfavoritedHistory();
context.pop();
},
child: Text('Clear Unfavorited'.tl),
),
const SizedBox(width: 4),
Button.filled(
color: context.colorScheme.error,
onPressed: () {
@@ -203,7 +211,7 @@ class _HistoryPageState extends State<HistoryPage> {
selections: selectedComics,
onLongPressed: null,
onTap: multiSelectMode
? (c) {
? (c, heroID) {
setState(() {
if (selectedComics.containsKey(c as History)) {
selectedComics.remove(c);

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart';
import 'package:zip_flutter/zip_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
class LocalComicsPage extends StatefulWidget {
const LocalComicsPage({super.key});
@@ -69,39 +70,29 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Sort".tl,
content: Column(
children: [
RadioListTile<LocalSortType>(
title: Text("Name".tl),
value: LocalSortType.name,
groupValue: sortType,
onChanged: (v) {
setState(() {
sortType = v!;
});
},
),
RadioListTile<LocalSortType>(
title: Text("Date".tl),
value: LocalSortType.timeAsc,
groupValue: sortType,
onChanged: (v) {
setState(() {
sortType = v!;
});
},
),
RadioListTile<LocalSortType>(
title: Text("Date Desc".tl),
value: LocalSortType.timeDesc,
groupValue: sortType,
onChanged: (v) {
setState(() {
sortType = v!;
});
},
),
],
content: RadioGroup<LocalSortType>(
groupValue: sortType,
onChanged: (v) {
setState(() {
sortType = v ?? sortType;
});
},
child: Column(
children: [
RadioListTile<LocalSortType>(
title: Text("Name".tl),
value: LocalSortType.name,
),
RadioListTile<LocalSortType>(
title: Text("Date".tl),
value: LocalSortType.timeAsc,
),
RadioListTile<LocalSortType>(
title: Text("Date Desc".tl),
value: LocalSortType.timeDesc,
),
],
),
),
actions: [
FilledButton(
@@ -143,6 +134,14 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
addFavorite(selectedComics.keys.toList());
},
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.folder_open,
text: "Open Folder".tl,
onClick: () {
openComicFolder(selectedComics.keys.first);
},
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.chrome_reader_mode_outlined,
@@ -259,40 +258,52 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
else if (searchMode)
SliverAppbar(
leading: Tooltip(
message: "Cancel".tl,
message: multiSelectMode ? "Cancel".tl : "Cancel".tl,
child: IconButton(
icon: const Icon(Icons.close),
icon: multiSelectMode
? const Icon(Icons.close)
: const Icon(Icons.close),
onPressed: () {
setState(() {
searchMode = false;
keyword = "";
update();
});
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
} else {
setState(() {
searchMode = false;
keyword = "";
update();
});
}
},
),
),
title: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
),
onChanged: (v) {
keyword = v;
update();
},
),
title: multiSelectMode
? Text(selectedComics.length.toString())
: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
),
onChanged: (v) {
keyword = v;
update();
},
),
actions: multiSelectMode ? selectActions : null,
),
SliverGridComics(
comics: comics,
selections: selectedComics,
onLongPressed: (c) {
onLongPressed: (c, heroID) {
setState(() {
multiSelectMode = true;
selectedComics[c as LocalComic] = true;
});
},
onTap: (c) {
onTap: (c, heroID) {
if (multiSelectMode) {
setState(() {
if (selectedComics.containsKey(c as LocalComic)) {
@@ -306,12 +317,20 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
});
} else {
// prevent dirty data
var comic = LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
var comic =
LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
comic.read();
}
},
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.folder_open,
text: "Open Folder".tl,
onClick: () {
openComicFolder(c as LocalComic);
},
),
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
@@ -337,6 +356,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return PopScope(
canPop: !multiSelectMode && !searchMode,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
@@ -360,28 +380,49 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
context: App.rootContext,
builder: (context) {
bool removeComicFile = true;
bool removeFavoriteAndHistory = true;
return StatefulBuilder(builder: (context, state) {
return ContentDialog(
title: "Delete".tl,
content: CheckboxListTile(
title: Text("Also remove files on disk".tl),
value: removeComicFile,
onChanged: (v) {
state(() {
removeComicFile = !removeComicFile;
});
},
content: Column(
children: [
CheckboxListTile(
title: Text("Remove local favorite and history".tl),
value: removeFavoriteAndHistory,
onChanged: (v) {
state(() {
removeFavoriteAndHistory = !removeFavoriteAndHistory;
});
},
),
CheckboxListTile(
title: Text("Also remove files on disk".tl),
value: removeComicFile,
onChanged: (v) {
state(() {
removeComicFile = !removeComicFile;
});
},
)
],
),
actions: [
if (comics.length == 1 && comics.first.hasChapters)
TextButton(
child: Text("Delete Chapters".tl),
onPressed: () {
context.pop();
showDeleteChaptersPopWindow(context, comics.first);
},
),
FilledButton(
onPressed: () {
context.pop();
for (var comic in comics) {
LocalManager().deleteComic(
comic,
removeComicFile,
);
}
LocalManager().batchDeleteComics(
comics,
removeComicFile,
removeFavoriteAndHistory,
);
isDeleted = true;
},
child: Text("Confirm".tl),
@@ -444,7 +485,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
var fileName = "";
// For each comic, export it to a file
for (var comic in comics) {
fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext);
fileName = FilePath.join(
cacheDir,
sanitizeFileName(comic.title, maxLength: 100) + ext,
);
await export(comic, fileName);
current++;
if (comics.length > 1) {
@@ -493,3 +537,102 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
typedef ExportComicFunc = Future<File> Function(
LocalComic comic, String outFilePath);
/// Opens the folder containing the comic in the system file explorer
Future<void> openComicFolder(LocalComic comic) async {
try {
final folderPath = comic.baseDir;
if (App.isWindows) {
await Process.run('explorer', [folderPath]);
} else if (App.isMacOS) {
await Process.run('open', [folderPath]);
} else if (App.isLinux) {
// Try different file managers commonly found on Linux
try {
await Process.run('xdg-open', [folderPath]);
} catch (e) {
// Fallback to other common file managers
try {
await Process.run('nautilus', [folderPath]);
} catch (e) {
try {
await Process.run('dolphin', [folderPath]);
} catch (e) {
try {
await Process.run('thunar', [folderPath]);
} catch (e) {
// Last resort: use the URL launcher with file:// protocol
await launchUrlString('file://$folderPath');
}
}
}
}
} else {
// For mobile platforms, use the URL launcher with file:// protocol
await launchUrlString('file://$folderPath');
}
} catch (e, s) {
Log.error("Open Folder", "Failed to open comic folder: $e", s);
// Show error message to user
if (App.rootContext.mounted) {
App.rootContext.showMessage(message: "Failed to open folder: $e");
}
}
}
void showDeleteChaptersPopWindow(BuildContext context, LocalComic comic) {
var chapters = <String>[];
showPopUpWidget(
context,
PopUpWidgetScaffold(
title: "Delete Chapters".tl,
body: StatefulBuilder(builder: (context, setState) {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: comic.downloadedChapters.length,
itemBuilder: (context, index) {
var id = comic.downloadedChapters[index];
var chapter = comic.chapters![id] ?? "Unknown Chapter";
return CheckboxListTile(
title: Text(chapter),
value: chapters.contains(id),
onChanged: (v) {
setState(() {
if (v == true) {
chapters.add(id);
} else {
chapters.remove(id);
}
});
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton(
onPressed: () {
Future.delayed(const Duration(milliseconds: 200), () {
LocalManager().deleteComicChapters(comic, chapters);
});
App.rootContext.pop();
},
child: Text("Submit".tl),
)
],
),
)
],
);
}),
),
);
}

View File

@@ -0,0 +1,588 @@
part of 'reader.dart';
bool _shouldBlockComment(Comment comment) {
var blockedWords = appdata.settings["blockedCommentWords"] as List;
if (blockedWords.isEmpty) return false;
var content = comment.content.toLowerCase();
for (var word in blockedWords) {
if (content.contains(word.toString().toLowerCase())) {
return true;
}
}
return false;
}
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) {
var filteredComments = res.data.where((c) => !_shouldBlockComment(c)).toList();
setState(() {
_comments = filteredComments;
_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 {
var filteredComments = res.data.where((c) => !_shouldBlockComment(c)).toList();
setState(() {
_comments!.addAll(filteredComments);
_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

@@ -152,12 +152,19 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
bool _dragInProgress = false;
bool get _enableDoubleTapToZoom =>
appdata.settings.getReaderSetting(reader.cid, reader.type.sourceKey, 'enableDoubleTapToZoom');
void onTapUp(TapUpDetails event) {
if (_longPressInProgress) {
_longPressInProgress = false;
return;
}
final location = event.globalPosition;
if (!_enableDoubleTapToZoom) {
onTap(location);
return;
}
final previousLocation = _previousEvent?.globalPosition;
if (previousLocation != null) {
if ((location - previousLocation).distanceSquared <
@@ -184,7 +191,8 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
} else if (context.readerScaffold.isOpen) {
context.readerScaffold.openOrClose();
} else {
if (appdata.settings['enableTapToTurnPages']) {
if (appdata.settings.getReaderSetting(
reader.cid, reader.type.sourceKey, 'enableTapToTurnPages')) {
bool isLeft = false, isRight = false, isTop = false, isBottom = false;
final width = context.width;
final height = context.height;
@@ -201,11 +209,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
isBottom = true;
}
bool isCenter = false;
var prev = context.reader.toPrevPage;
var next = context.reader.toNextPage;
if (appdata.settings['reverseTapToTurnPages']) {
prev = context.reader.toNextPage;
next = context.reader.toPrevPage;
var prev = () => context.reader.toPrevPage();
var next = () => context.reader.toNextPage();
if (appdata.settings.getReaderSetting(
reader.cid, reader.type.sourceKey, 'reverseTapToTurnPages')) {
prev = () => context.reader.toNextPage();
next = () => context.reader.toPrevPage();
}
switch (context.reader.mode) {
case ReaderMode.galleryLeftToRight:
@@ -287,6 +296,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
text: "Copy Image".tl,
onClick: () => copyImage(location),
),
if (!reader.isLoading)
MenuEntry(
icon: Icons.download_outlined,
text: "Save Image".tl,
onClick: () => saveImage(location),
),
],
);
}
@@ -319,6 +334,17 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
context.showMessage(message: "No Image");
}
}
void saveImage(Offset location) async {
var controller = reader._imageViewController;
var image = await controller!.getImageByOffset(location);
if (image != null) {
var filetype = detectFileType(image);
saveFile(filename: "image${filetype.ext}", data: image);
} else {
context.showMessage(message: "No Image");
}
}
}
class _DragListener {

View File

@@ -21,19 +21,35 @@ class _ReaderImagesState extends State<_ReaderImages> {
super.initState();
}
@override
void dispose() {
super.dispose();
ImageDownloader.cancelAllLoadingImages();
}
void load() async {
if (inProgress) return;
inProgress = true;
if (reader.type == ComicType.local ||
(LocalManager().isDownloaded(
reader.cid, reader.type, reader.chapter, reader.widget.chapters))) {
reader.cid,
reader.type,
reader.chapter,
reader.widget.chapters,
))) {
try {
var images = await LocalManager()
.getImages(reader.cid, reader.type, reader.chapter);
var images = await LocalManager().getImages(
reader.cid,
reader.type,
reader.chapter,
);
setState(() {
reader.images = images;
reader.isLoading = false;
inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
});
} catch (e) {
setState(() {
@@ -59,6 +75,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
reader.images = res.data;
reader.isLoading = false;
inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
});
}
}
@@ -69,23 +88,29 @@ class _ReaderImagesState extends State<_ReaderImages> {
Widget build(BuildContext context) {
if (reader.isLoading) {
load();
return const Center(
child: CircularProgressIndicator(),
);
return const Center(child: CircularProgressIndicator());
} else if (error != null) {
return NetworkError(
message: error!,
retry: () {
setState(() {
reader.isLoading = true;
error = null;
});
return GestureDetector(
onTap: () {
context.readerScaffold.openOrClose();
},
child: SizedBox.expand(
child: NetworkError(
message: error!,
retry: () {
setState(() {
reader.isLoading = true;
error = null;
});
},
),
),
);
} else {
if (reader.mode.isGallery) {
return _GalleryMode(
key: Key('${reader.mode.key}_${reader.imagesPerPage}'));
key: Key('${reader.mode.key}_${reader.imagesPerPage}'),
);
} else {
return _ContinuousMode(key: Key(reader.mode.key));
}
@@ -104,15 +129,26 @@ class _GalleryModeState extends State<_GalleryMode>
implements _ImageViewController {
late PageController controller;
late List<bool> cached;
int get preCacheCount => appdata.settings["preloadImageCount"];
var photoViewControllers = <int, PhotoViewController>{};
late _ReaderState reader;
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
/// [totalPages] is the total number of pages in the current chapter.
/// More than one images can be displayed on one page.
int get totalPages {
if (!reader.showSingleImageOnFirstPage()) {
return (reader.images!.length /
reader.imagesPerPage)
.ceil();
} else {
return 1 +
((reader.images!.length - 1) /
reader.imagesPerPage)
.ceil();
}
}
var imageStates = <State<ComicImage>>{};
@@ -125,24 +161,66 @@ class _GalleryModeState extends State<_GalleryMode>
reader = context.reader;
controller = PageController(initialPage: reader.page);
reader._imageViewController = this;
cached = List.filled(reader.maxPage + 2, false);
Future.microtask(() {
context.readerScaffold.setFloatingButton(0);
});
super.initState();
}
void cache(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= totalPages && !cached[i]) {
int startIndex = (i - 1) * reader.imagesPerPage;
int endIndex =
math.min(startIndex + reader.imagesPerPage, reader.images!.length);
for (int i = startIndex; i < endIndex; i++) {
precacheImage(
_createImageProviderFromKey(reader.images![i], context), context);
}
cached[i] = true;
/// Get the range of images for the given page. [page] is 1-based.
(int start, int end) getPageImagesRange(int page) {
var imagesPerPage = reader.imagesPerPage;
if (reader.showSingleImageOnFirstPage()) {
if (page == 1) {
return (0, 1);
} else {
int startIndex = (page - 2) * imagesPerPage + 1;
int endIndex = math.min(
startIndex + imagesPerPage,
reader.images!.length,
);
return (startIndex, endIndex);
}
} else {
int startIndex = (page - 1) * imagesPerPage;
int endIndex = math.min(
startIndex + imagesPerPage,
reader.images!.length,
);
return (startIndex, endIndex);
}
}
/// Get the image indices for current page. Returns null if no images.
/// Returns a single index if only one image, or a range if multiple images.
(int, int)? getCurrentPageImageRange() {
if (reader.images == null || reader.images!.isEmpty) {
return null;
}
var (startIndex, endIndex) = getPageImagesRange(reader.page);
return (startIndex, endIndex);
}
/// [cache] is used to cache the images.
/// The count of images to cache is determined by the [preCacheCount] setting.
/// For previous page and next page, it will do a memory cache.
/// For current page, it will do nothing because it is already on the screen.
/// For other pages, it will do a pre-download cache.
void cache(int startPage) {
for (int i = startPage - 1; i <= startPage + preCacheCount; i++) {
if (i == startPage || i <= 0 || i > totalPages) continue;
bool shouldPreCache = i == startPage + 1 || i == startPage - 1;
_cachePage(i, shouldPreCache);
}
}
void _cachePage(int page, bool shouldPreCache) {
var (startIndex, endIndex) = getPageImagesRange(page);
for (int i = startIndex; i < endIndex; i++) {
if (shouldPreCache) {
_precacheImage(i + 1, context);
} else {
_preDownloadImage(i + 1, context);
}
}
}
@@ -164,16 +242,12 @@ class _GalleryModeState extends State<_GalleryMode>
var controller = photoViewControllers[reader.page]!;
Offset value = event.delta;
if (isLongPressing) {
controller.updateMultiple(
position: controller.position + value,
);
controller.updateMultiple(position: controller.position + value);
}
}
},
child: PhotoViewGallery.builder(
backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface,
),
backgroundDecoration: BoxDecoration(color: context.colorScheme.surface),
reverse: reader.mode == ReaderMode.galleryRightToLeft,
scrollDirection: reader.mode == ReaderMode.galleryTopToBottom
? Axis.vertical
@@ -185,24 +259,26 @@ class _GalleryModeState extends State<_GalleryMode>
child: const SizedBox(),
);
} else {
int pageIndex = index - 1;
int startIndex = pageIndex * reader.imagesPerPage;
int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length);
List<String> pageImages =
reader.images!.sublist(startIndex, endIndex);
var (startIndex, endIndex) = getPageImagesRange(index);
List<String> pageImages = reader.images!.sublist(
startIndex,
endIndex,
);
cached[index] = true;
cache(index);
photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage == 1) {
if (reader.imagesPerPage == 1 ||
pageImages.length == 1) {
return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium,
controller: photoViewControllers[index],
imageProvider:
_createImageProviderFromKey(pageImages[0], context),
imageProvider: _createImageProviderFromKey(
pageImages[0],
context,
startIndex + 1,
),
fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry);
@@ -210,11 +286,13 @@ class _GalleryModeState extends State<_GalleryMode>
);
}
final viewportSize = MediaQuery.of(context).size;
return PhotoViewGalleryPageOptions.customChild(
childSize: viewportSize,
controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
child: buildPageImages(pageImages),
child: buildPageImages(pageImages, startIndex),
);
}
},
@@ -244,12 +322,19 @@ class _GalleryModeState extends State<_GalleryMode>
reader.setPage(i);
context.readerScaffold.update();
}
// Remove other pages' controllers to reset their state.
var keys = photoViewControllers.keys.toList();
for (var key in keys) {
if (key != i) {
photoViewControllers.remove(key);
}
}
},
),
);
}
Widget buildPageImages(List<String> images) {
Widget buildPageImages(List<String> images, int startIndex) {
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
? Axis.vertical
: Axis.horizontal;
@@ -267,7 +352,11 @@ class _GalleryModeState extends State<_GalleryMode>
child: ComicImage(
width: double.infinity,
height: double.infinity,
image: _createImageProviderFromKey(images[0], context),
image: _createImageProviderFromKey(
images[0],
context,
startIndex + 1,
),
fit: BoxFit.contain,
alignment: axis == Axis.vertical
? Alignment.bottomCenter
@@ -280,7 +369,11 @@ class _GalleryModeState extends State<_GalleryMode>
child: ComicImage(
width: double.infinity,
height: double.infinity,
image: _createImageProviderFromKey(images[1], context),
image: _createImageProviderFromKey(
images[1],
context,
startIndex + 2,
),
fit: BoxFit.contain,
alignment: axis == Axis.vertical
? Alignment.topCenter
@@ -288,12 +381,16 @@ class _GalleryModeState extends State<_GalleryMode>
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
),
)
),
];
} else {
imageWidgets = images.map((imageKey) {
ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context);
startIndex++;
ImageProvider imageProvider = _createImageProviderFromKey(
imageKey,
context,
startIndex,
);
return Expanded(
child: ComicImage(
image: imageProvider,
@@ -354,10 +451,7 @@ class _GalleryModeState extends State<_GalleryMode>
} else {
zoomPosition = Offset(0, 0);
}
photoViewController.animateScale?.call(
target,
zoomPosition,
);
photoViewController.animateScale?.call(target, zoomPosition);
isLongPressing = true;
}
@@ -402,34 +496,24 @@ class _GalleryModeState extends State<_GalleryMode>
keyRepeatTimer = null;
}
if (forward == true) {
controller.nextPage(
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
reader.toPage(reader.page + 1);
} else if (forward == false) {
controller.previousPage(
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
reader.toPage(reader.page - 1);
}
}
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
keyRepeatTimer = Timer.periodic(
const Duration(milliseconds: 100),
reader.enablePageAnimation(reader.cid, reader.type)
? const Duration(milliseconds: 200)
: const Duration(milliseconds: 50),
(timer) {
if (!mounted) {
timer.cancel();
return;
} else if (forward == true) {
controller.nextPage(
duration: const Duration(milliseconds: 100),
curve: Curves.ease,
);
reader.toPage(reader.page + 1);
} else if (forward == false) {
controller.previousPage(
duration: const Duration(milliseconds: 100),
curve: Curves.ease,
);
reader.toPage(reader.page - 1);
}
},
);
@@ -447,25 +531,41 @@ class _GalleryModeState extends State<_GalleryMode>
@override
Future<Uint8List?> getImageByOffset(Offset offset) async {
String? imageKey;
if (reader.imagesPerPage == 1) {
imageKey = reader.images![reader.page - 1];
} else {
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
}
var imageKey = getImageKeyByOffset(offset);
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
))!.readAsBytes();
}
}
@override
String? getImageKeyByOffset(Offset offset) {
var range = getCurrentPageImageRange();
if (range == null) return null;
var (startIndex, endIndex) = range;
int actualImageCount = endIndex - startIndex;
if (actualImageCount == 1) {
return reader.images![startIndex];
}
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
var imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
int index = reader.images!.indexOf(imageKey);
if (index >= startIndex && index < endIndex) {
return imageKey;
}
}
}
return reader.images![startIndex];
}
}
const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
@@ -473,7 +573,7 @@ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
PointerDeviceKind.unknown
PointerDeviceKind.unknown,
};
const double _kChangeChapterOffset = 160;
@@ -559,27 +659,52 @@ class _ContinuousModeState extends State<_ContinuousMode>
cacheImages(page);
}
double? futurePosition;
double? _futurePosition;
void smoothTo(double offset) {
futurePosition ??= scrollController.offset;
if (futurePosition! > scrollController.position.maxScrollExtent &&
offset > 0) {
return;
} else if (futurePosition! < scrollController.position.minScrollExtent &&
offset < 0) {
if (HardwareKeyboard.instance.isShiftPressed) {
return;
}
futurePosition = futurePosition! + offset * 1.2;
futurePosition = futurePosition!.clamp(
var currentLocation = scrollController.position.pixels;
var old = _futurePosition;
_futurePosition ??= currentLocation;
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
final customSpeed = appdata.settings.getReaderSetting(
context.reader.cid,
context.reader.type.sourceKey,
"readerScrollSpeed",
);
if (customSpeed is num) {
k *= customSpeed;
}
_futurePosition = _futurePosition! + offset * k;
var beforeOffset = (_futurePosition! - currentLocation).abs();
_futurePosition = _futurePosition!.clamp(
scrollController.position.minScrollExtent,
scrollController.position.maxScrollExtent,
);
scrollController.animateTo(
futurePosition!,
duration: const Duration(milliseconds: 200),
var afterOffset = (_futurePosition! - currentLocation).abs();
if (_futurePosition == old) return;
var target = _futurePosition!;
var duration = const Duration(milliseconds: 160);
if (afterOffset < beforeOffset) {
duration = duration * (afterOffset / beforeOffset);
if (duration < Duration(milliseconds: 10)) {
duration = Duration(milliseconds: 10);
}
}
scrollController
.animateTo(
_futurePosition!,
duration: duration,
curve: Curves.linear,
);
)
.then((_) {
var current = scrollController.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
}
});
}
void onPointerSignal(PointerSignalEvent event) {
@@ -599,7 +724,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
void cacheImages(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= reader.maxPage && !cached[i]) {
_precacheImage(i, context);
_preDownloadImage(i, context);
cached[i] = true;
}
}
@@ -608,10 +733,12 @@ class _ContinuousModeState extends State<_ContinuousMode>
void onScroll() {
if (prepareToPrevChapter) {
jumpToNextChapter = false;
jumpToPrevChapter = scrollController.offset <
jumpToPrevChapter =
scrollController.offset <
scrollController.position.minScrollExtent - _kChangeChapterOffset;
} else if (prepareToNextChapter) {
jumpToNextChapter = scrollController.offset >
jumpToNextChapter =
scrollController.offset >
scrollController.position.maxScrollExtent + _kChangeChapterOffset;
jumpToPrevChapter = false;
}
@@ -656,8 +783,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
physics: isCTRLPressed || _isMouseScrolling || disableScroll
? const NeverScrollableScrollPhysics()
: isZoomedIn
? const ClampingScrollPhysics()
: const BouncingScrollPhysics(),
? const ClampingScrollPhysics()
: const BouncingScrollPhysics(),
itemBuilder: (context, index) {
if (index == 0 || index == reader.maxPage + 1) {
return const SizedBox();
@@ -685,8 +812,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
),
);
},
scrollBehavior: const MaterialScrollBehavior()
.copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes),
scrollBehavior: const MaterialScrollBehavior().copyWith(
scrollbars: false,
dragDevices: _kTouchLikeDeviceTypes,
),
);
widget = Stack(
@@ -704,7 +833,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
disableScroll = true;
});
}
futurePosition = null;
_futurePosition = null;
if (_isMouseScrolling) {
setState(() {
_isMouseScrolling = false;
@@ -830,20 +959,14 @@ class _ContinuousModeState extends State<_ContinuousMode>
}
return PhotoView.customChild(
backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface,
),
backgroundDecoration: BoxDecoration(color: context.colorScheme.surface),
childSize: Size(width, height),
minScale: 1.0,
maxScale: 2.5,
strictScale: true,
controller: photoViewController,
onScaleUpdate: onScaleUpdate,
child: SizedBox(
width: width,
height: height,
child: widget,
),
child: SizedBox(width: width, height: height, child: widget),
);
}
@@ -913,10 +1036,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
} else {
zoomPosition = Offset(0, 0);
}
photoViewController.animateScale?.call(
target,
zoomPosition,
);
photoViewController.animateScale?.call(target, zoomPosition);
onScaleUpdate(target);
isLongPressing = true;
}
@@ -935,7 +1055,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override
void toPage(int page) {
itemScrollController.jumpTo(index: page);
futurePosition = null;
_futurePosition = null;
}
@override
@@ -975,13 +1095,13 @@ class _ContinuousModeState extends State<_ContinuousMode>
}
if (forward == true) {
scrollController.animateTo(
scrollController.offset + context.height,
scrollController.offset + context.height * 0.25,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
} else if (forward == false) {
scrollController.animateTo(
scrollController.offset - context.height,
scrollController.offset - context.height * 0.25,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
@@ -998,25 +1118,34 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override
Future<Uint8List?> getImageByOffset(Offset offset) async {
var imageKey = getImageKeyByOffset(offset);
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
))!.readAsBytes();
}
}
@override
String? getImageKeyByOffset(Offset offset) {
String? imageKey;
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
return imageKey;
}
}
ImageProvider _createImageProviderFromKey(
String imageKey, BuildContext context) {
String imageKey,
BuildContext context,
int page,
) {
var reader = context.reader;
return ReaderImageProvider(
imageKey,
@@ -1030,21 +1159,38 @@ ImageProvider _createImageProviderFromKey(
ImageProvider _createImageProvider(int page, BuildContext context) {
var reader = context.reader;
var imageKey = reader.images![page - 1];
return _createImageProviderFromKey(imageKey, context);
return _createImageProviderFromKey(imageKey, context, page);
}
/// [_precacheImage] is used to precache the image for the given page.
/// The image is cached using the flutter's [precacheImage] method.
/// The image will be downloaded and decoded into memory.
void _precacheImage(int page, BuildContext context) {
precacheImage(
_createImageProvider(page, context),
context,
);
if (page <= 0 || page > context.reader.images!.length) {
return;
}
precacheImage(_createImageProvider(page, context), context);
}
/// [_preDownloadImage] is used to download the image for the given page.
/// The image is downloaded using the [CacheManager] and saved to the local storage.
void _preDownloadImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) {
return;
}
var reader = context.reader;
var imageKey = reader.images![page - 1];
if (imageKey.startsWith("file://")) {
return;
}
var cid = reader.cid;
var eid = reader.eid;
var sourceKey = reader.type.comicSource?.key;
ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid);
}
class _SwipeChangeChapterProgress extends StatefulWidget {
const _SwipeChangeChapterProgress({
this.controller,
required this.isPrev,
});
const _SwipeChangeChapterProgress({this.controller, required this.isPrev});
final ScrollController? controller;
@@ -1161,7 +1307,12 @@ class _ProgressPainter extends CustomPainter {
paint.color = color;
canvas.drawRRect(
RRect.fromLTRBR(
0, 0, size.width * value, size.height, Radius.circular(16)),
0,
0,
size.width * value,
size.height,
Radius.circular(16),
),
paint,
);
}

View File

@@ -25,10 +25,12 @@ import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/images.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/clipboard_image.dart';
import 'package:venera/utils/data_sync.dart';
@@ -53,6 +55,8 @@ part 'loading.dart';
part 'chapters.dart';
part 'chapter_comments.dart';
extension _ReaderContext on BuildContext {
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
@@ -110,10 +114,21 @@ class _ReaderState extends State<Reader>
}
@override
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
int get maxPage {
if (images == null) {
return 1;
}
if (!showSingleImageOnFirstPage()) {
return (images!.length / imagesPerPage).ceil();
} else {
return 1 + ((images!.length - 1) / imagesPerPage).ceil();
}
}
@override
ComicType get type => widget.type;
@override
String get cid => widget.cid;
String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
@@ -124,7 +139,8 @@ class _ReaderState extends State<Reader>
late ReaderMode mode;
@override
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait;
bool get isPortrait =>
MediaQuery.of(context).orientation == Orientation.portrait;
History? history;
@@ -150,14 +166,27 @@ class _ReaderState extends State<Reader>
}
if (widget.initialPage != null) {
page = widget.initialPage!;
if (page < 1) {
page = 1;
}
}
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
// mode = ReaderMode.fromKey(appdata.settings['readerMode']);
mode = ReaderMode.fromKey(
appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'),
);
history = widget.history;
Future.microtask(() {
updateHistory();
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if (appdata.settings['enableTurnPageByVolumeKey']) {
if (!appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'showSystemStatusBar',
)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
if (appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'enableTurnPageByVolumeKey',
)) {
handleVolumeEvent();
}
setImageCacheSize();
@@ -167,10 +196,18 @@ class _ReaderState extends State<Reader>
super.initState();
}
bool _isInitialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
initImagesPerPage(widget.initialPage ?? 1);
if (!_isInitialized) {
initImagesPerPage(widget.initialPage ?? 1);
_isInitialized = true;
} else {
// For orientation changed
_checkImagesPerPageChange();
}
initReaderWindow();
}
@@ -187,8 +224,10 @@ class _ReaderState extends State<Reader>
} else {
maxImageCacheSize = 500 << 20;
}
Log.info("Reader",
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize");
Log.info(
"Reader",
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize",
);
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
}
@@ -216,10 +255,18 @@ class _ReaderState extends State<Reader>
focusNode: focusNode,
autofocus: true,
onKeyEvent: onKeyEvent,
child: _ReaderScaffold(
child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())),
),
child: Overlay(
initialEntries: [
OverlayEntry(
builder: (context) {
return _ReaderScaffold(
child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())),
),
);
},
),
],
),
);
}
@@ -250,7 +297,15 @@ class _ReaderState extends State<Reader>
history!.page = images?.length ?? 1;
} else {
/// Record the first image of the page
history!.page = (page - 1) * imagesPerPage + 1;
if (!showSingleImageOnFirstPage() || imagesPerPage == 1) {
history!.page = (page - 1) * imagesPerPage + 1;
} else {
if (page == 1) {
history!.page = 1;
} else {
history!.page = (page - 2) * imagesPerPage + 2;
}
}
}
history!.maxPage = images?.length ?? 1;
if (widget.chapters?.isGrouped ?? false) {
@@ -321,6 +376,8 @@ class _ReaderState extends State<Reader>
abstract mixin class _ImagePerPageHandler {
late int _lastImagesPerPage;
late bool _lastOrientation;
bool get isPortrait;
int get page;
@@ -329,39 +386,92 @@ abstract mixin class _ImagePerPageHandler {
ReaderMode get mode;
String get cid;
ComicType get type;
void initImagesPerPage(int initialPage) {
_lastImagesPerPage = imagesPerPage;
_lastOrientation = isPortrait;
if (imagesPerPage != 1) {
page = (initialPage / imagesPerPage).ceil();
if (showSingleImageOnFirstPage()) {
page = ((initialPage - 1) / imagesPerPage).ceil() + 1;
} else {
page = (initialPage / imagesPerPage).ceil();
}
}
}
bool showSingleImageOnFirstPage() => appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'showSingleImageOnFirstPage',
);
/// The number of images displayed on one screen
int get imagesPerPage {
if (mode.isContinuous) return 1;
if (isPortrait) {
return appdata.settings['readerScreenPicNumberForPortrait'] ?? 1;
return appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'readerScreenPicNumberForPortrait',
) ??
1;
} else {
return appdata.settings['readerScreenPicNumberForLandscape'] ?? 1;
return appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'readerScreenPicNumberForLandscape',
) ??
1;
}
}
/// Check if the number of images per page has changed
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
bool currentOrientation = isPortrait;
if (_lastImagesPerPage != currentImagesPerPage ||
_lastOrientation != currentOrientation) {
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage,
currentImagesPerPage,
);
_lastImagesPerPage = currentImagesPerPage;
_lastOrientation = currentOrientation;
}
}
/// Adjust the page number when the number of images per page changes
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
int oldImagesPerPage,
int newImagesPerPage,
) {
int previousImageIndex = 1;
if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) {
previousImageIndex = (page - 1) * oldImagesPerPage + 1;
} else {
if (page == 1) {
previousImageIndex = 1;
} else {
previousImageIndex = (page - 2) * oldImagesPerPage + 2;
}
}
int newPage;
if (newImagesPerPage != 1) {
if (showSingleImageOnFirstPage()) {
newPage = ((previousImageIndex - 1) / newImagesPerPage).ceil() + 1;
} else {
newPage = (previousImageIndex / newImagesPerPage).ceil();
}
} else {
newPage = previousImageIndex;
}
page = newPage > 0 ? newPage : 1;
}
}
@@ -396,10 +506,7 @@ abstract mixin class _VolumeListener {
if (volumeListener != null) {
volumeListener?.cancel();
}
volumeListener = VolumeListener(
onDown: onDown,
onUp: onUp,
)..listen();
volumeListener = VolumeListener(onDown: onDown, onUp: onUp)..listen();
}
void stopVolumeEvent() {
@@ -428,9 +535,14 @@ abstract mixin class _ReaderLocation {
bool get isLoading;
String get cid;
ComicType get type;
void update();
bool get enablePageAnimation => appdata.settings['enablePageAnimation'];
bool enablePageAnimation(String cid, ComicType type) => appdata.settings
.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
_ImageViewController? _imageViewController;
@@ -467,7 +579,7 @@ abstract mixin class _ReaderLocation {
}
this.page = page;
update();
if (enablePageAnimation) {
if (enablePageAnimation(cid, type)) {
_animationCount++;
_imageViewController!.animateToPage(page).then((_) {
_animationCount--;
@@ -506,12 +618,16 @@ abstract mixin class _ReaderLocation {
Timer? autoPageTurningTimer;
void autoPageTurning() {
void autoPageTurning(String cid, ComicType type) {
if (autoPageTurningTimer != null) {
autoPageTurningTimer!.cancel();
autoPageTurningTimer = null;
} else {
int interval = appdata.settings['autoPageTurningInterval'];
int interval = appdata.settings.getReaderSetting(
cid,
type.sourceKey,
'autoPageTurningInterval',
);
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
if (page == maxPage) {
autoPageTurningTimer!.cancel();
@@ -603,4 +719,6 @@ abstract interface class _ImageViewController {
bool handleOnTap(Offset location);
Future<Uint8List?> getImageByOffset(Offset offset);
String? getImageKeyByOffset(Offset offset);
}

View File

@@ -107,7 +107,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
if (!_isOpen) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if (!appdata.settings['showSystemStatusBar']) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
}
setState(() {
_isOpen = !_isOpen;
@@ -124,9 +128,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: widget.child,
),
Positioned.fill(child: widget.child),
if (appdata.settings['showPageNumberInReader'] == true)
buildPageInfoText(),
buildStatusInfo(),
@@ -170,29 +172,43 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
),
),
),
child: Row(
children: [
const SizedBox(width: 8),
const BackButton(),
const SizedBox(width: 8),
Expanded(
child: Text(
context.reader.widget.name,
style: ts.s18,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Padding(
padding: EdgeInsets.only(
left: context.padding.left,
right: context.padding.right,
),
child: Row(
children: [
const SizedBox(width: 8),
const BackButton(),
const SizedBox(width: 8),
Expanded(
child: Text(
context.reader.widget.name,
style: ts.s18,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 8),
Tooltip(
message: "Settings".tl,
child: IconButton(
icon: const Icon(Icons.settings),
onPressed: openSetting,
const SizedBox(width: 8),
if (shouldShowChapterComments())
Tooltip(
message: "Chapter Comments".tl,
child: IconButton(
icon: const Icon(Icons.comment),
onPressed: openChapterComments,
),
),
Tooltip(
message: "Settings".tl,
child: IconButton(
icon: const Icon(Icons.settings),
onPressed: openSetting,
),
),
),
const SizedBox(width: 8),
],
const SizedBox(width: 8),
],
),
),
),
);
@@ -208,12 +224,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
}
void addImageFavorite() {
void addImageFavorite() async {
try {
if (context.reader.images![0].contains('file://')) {
showToast(
message: "Local comic collection is not supported at present".tl,
context: context);
message: "Local comic collection is not supported at present".tl,
context: context,
);
return;
}
String id = context.reader.cid;
@@ -222,14 +239,18 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
String title = context.reader.history!.title;
String subTitle = context.reader.history!.subtitle;
int maxPage = context.reader.images!.length;
int page = context.reader.page;
int? page = await selectImage();
if (page == null) return;
page += 1;
String sourceKey = context.reader.type.sourceKey;
String imageKey = context.reader.images![page - 1];
List<String> tags = context.reader.widget.tags;
String author = context.reader.widget.author;
var epName = context.reader.widget.chapters?.titles
.elementAtOrNull(context.reader.chapter - 1) ??
var epName =
context.reader.widget.chapters?.titles.elementAtOrNull(
context.reader.chapter - 1,
) ??
"E${context.reader.chapter}";
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
@@ -242,7 +263,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
return;
}
ImageFavoriteManager().deleteImageFavorite([
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName)
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName),
]);
showToast(
message: "Uncollected the image".tl,
@@ -250,7 +271,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
seconds: 1,
);
} else {
var imageFavoritesComic = ImageFavoriteManager().find(id, sourceKey) ??
var imageFavoritesComic =
ImageFavoriteManager().find(id, sourceKey) ??
ImageFavoritesComic(
id,
[],
@@ -264,12 +286,21 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
subTitle,
maxPage,
);
ImageFavorite imageFavorite =
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName);
ImageFavoritesEp? imageFavoritesEp =
imageFavoritesComic.imageFavoritesEp.firstWhereOrNull((e) {
return e.ep == ep;
});
ImageFavorite imageFavorite = ImageFavorite(
page,
imageKey,
null,
eid,
id,
ep,
sourceKey,
epName,
);
ImageFavoritesEp? imageFavoritesEp = imageFavoritesComic
.imageFavoritesEp
.firstWhereOrNull((e) {
return e.ep == ep;
});
if (imageFavoritesEp == null) {
if (page != firstPage) {
var copy = imageFavorite.copyWith(
@@ -279,10 +310,20 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
// 不是第一页的话, 自动塞一个封面进去
imageFavoritesEp = ImageFavoritesEp(
eid, ep, [copy, imageFavorite], epName, maxPage);
eid,
ep,
[copy, imageFavorite],
epName,
maxPage,
);
} else {
imageFavoritesEp =
ImageFavoritesEp(eid, ep, [imageFavorite], epName, maxPage);
imageFavoritesEp = ImageFavoritesEp(
eid,
ep,
[imageFavorite],
epName,
maxPage,
);
}
imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp);
} else {
@@ -306,7 +347,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic);
showToast(
message: "Successfully collected".tl, context: context, seconds: 1);
message: "Successfully collected".tl,
context: context,
seconds: 1,
);
}
update();
} catch (e, stackTrace) {
@@ -321,154 +365,152 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
text = "P${context.reader.page}";
}
final buttons = [
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite,
),
),
if (App.isDesktop)
Tooltip(
message: "${"Full Screen".tl}(F12)",
child: IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
context.reader.fullscreen();
},
),
),
if (App.isAndroid)
Tooltip(
message: "Screen Rotation".tl,
child: IconButton(
icon: () {
if (rotation == null) {
return const Icon(Icons.screen_rotation);
} else if (rotation == false) {
return const Icon(Icons.screen_lock_portrait);
} else {
return const Icon(Icons.screen_lock_landscape);
}
}.call(),
onPressed: () {
if (rotation == null) {
setState(() {
rotation = false;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
} else if (rotation == false) {
setState(() {
rotation = true;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
} else {
setState(() {
rotation = null;
});
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
},
),
),
Tooltip(
message: "Auto Page Turning".tl,
child: IconButton(
icon: context.reader.autoPageTurningTimer != null
? const Icon(Icons.timer)
: const Icon(Icons.timer_sharp),
onPressed: () {
context.reader.autoPageTurning(
context.reader.cid,
context.reader.type,
);
update();
},
),
),
if (context.reader.widget.chapters != null)
Tooltip(
message: "Chapters".tl,
child: IconButton(
icon: const Icon(Icons.library_books),
onPressed: openChapterDrawer,
),
),
Tooltip(
message: "Save Image".tl,
child: IconButton(
icon: const Icon(Icons.download),
onPressed: saveCurrentImage,
),
),
Tooltip(
message: "Share".tl,
child: IconButton(icon: const Icon(Icons.share), onPressed: share),
),
];
Widget child = SizedBox(
height: kBottomBarHeight,
child: Column(
children: [
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
Row(
children: [
const SizedBox(width: 8),
IconButton.filledTonal(
onPressed: () => !isReversed
? context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1)
? context.reader.toPrevChapter()
: context.reader.toPage(1)
: context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage),
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage),
icon: const Icon(Icons.first_page),
),
Expanded(
child: buildSlider(),
),
Expanded(child: buildSlider()),
IconButton.filledTonal(
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1),
icon: const Icon(Icons.last_page)),
const SizedBox(
width: 8,
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1),
icon: const Icon(Icons.last_page),
),
const SizedBox(width: 8),
],
),
Row(
children: [
const SizedBox(
width: 16,
),
Container(
height: 24,
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(text),
),
),
const Spacer(),
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(
isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite),
),
if (App.isWindows)
Tooltip(
message: "${"Full Screen".tl}(F12)",
child: IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
context.reader.fullscreen();
},
),
),
if (App.isAndroid)
Tooltip(
message: "Screen Rotation".tl,
child: IconButton(
icon: () {
if (rotation == null) {
return const Icon(Icons.screen_rotation);
} else if (rotation == false) {
return const Icon(Icons.screen_lock_portrait);
} else {
return const Icon(Icons.screen_lock_landscape);
}
}.call(),
onPressed: () {
if (rotation == null) {
setState(() {
rotation = false;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
} else if (rotation == false) {
setState(() {
rotation = true;
});
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight
]);
} else {
setState(() {
rotation = null;
});
SystemChrome.setPreferredOrientations(
DeviceOrientation.values);
}
},
),
),
Tooltip(
message: "Auto Page Turning".tl,
child: IconButton(
icon: context.reader.autoPageTurningTimer != null
? const Icon(Icons.timer)
: const Icon(Icons.timer_sharp),
onPressed: () {
context.reader.autoPageTurning();
update();
},
),
),
if (context.reader.widget.chapters != null)
Tooltip(
message: "Chapters".tl,
child: IconButton(
icon: const Icon(Icons.library_books),
onPressed: openChapterDrawer,
),
),
Tooltip(
message: "Save Image".tl,
child: IconButton(
icon: const Icon(Icons.download),
onPressed: saveCurrentImage,
),
),
Tooltip(
message: "Share".tl,
child: IconButton(
icon: const Icon(Icons.share),
onPressed: share,
),
),
const SizedBox(width: 4)
],
)
LayoutBuilder(
builder: (context, constrains) {
return Row(
children: [
if ((constrains.maxWidth - buttons.length * 42) > 80)
Container(
height: 24,
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(child: Text(text)),
).paddingLeft(16),
const Spacer(),
...buttons,
const SizedBox(width: 4),
],
);
},
),
],
),
);
@@ -487,7 +529,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
: null,
),
padding: EdgeInsets.only(bottom: context.padding.bottom),
child: child,
child: Padding(
padding: EdgeInsets.only(
left: context.padding.left,
right: context.padding.right,
),
child: child,
),
),
);
}
@@ -499,8 +547,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
focusNode: sliderFocus,
value: context.reader.page.toDouble(),
min: 1,
max:
context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
max: context.reader.maxPage
.clamp(context.reader.page, 1 << 16)
.toDouble(),
reversed: isReversed,
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
onChanged: (i) {
@@ -510,8 +559,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
}
Widget buildPageInfoText() {
var epName = context.reader.widget.chapters?.titles
.elementAtOrNull(context.reader.chapter - 1) ??
var epName =
context.reader.widget.chapters?.titles.elementAtOrNull(
context.reader.chapter - 1,
) ??
"E${context.reader.chapter}";
if (epName.length > 8) {
epName = "${epName.substring(0, 8)}...";
@@ -570,126 +621,52 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
}
Future<Uint8List?> _getCurrentImageData() async {
var imageKey = context.reader.images![context.reader.page - 1];
var reader = context.reader;
if (context.reader.mode.isContinuous) {
var continuesState =
context.reader._imageViewController as _ContinuousModeState;
var imagesOnScreen =
continuesState.itemPositionsListener.itemPositions.value;
var images = imagesOnScreen
.map((e) => context.reader.images!.elementAtOrNull(e.index - 1))
.whereType<String>()
.toList();
String? selected;
if (images.length > 1) {
await showPopUpWidget(
context,
PopUpWidgetScaffold(
title: "Select an image on screen".tl,
body: GridView.builder(
itemCount: images.length,
itemBuilder: (context, index) {
ImageProvider image;
var imageKey = images[index];
if (imageKey.startsWith('file://')) {
image = FileImage(File(imageKey.replaceFirst("file://", '')));
} else {
image = ReaderImageProvider(
imageKey,
reader.type.comicSource!.key,
reader.cid,
reader.eid,
reader.page,
);
}
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: () {
selected = images[index];
App.rootContext.pop();
},
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
width: double.infinity,
height: double.infinity,
child: Image(
width: double.infinity,
height: double.infinity,
image: image,
),
),
).padding(const EdgeInsets.all(8));
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.7,
),
),
),
);
} else {
selected = images.first;
}
if (selected == null) {
return null;
} else {
imageKey = selected!;
}
}
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
void saveCurrentImage() async {
var data = await _getCurrentImageData();
if (data == null) {
var result = await selectImageToData();
if (result == null) {
return;
}
var (imageIndex, data) = result;
var fileType = detectFileType(data);
var filename = "${context.reader.page}${fileType.ext}";
var filename =
"${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
saveFile(data: data, filename: filename);
}
void share() async {
var data = await _getCurrentImageData();
if (data == null) {
var result = await selectImageToData();
if (result == null) {
return;
}
var (imageIndex, data) = result;
var fileType = detectFileType(data);
var filename = "${context.reader.page}${fileType.ext}";
Share.shareFile(
data: data,
filename: filename,
mime: fileType.mime,
);
var filename =
"${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
Share.shareFile(data: data, filename: filename, mime: fileType.mime);
}
void openSetting() {
showSideBar(
context,
ReaderSettings(
comicId: context.reader.cid,
comicSource: context.reader.type.sourceKey,
onChanged: (key) {
if (key == "readerMode") {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
context.reader.mode = ReaderMode.fromKey(
appdata.settings.getReaderSetting(
context.reader.cid,
context.reader.type.sourceKey,
key,
),
);
}
if (key == "enableTurnPageByVolumeKey") {
if (appdata.settings[key]) {
if (appdata.settings.getReaderSetting(
context.reader.cid,
context.reader.type.sourceKey,
key,
)) {
context.reader.handleVolumeEvent();
} else {
context.reader.stopVolumeEvent();
@@ -698,6 +675,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
if (key == "quickCollectImage") {
addDragListener();
}
if (key == "showChapterComments") {
update();
}
context.reader.update();
},
),
@@ -705,12 +685,55 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
}
bool shouldShowChapterComments() {
// Check if chapters exist
if (context.reader.widget.chapters == null) return false;
// Check if setting is enabled
var showChapterComments = appdata.settings.getReaderSetting(
context.reader.cid,
context.reader.type.sourceKey,
'showChapterComments',
);
if (showChapterComments != true) return false;
// Check if comic source supports chapter comments
var source = ComicSource.find(context.reader.type.sourceKey);
if (source == null || source.chapterCommentsLoader == null) return false;
return true;
}
void openChapterComments() {
var source = ComicSource.find(context.reader.type.sourceKey);
if (source == null) return;
var chapters = context.reader.widget.chapters;
if (chapters == null) return;
var chapterIndex = context.reader.chapter - 1;
var epId = chapters.ids.elementAt(chapterIndex);
var chapterTitle = chapters.titles.elementAt(chapterIndex);
showSideBar(
context,
ChapterCommentsPage(
comicId: context.reader.cid,
epId: epId,
source: source,
comicTitle: context.reader.widget.name,
chapterTitle: chapterTitle,
),
);
}
Widget buildEpChangeButton() {
final extraWidth = context.padding.left + context.padding.right;
if (context.reader.widget.chapters == null) return const SizedBox();
switch (showFloatingButtonValue) {
case 0:
return Container(
width: 58,
width: 58 + extraWidth,
height: 58,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
@@ -728,7 +751,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
case -1:
case 1:
return SizedBox(
width: 58,
width: 58 + extraWidth,
height: 58,
child: Material(
color: Theme.of(context).colorScheme.primaryContainer,
@@ -750,9 +773,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined,
size: 24,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
@@ -761,6 +782,101 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
}
return const SizedBox();
}
/// If there is only one image on screen, return it.
///
/// If there are multiple images on screen,
/// show an overlay to let the user select an image.
///
/// The return value is the index of the selected image.
Future<int?> selectImage() async {
var reader = context.reader;
var imageViewController = context.reader._imageViewController;
bool needsSelection = false;
int? singleImageIndex;
if (imageViewController is _GalleryModeState) {
var range = imageViewController.getCurrentPageImageRange();
if (range != null) {
var (startIndex, endIndex) = range;
int actualImageCount = endIndex - startIndex;
if (actualImageCount == 1) {
needsSelection = false;
singleImageIndex = startIndex;
} else {
needsSelection = true;
}
}
} else if (imageViewController is _ContinuousModeState) {
needsSelection = false;
singleImageIndex = reader.page - 1;
}
if (!needsSelection && singleImageIndex != null) {
return singleImageIndex;
} else {
var location = await _showSelectImageOverlay();
if (location == null) {
return null;
}
var imageKey = imageViewController!.getImageKeyByOffset(location);
if (imageKey == null) {
return null;
}
return reader.images!.indexOf(imageKey);
}
}
/// Same as [selectImage], but return the image data with its index.
/// Returns (imageIndex, imageData) or null if cancelled.
Future<(int, Uint8List)?> selectImageToData() async {
var i = await selectImage();
if (i == null) {
return null;
}
var imageKey = context.reader.images![i];
Uint8List data;
if (imageKey.startsWith("file://")) {
data = await File(imageKey.substring(7)).readAsBytes();
} else {
data = await (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
))!.readAsBytes();
}
return (i, data);
}
Future<Offset?> _showSelectImageOverlay() {
if (_isOpen) {
openOrClose();
}
var completer = Completer<Offset?>();
var overlay = Overlay.of(context);
OverlayEntry? entry;
entry = OverlayEntry(
builder: (context) {
return Positioned.fill(
child: _SelectImageOverlayContent(
onTap: (offset) {
completer.complete(offset);
entry!.remove();
},
onDispose: () {
if (!completer.isCompleted) {
completer.complete(null);
}
},
),
);
},
);
overlay.insert(entry);
return completer.future;
}
}
class _BatteryWidget extends StatefulWidget {
@@ -853,20 +969,17 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
size: 16,
color: batteryColor,
// Stroke
shadows: List.generate(
9,
(index) {
if (index == 4) {
return null;
}
double offsetX = (index % 3 - 1) * 0.8;
double offsetY = ((index / 3).floor() - 1) * 0.8;
return Shadow(
color: context.colorScheme.onInverseSurface,
offset: Offset(offsetX, offsetY),
);
},
).whereType<Shadow>().toList(),
shadows: List.generate(9, (index) {
if (index == 4) {
return null;
}
double offsetX = (index % 3 - 1) * 0.8;
double offsetY = ((index / 3).floor() - 1) * 0.8;
return Shadow(
color: context.colorScheme.onInverseSurface,
offset: Offset(offsetX, offsetY),
);
}).whereType<Shadow>().toList(),
),
Stack(
children: [
@@ -941,3 +1054,66 @@ class _ClockWidgetState extends State<_ClockWidget> {
);
}
}
class _SelectImageOverlayContent extends StatefulWidget {
const _SelectImageOverlayContent({
required this.onTap,
required this.onDispose,
});
final void Function(Offset) onTap;
final void Function() onDispose;
@override
State<_SelectImageOverlayContent> createState() =>
_SelectImageOverlayContentState();
}
class _SelectImageOverlayContentState
extends State<_SelectImageOverlayContent> {
@override
void dispose() {
widget.onDispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
widget.onTap(details.globalPosition);
},
child: Container(
color: Colors.black.withAlpha(50),
child: Align(
alignment: Alignment(0, -0.8),
child: Container(
width: 232,
height: 42,
decoration: BoxDecoration(
color: context.colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: context.colorScheme.outlineVariant),
),
child: Row(
children: [
const SizedBox(width: 8),
const Icon(Icons.info_outline),
const SizedBox(width: 16),
Text(
"Click to select an image".tl,
style: TextStyle(
fontSize: 16,
color: context.colorScheme.onSurface,
),
),
],
),
),
),
),
);
}
}

View File

@@ -49,7 +49,9 @@ class _SearchPageState extends State<SearchPage> {
void search([String? text]) {
if (aggregatedSearch) {
context
.to(() => AggregatedSearchPage(keyword: text ?? controller.text))
.to(
() => AggregatedSearchPage(keyword: text ?? controller.text)
)
.then((_) => update());
} else {
context
@@ -58,7 +60,7 @@ class _SearchPageState extends State<SearchPage> {
text: text ?? controller.text,
sourceKey: searchTarget,
options: options,
),
)
)
.then((_) => update());
}
@@ -376,11 +378,16 @@ class _SearchPageState extends State<SearchPage> {
controller.text =
controller.text.replaceLast(words[words.length - 1], "");
}
if (type != null) {
controller.text += "${type.name}:$text ";
final source = ComicSource.find(searchTarget);
String insert;
if (source?.onTagSuggestionSelected != null) {
insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
} else {
controller.text += "$text ";
var t = text;
if (t.contains(' ')) t = "'$t'";
insert = type != null ? "${type.name}:$t" : t;
}
controller.text += "$insert ";
suggestions.clear();
update();
focusNode.requestFocus();

View File

@@ -124,7 +124,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
options = widget.options ?? const [];
validateOptions();
appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller);
suggestionsController = _SuggestionsController(controller, sourceKey);
super.initState();
}
@@ -213,6 +213,8 @@ class _SuggestionsController {
final SearchBarController controller;
final String sourceKey;
OverlayEntry? entry;
void updateWidget() {
@@ -270,7 +272,7 @@ class _SuggestionsController {
find(TagsTranslation.cosplayerTags, TranslationType.cosplayer);
}
_SuggestionsController(this.controller);
_SuggestionsController(this.controller, this.sourceKey);
}
class _Suggestions extends StatefulWidget {
@@ -400,14 +402,16 @@ class _SuggestionsState extends State<_Suggestions> {
controller.text =
controller.text.replaceLast(words[words.length - 1], "");
}
if (text.contains(' ')) {
text = "'$text'";
}
if (type != null) {
controller.text += "${type.name}:$text ";
final source = ComicSource.find(widget.controller.sourceKey);
String insert;
if (source?.onTagSuggestionSelected != null) {
insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
} else {
controller.text += "$text ";
var t = text;
if (t.contains(' ')) t = "'$t'";
insert = type != null ? "${type.name}:$t" : t;
}
controller.text += "$insert ";
widget.controller.suggestions.clear();
widget.controller.remove();
}

View File

@@ -96,10 +96,13 @@ Future<bool> checkUpdate() async {
return false;
}
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true, bool delay = false]) async {
try {
var value = await checkUpdate();
if (value) {
if (delay) {
await Future.delayed(const Duration(seconds: 2));
}
showDialog(
context: App.rootContext,
builder: (context) {

View File

@@ -100,7 +100,7 @@ class _AppSettingsState extends State<AppSettings> {
title: "Export App Data".tl,
callback: () async {
var controller = showLoadingDialog(context);
var file = await exportAppData();
var file = await exportAppData(false);
await saveFile(filename: "data.venera", file: file);
controller.close();
},
@@ -193,12 +193,46 @@ class LogsPage extends StatefulWidget {
}
class _LogsPageState extends State<LogsPage> {
String logLevelToShow = "all";
@override
Widget build(BuildContext context) {
var logToShow = logLevelToShow == "all"
? Log.logs
: Log.logs.where((log) => log.level.name == logLevelToShow).toList();
return Scaffold(
appBar: Appbar(
title: const Text("Logs"),
title: Text("Logs".tl),
actions: [
IconButton(
onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
MediaQuery.of(context).padding.top + kToolbarHeight,
0.0,
0.0,
);
showMenu(context: context, position: position, items: [
PopupMenuItem(
child: Text("all"),
onTap: () => setState(() => logLevelToShow = "all")
),
PopupMenuItem(
child: Text("info"),
onTap: () => setState(() => logLevelToShow = "info")
),
PopupMenuItem(
child: Text("warning"),
onTap: () => setState(() => logLevelToShow = "warning")
),
PopupMenuItem(
child: Text("error"),
onTap: () => setState(() => logLevelToShow = "error")
),
]);
}),
icon: const Icon(Icons.filter_list_outlined)
),
IconButton(
onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB(
@@ -217,7 +251,7 @@ class _LogsPageState extends State<LogsPage> {
onTap: () {
Log.ignoreLimitation = true;
context.showMessage(
message: "Only valid for this run");
message: "Only valid for this run".tl);
},
),
PopupMenuItem(
@@ -232,9 +266,9 @@ class _LogsPageState extends State<LogsPage> {
body: ListView.builder(
reverse: true,
controller: ScrollController(),
itemCount: Log.logs.length,
itemCount: logToShow.length,
itemBuilder: (context, index) {
index = Log.logs.length - index - 1;
index = logToShow.length - index - 1;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: SelectionArea(
@@ -253,7 +287,7 @@ class _LogsPageState extends State<LogsPage> {
),
child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(Log.logs[index].title),
child: Text(logToShow[index].title),
),
),
const SizedBox(
@@ -265,16 +299,16 @@ class _LogsPageState extends State<LogsPage> {
Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.errorContainer,
Theme.of(context).colorScheme.primaryContainer
][Log.logs[index].level.index],
][logToShow[index].level.index],
borderRadius:
const BorderRadius.all(Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(
Log.logs[index].level.name,
logToShow[index].level.name,
style: TextStyle(
color: Log.logs[index].level.index == 0
color: logToShow[index].level.index == 0
? Colors.white
: Colors.black),
),
@@ -282,14 +316,14 @@ class _LogsPageState extends State<LogsPage> {
),
],
),
Text(Log.logs[index].content),
Text(Log.logs[index].time
Text(logToShow[index].content),
Text(logToShow[index].time
.toString()
.replaceAll(RegExp(r"\.\w+"), "")),
TextButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: Log.logs[index].content));
ClipboardData(text: logToShow[index].content));
},
child: Text("Copy".tl),
),
@@ -319,6 +353,8 @@ class _WebdavSettingState extends State<_WebdavSetting> {
String url = "";
String user = "";
String pass = "";
String disableSync = "";
bool autoSync = true;
bool isTesting = false;
@@ -330,6 +366,9 @@ class _WebdavSettingState extends State<_WebdavSetting> {
if (appdata.settings['webdav'] is! List) {
appdata.settings['webdav'] = [];
}
if (appdata.settings['disableSyncFields'].trim().isNotEmpty) {
disableSync = appdata.settings['disableSyncFields'];
}
var configs = appdata.settings['webdav'] as List;
if (configs.whereType<String>().length != 3) {
return;
@@ -384,6 +423,56 @@ class _WebdavSettingState extends State<_WebdavSetting> {
onChanged: (value) => pass = value,
),
const SizedBox(height: 12),
TextField(
decoration: InputDecoration(
labelText: "Skip Setting Fields (Optional)".tl,
hintText: "field0, field1, field2, ...",
hintStyle: TextStyle(color: Theme.of(context).hintColor),
border: OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(Icons.help_outline),
onPressed: () {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text("Skip Setting Fields".tl),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"When sync data, skip certain setting fields, which means these won't be uploaded / override.".tl,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
"See source code for available fields.".tl,
),
),
Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: const Icon(Icons.open_in_new),
onPressed: () {
launchUrlString("https://github.com/venera-app/venera/blob/b08f11f6ac49bd07d34b4fcde233ed07e86efbc9/lib/foundation/appdata.dart#L138");
},
),
),
],
),
],
),
),
);
},
),
),
controller: TextEditingController(text: disableSync),
onChanged: (value) => disableSync = value,
),
const SizedBox(height: 12),
ListTile(
leading: Icon(Icons.sync),
title: Text("Auto Sync Data".tl),
@@ -394,30 +483,26 @@ class _WebdavSettingState extends State<_WebdavSetting> {
),
),
const SizedBox(height: 12),
Row(
children: [
Text("Operation".tl),
Radio<bool>(
groupValue: upload,
value: true,
onChanged: (value) {
setState(() {
upload = value!;
});
},
),
Text("Upload".tl),
Radio<bool>(
groupValue: upload,
value: false,
onChanged: (value) {
setState(() {
upload = value!;
});
},
),
Text("Download".tl),
],
RadioGroup<bool>(
groupValue: upload,
onChanged: (value) {
setState(() {
upload = value ?? upload;
});
},
child: Row(
children: [
Text("Operation".tl),
Radio<bool>(
value: true,
),
Text("Upload".tl),
Radio<bool>(
value: false,
),
Text("Download".tl),
],
),
),
const SizedBox(height: 16),
AnimatedSize(
@@ -464,6 +549,7 @@ class _WebdavSettingState extends State<_WebdavSetting> {
}
appdata.settings['webdav'] = [url, user, pass];
appdata.settings['disableSyncFields'] = disableSync;
appdata.implicitData['webdavAutoSync'] = autoSync;
appdata.writeImplicitData();

View File

@@ -18,8 +18,8 @@ class DebugPageState extends State<DebugPage> {
slivers: [
SliverAppbar(title: Text("Debug".tl)),
_CallbackSetting(
title: "Reload Configs",
actionTitle: "Reload",
title: "Reload Configs".tl,
actionTitle: "Reload".tl,
callback: () {
ComicSourceManager().reload();
},
@@ -31,6 +31,10 @@ class DebugPageState extends State<DebugPage> {
},
actionTitle: 'Open'.tl,
).toSliver(),
_SwitchSetting(
title: "Ignore Certificate Errors".tl,
settingKey: "ignoreBadCertificate",
).toSliver(),
SliverToBoxAdapter(
child: Column(
children: [
@@ -58,7 +62,7 @@ class DebugPageState extends State<DebugPage> {
TextButton(
onPressed: () {
try {
var res = JsEngine().runCode(controller.text);
var res = JsEngine().runCode(controller.text, "<debug>");
setState(() {
result = res.toString();
});

View File

@@ -52,10 +52,18 @@ class _ExploreSettingsState extends State<ExploreSettings> {
title: "Show history on comic tile".tl,
settingKey: "showHistoryStatusOnTile",
).toSliver(),
_SwitchSetting(
title: "Reverse default chapter order".tl,
settingKey: "reverseChapterOrder",
).toSliver(),
_PopupWindowSetting(
title: "Keyword blocking".tl,
builder: () => const _ManageBlockingWordView(),
).toSliver(),
_PopupWindowSetting(
title: "Comment keyword blocking".tl,
builder: () => const _ManageBlockingCommentWordView(),
).toSliver(),
SelectSetting(
title: "Default Search Target".tl,
settingKey: "defaultSearchTarget",
@@ -246,4 +254,93 @@ Widget setSearchSourcesWidget() {
settingsIndex: "searchSources",
pages: pages,
);
}
class _ManageBlockingCommentWordView extends StatefulWidget {
const _ManageBlockingCommentWordView();
@override
State<_ManageBlockingCommentWordView> createState() =>
_ManageBlockingCommentWordViewState();
}
class _ManageBlockingCommentWordViewState extends State<_ManageBlockingCommentWordView> {
@override
Widget build(BuildContext context) {
assert(appdata.settings["blockedCommentWords"] is List);
return PopUpWidgetScaffold(
title: "Comment keyword blocking".tl,
tailing: [
TextButton.icon(
icon: const Icon(Icons.add),
label: Text("Add".tl),
onPressed: add,
),
],
body: ListView.builder(
itemCount: appdata.settings["blockedCommentWords"].length,
itemBuilder: (context, index) {
return ListTile(
title: Text(appdata.settings["blockedCommentWords"][index]),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
appdata.settings["blockedCommentWords"].removeAt(index);
appdata.saveData();
setState(() {});
},
),
);
},
),
);
}
void add() {
showDialog(
context: App.rootContext,
builder: (context) {
var controller = TextEditingController();
String? error;
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Add keyword".tl,
content: TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: Text("Keyword".tl),
errorText: error,
),
onChanged: (s) {
if (error != null) {
setState(() {
error = null;
});
}
},
).paddingHorizontal(12),
actions: [
Button.filled(
onPressed: () {
if (appdata.settings["blockedCommentWords"]
.contains(controller.text)) {
setState(() {
error = "Keyword already exists".tl;
});
return;
}
appdata.settings["blockedCommentWords"].add(controller.text);
appdata.saveData();
this.setState(() {});
context.pop();
},
child: Text("Add".tl),
),
],
);
});
},
);
}
}

View File

@@ -13,6 +13,14 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Local Favorites".tl)),
_SwitchSetting(
title: "Show local favorites before network favorites".tl,
settingKey: "localFavoritesFirst",
).toSliver(),
_SwitchSetting(
title: "Auto close favorite panel after operation".tl,
settingKey: "autoCloseFavoritePanel",
).toSliver(),
SelectSetting(
title: "Add new favorite to".tl,
settingKey: "newFavoriteAddTo",

View File

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

View File

@@ -1,9 +1,16 @@
part of 'settings_page.dart';
class ReaderSettings extends StatefulWidget {
const ReaderSettings({super.key, this.onChanged});
const ReaderSettings({
super.key,
this.onChanged,
this.comicId,
this.comicSource,
});
final void Function(String key)? onChanged;
final String? comicId;
final String? comicSource;
@override
State<ReaderSettings> createState() => _ReaderSettingsState();
@@ -12,15 +19,57 @@ class ReaderSettings extends StatefulWidget {
class _ReaderSettingsState extends State<ReaderSettings> {
@override
Widget build(BuildContext context) {
final comicId = widget.comicId;
final sourceKey = widget.comicSource;
final key = "$comicId@$sourceKey";
bool isEnabledSpecificSettings =
comicId != null &&
appdata.settings.isComicSpecificSettingsEnabled(comicId, sourceKey);
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Reading".tl)),
if (comicId != null && sourceKey != null)
SliverMainAxisGroup(
slivers: [
SwitchListTile(
title: Text("Enable comic specific settings".tl),
value: isEnabledSpecificSettings,
onChanged: (b) {
setState(() {
appdata.settings.setEnabledComicSpecificSettings(
comicId,
sourceKey,
b,
);
});
},
).toSliver(),
if (isEnabledSpecificSettings)
Center(
child: TextButton(
onPressed: () {
setState(() {
appdata.settings.resetComicReaderSettings(key);
});
},
child: Text(
"Clear specific reader settings for this comic".tl,
),
),
).toSliver(),
Divider().toSliver(),
],
),
_SwitchSetting(
title: "Tap to turn Pages".tl,
settingKey: "enableTapToTurnPages",
onChanged: () {
widget.onChanged?.call("enableTapToTurnPages");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Reverse tap to turn Pages".tl,
@@ -28,6 +77,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("reverseTapToTurnPages");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Page animation".tl,
@@ -35,6 +86,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("enablePageAnimation");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
SelectSetting(
title: "Reading mode".tl,
@@ -58,6 +111,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
}
widget.onChanged?.call("readerMode");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SliderSetting(
title: "Auto page turning interval".tl,
@@ -66,8 +121,11 @@ class _ReaderSettingsState extends State<ReaderSettings> {
min: 1,
max: 20,
onChanged: () {
setState(() {});
widget.onChanged?.call("autoPageTurningInterval");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('gallery'),
@@ -80,8 +138,11 @@ class _ReaderSettingsState extends State<ReaderSettings> {
min: 1,
max: 5,
onChanged: () {
setState(() {});
widget.onChanged?.call("readerScreenPicNumberForLandscape");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
SliverAnimatedVisibility(
@@ -97,8 +158,50 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("readerScreenPicNumberForPortrait");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
SliverAnimatedVisibility(
visible:
appdata.settings['readerMode']!.startsWith('gallery') &&
(appdata.settings['readerScreenPicNumberForLandscape'] > 1 ||
appdata.settings['readerScreenPicNumberForPortrait'] > 1),
child: _SwitchSetting(
title: "Show single image on first page".tl,
settingKey: "showSingleImageOnFirstPage",
onChanged: () {
widget.onChanged?.call("showSingleImageOnFirstPage");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('continuous'),
child: _SliderSetting(
title: "Mouse scroll speed".tl,
settingsIndex: "readerScrollSpeed",
interval: 0.1,
min: 0.5,
max: 3,
onChanged: () {
widget.onChanged?.call("readerScrollSpeed");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
_SwitchSetting(
title: 'Double tap to zoom'.tl,
settingKey: 'enableDoubleTapToZoom',
onChanged: () {
setState(() {});
widget.onChanged?.call('enableDoubleTapToZoom');
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: 'Long press to zoom'.tl,
settingKey: 'enableLongPressToZoom',
@@ -106,6 +209,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
setState(() {});
widget.onChanged?.call('enableLongPressToZoom');
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
SliverAnimatedVisibility(
visible: appdata.settings['enableLongPressToZoom'] == true,
@@ -116,6 +221,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
"press": "Press position".tl,
"center": "Screen center".tl,
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
),
),
_SwitchSetting(
@@ -125,6 +232,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call('limitImageWidth');
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
if (App.isAndroid)
_SwitchSetting(
@@ -133,6 +242,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call('enableTurnPageByVolumeKey');
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Display time & battery info in reader".tl,
@@ -140,6 +251,17 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Show system status bar".tl,
settingKey: "showSystemStatusBar",
onChanged: () {
widget.onChanged?.call("showSystemStatusBar");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
SelectSetting(
title: "Quick collect image".tl,
@@ -155,6 +277,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
help:
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
.tl,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_CallbackSetting(
title: "Custom Image Processing".tl,
@@ -167,6 +291,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
interval: 1,
min: 1,
max: 16,
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Show Page Number".tl,
@@ -174,6 +300,17 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
widget.onChanged?.call("showPageNumberInReader");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
_SwitchSetting(
title: "Show Chapter Comments".tl,
settingKey: "showChapterComments",
onChanged: () {
widget.onChanged?.call("showChapterComments");
},
comicId: isEnabledSpecificSettings ? widget.comicId : null,
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
).toSliver(),
],
);
@@ -219,7 +356,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
setState(() {});
},
child: Text("Reset".tl),
)
),
],
),
body: Column(
@@ -245,7 +382,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
),
),
),
)
),
],
),
);

View File

@@ -6,6 +6,8 @@ class _SwitchSetting extends StatefulWidget {
required this.settingKey,
this.onChanged,
this.subtitle,
this.comicId,
this.comicSource,
});
final String title;
@@ -16,6 +18,10 @@ class _SwitchSetting extends StatefulWidget {
final String? subtitle;
final String? comicId;
final String? comicSource;
@override
State<_SwitchSetting> createState() => _SwitchSettingState();
}
@@ -23,16 +29,33 @@ class _SwitchSetting extends StatefulWidget {
class _SwitchSettingState extends State<_SwitchSetting> {
@override
Widget build(BuildContext context) {
assert(appdata.settings[widget.settingKey] is bool);
var value = widget.comicId == null
? appdata.settings[widget.settingKey]
: appdata.settings.getReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
);
assert(value is bool);
return ListTile(
title: Text(widget.title),
subtitle: widget.subtitle == null ? null : Text(widget.subtitle!),
trailing: Switch(
value: appdata.settings[widget.settingKey],
value: value,
onChanged: (value) {
setState(() {
appdata.settings[widget.settingKey] = value;
if (widget.comicId == null) {
appdata.settings[widget.settingKey] = value;
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
value,
);
}
});
appdata.saveData().then((_) {
widget.onChanged?.call();
@@ -51,6 +74,8 @@ class SelectSetting extends StatelessWidget {
required this.optionTranslation,
this.onChanged,
this.help,
this.comicId,
this.comicSource,
});
final String title;
@@ -63,6 +88,10 @@ class SelectSetting extends StatelessWidget {
final String? help;
final String? comicId;
final String? comicSource;
@override
Widget build(BuildContext context) {
return SizedBox(
@@ -76,6 +105,8 @@ class SelectSetting extends StatelessWidget {
optionTranslation: optionTranslation,
onChanged: onChanged,
help: help,
comicId: comicId,
comicSource: comicSource,
);
} else {
return _EndSelectorSelectSetting(
@@ -84,6 +115,8 @@ class SelectSetting extends StatelessWidget {
optionTranslation: optionTranslation,
onChanged: onChanged,
help: help,
comicId: comicId,
comicSource: comicSource,
);
}
},
@@ -99,6 +132,8 @@ class _DoubleLineSelectSettings extends StatefulWidget {
required this.optionTranslation,
this.onChanged,
this.help,
this.comicId,
this.comicSource,
});
final String title;
@@ -111,6 +146,10 @@ class _DoubleLineSelectSettings extends StatefulWidget {
final String? help;
final String? comicId;
final String? comicSource;
@override
State<_DoubleLineSelectSettings> createState() =>
_DoubleLineSelectSettingsState();
@@ -119,6 +158,14 @@ class _DoubleLineSelectSettings extends StatefulWidget {
class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
@override
Widget build(BuildContext context) {
var value = widget.comicId == null
? appdata.settings[widget.settingKey]
: appdata.settings.getReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
);
return ListTile(
title: Row(
children: [
@@ -134,9 +181,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
builder: (context) {
return ContentDialog(
title: "Help".tl,
content: Text(widget.help!)
.paddingHorizontal(16)
.fixWidth(double.infinity),
content: Text(
widget.help!,
).paddingHorizontal(16).fixWidth(double.infinity),
actions: [
Button.filled(
onPressed: context.pop,
@@ -150,9 +197,7 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
),
],
),
subtitle: Text(
widget.optionTranslation[appdata.settings[widget.settingKey]] ??
"None"),
subtitle: Text(widget.optionTranslation[value] ?? "None"),
trailing: const Icon(Icons.arrow_drop_down),
onTap: () {
var renderBox = context.findRenderObject() as RenderBox;
@@ -170,16 +215,27 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
Offset.zero & MediaQuery.of(context).size,
),
items: widget.optionTranslation.keys
.map((key) => PopupMenuItem(
value: key,
height: App.isMobile ? 46 : 40,
child: Text(widget.optionTranslation[key]!),
))
.map(
(key) => PopupMenuItem(
value: key,
height: App.isMobile ? 46 : 40,
child: Text(widget.optionTranslation[key]!),
),
)
.toList(),
).then((value) {
if (value != null) {
setState(() {
appdata.settings[widget.settingKey] = value;
if (widget.comicId == null) {
appdata.settings[widget.settingKey] = value;
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
value,
);
}
});
appdata.saveData();
widget.onChanged?.call();
@@ -197,6 +253,8 @@ class _EndSelectorSelectSetting extends StatefulWidget {
required this.optionTranslation,
this.onChanged,
this.help,
this.comicId,
this.comicSource,
});
final String title;
@@ -209,6 +267,10 @@ class _EndSelectorSelectSetting extends StatefulWidget {
final String? help;
final String? comicId;
final String? comicSource;
@override
State<_EndSelectorSelectSetting> createState() =>
_EndSelectorSelectSettingState();
@@ -218,6 +280,13 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
@override
Widget build(BuildContext context) {
var options = widget.optionTranslation;
var value = widget.comicId == null
? appdata.settings[widget.settingKey]
: appdata.settings.getReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
);
return ListTile(
title: Row(
children: [
@@ -233,9 +302,9 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
builder: (context) {
return ContentDialog(
title: "Help".tl,
content: Text(widget.help!)
.paddingHorizontal(16)
.fixWidth(double.infinity),
content: Text(
widget.help!,
).paddingHorizontal(16).fixWidth(double.infinity),
actions: [
Button.filled(
onPressed: context.pop,
@@ -250,12 +319,22 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
],
),
trailing: Select(
current: options[appdata.settings[widget.settingKey]],
current: options[value],
values: options.values.toList(),
minWidth: 64,
onTap: (index) {
setState(() {
appdata.settings[widget.settingKey] = options.keys.elementAt(index);
var value = options.keys.elementAt(index);
if (widget.comicId == null) {
appdata.settings[widget.settingKey] = value;
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingKey,
value,
);
}
});
appdata.saveData();
widget.onChanged?.call();
@@ -273,6 +352,8 @@ class _SliderSetting extends StatefulWidget {
required this.min,
required this.max,
this.onChanged,
this.comicId,
this.comicSource,
});
final String title;
@@ -287,6 +368,10 @@ class _SliderSetting extends StatefulWidget {
final VoidCallback? onChanged;
final String? comicId;
final String? comicSource;
@override
State<_SliderSetting> createState() => _SliderSettingState();
}
@@ -294,28 +379,51 @@ class _SliderSetting extends StatefulWidget {
class _SliderSettingState extends State<_SliderSetting> {
@override
Widget build(BuildContext context) {
var value =
(widget.comicId == null
? appdata.settings[widget.settingsIndex]
: appdata.settings.getReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingsIndex,
))
.toDouble();
return ListTile(
title: Row(
children: [
Text(widget.title),
const Spacer(),
Text(
appdata.settings[widget.settingsIndex].toString(),
style: ts.s12,
),
],
title: Text(
widget.title,
softWrap: true,
maxLines: 2,
),
trailing: Text(value.toString(), style: ts.s12),
subtitle: Slider(
value: appdata.settings[widget.settingsIndex].toDouble(),
value: value,
onChanged: (value) {
if (value.toInt() == value) {
setState(() {
appdata.settings[widget.settingsIndex] = value.toInt();
if (widget.comicId == null) {
appdata.settings[widget.settingsIndex] = value.toInt();
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingsIndex,
value.toInt(),
);
}
appdata.saveData();
});
} else {
setState(() {
appdata.settings[widget.settingsIndex] = value;
if (widget.comicId == null) {
appdata.settings[widget.settingsIndex] = value;
} else {
appdata.settings.setReaderSetting(
widget.comicId!,
widget.comicSource!,
widget.settingsIndex,
value,
);
}
appdata.saveData();
});
}
@@ -402,10 +510,11 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
color: Theme.of(context).colorScheme.surfaceContainer,
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 5,
offset: Offset(0, 2),
spreadRadius: 2)
color: Colors.black12,
blurRadius: 5,
offset: Offset(0, 2),
spreadRadius: 2,
),
],
),
onReorder: (reorderFunc) {
@@ -435,7 +544,7 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
label: Text("Add".tl),
icon: const Icon(Icons.add),
onPressed: showAddDialog,
)
),
],
body: view,
);
@@ -445,12 +554,13 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
Widget removeButton = Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
onPressed: () {
setState(() {
keys.remove(key);
});
},
icon: const Icon(Icons.delete_outline)),
onPressed: () {
setState(() {
keys.remove(key);
});
},
icon: const Icon(Icons.delete_outline),
),
);
return ListTile(
@@ -458,10 +568,7 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
key: Key(key),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
removeButton,
const Icon(Icons.drag_handle),
],
children: [removeButton, const Icon(Icons.drag_handle)],
),
);
}
@@ -477,64 +584,66 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Add".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: canAdd.entries
.map(
(e) => CheckboxListTile(
value: selected.contains(e.key),
title: Text(e.value),
key: Key(e.key),
onChanged: (value) {
setState(() {
if (value!) {
selected.add(e.key);
} else {
selected.remove(e.key);
}
});
},
),
)
.toList(),
),
actions: [
if (selected.length < canAdd.length)
TextButton(
child: Text("Select All".tl),
onPressed: () {
setState(() {
selected = canAdd.keys.toList();
});
},
)
else
TextButton(
child: Text("Deselect All".tl),
onPressed: () {
setState(() {
selected.clear();
});
},
),
const SizedBox(width: 8),
FilledButton(
onPressed: selected.isNotEmpty
? () {
this.setState(() {
keys.addAll(selected);
});
Navigator.pop(context);
}
: null,
child: Text("Add".tl),
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: "Add".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: canAdd.entries
.map(
(e) => CheckboxListTile(
value: selected.contains(e.key),
title: Text(e.value),
key: Key(e.key),
onChanged: (value) {
setState(() {
if (value!) {
selected.add(e.key);
} else {
selected.remove(e.key);
}
});
},
),
)
.toList(),
),
],
);
});
actions: [
if (selected.length < canAdd.length)
TextButton(
child: Text("Select All".tl),
onPressed: () {
setState(() {
selected = canAdd.keys.toList();
});
},
)
else
TextButton(
child: Text("Deselect All".tl),
onPressed: () {
setState(() {
selected.clear();
});
},
),
const SizedBox(width: 8),
FilledButton(
onPressed: selected.isNotEmpty
? () {
this.setState(() {
keys.addAll(selected);
});
Navigator.pop(context);
}
: null,
child: Text("Add".tl),
),
],
);
},
);
},
);
}

View File

@@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
@@ -41,7 +40,7 @@ class SettingsPage extends StatefulWidget {
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> implements PopEntry {
class _SettingsPageState extends State<SettingsPage> {
int currentPage = -1;
ColorScheme get colors => Theme.of(context).colorScheme;
@@ -70,84 +69,14 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
Icons.bug_report,
];
double offset = 0;
late final HorizontalDragGestureRecognizer gestureRecognizer;
ModalRoute? _route;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final ModalRoute<dynamic>? nextRoute = ModalRoute.of(context);
if (nextRoute != _route) {
_route?.unregisterPopEntry(this);
_route = nextRoute;
_route?.registerPopEntry(this);
}
}
@override
void initState() {
currentPage = widget.initialPage;
gestureRecognizer = HorizontalDragGestureRecognizer(debugOwner: this)
..onUpdate = ((details) => setState(() => offset += details.delta.dx))
..onEnd = (details) async {
if (details.velocity.pixelsPerSecond.dx.abs() > 1 &&
details.velocity.pixelsPerSecond.dx >= 0) {
setState(() {
Future.delayed(const Duration(milliseconds: 300), () => offset = 0);
currentPage = -1;
});
} else if (offset > MediaQuery.of(context).size.width / 2) {
setState(() {
Future.delayed(const Duration(milliseconds: 300), () => offset = 0);
currentPage = -1;
});
} else {
int i = 10;
while (offset != 0) {
setState(() {
offset -= i;
i *= 10;
if (offset < 0) {
offset = 0;
}
});
await Future.delayed(const Duration(milliseconds: 10));
}
}
}
..onCancel = () async {
int i = 10;
while (offset != 0) {
setState(() {
offset -= i;
i *= 10;
if (offset < 0) {
offset = 0;
}
});
await Future.delayed(const Duration(milliseconds: 10));
}
};
super.initState();
}
@override
dispose() {
super.dispose();
gestureRecognizer.dispose();
_route?.unregisterPopEntry(this);
}
@override
Widget build(BuildContext context) {
if (currentPage != -1) {
canPop.value = false;
} else {
canPop.value = true;
}
return Material(
child: buildBody(),
);
@@ -209,51 +138,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
],
);
} else {
return LayoutBuilder(
builder: (context, constrains) {
return Stack(
children: [
Positioned.fill(child: buildLeft()),
Positioned(
left: offset,
width: constrains.maxWidth,
top: 0,
bottom: 0,
child: Listener(
onPointerDown: handlePointerDown,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
transitionBuilder: (child, animation) {
var tween = Tween<Offset>(
begin: const Offset(1, 0), end: const Offset(0, 0));
return SlideTransition(
position: tween.animate(animation),
child: child,
);
},
child: Material(
key: ValueKey(currentPage),
child: buildRight(),
),
),
),
)
],
);
},
);
}
}
void handlePointerDown(PointerDownEvent event) {
if (!App.isIOS) {
return;
}
if (event.position.dx < 20) {
gestureRecognizer.addPointer(event);
return buildLeft();
}
}
@@ -333,7 +218,13 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
? const EdgeInsets.fromLTRB(8, 0, 8, 0)
: EdgeInsets.zero,
child: InkWell(
onTap: () => setState(() => currentPage = id),
onTap: () {
if (enableTwoViews) {
setState(() => currentPage = id);
} else {
context.to(() => _SettingsDetailPage(pageIndex: id));
}
},
child: content,
).paddingVertical(4),
);
@@ -347,8 +238,23 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
}
Widget buildRight() {
return switch (currentPage) {
-1 => const SizedBox(),
if (currentPage == -1) {
return const SizedBox();
}
return Navigator(
onGenerateRoute: (settings) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) {
return _buildSettingsContent(currentPage);
},
transitionDuration: Duration.zero,
);
},
);
}
Widget _buildSettingsContent(int pageIndex) {
return switch (pageIndex) {
0 => const ExploreSettings(),
1 => const ReaderSettings(),
2 => const AppearanceSettings(),
@@ -361,26 +267,31 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
};
}
var canPop = ValueNotifier(true);
}
class _SettingsDetailPage extends StatelessWidget {
const _SettingsDetailPage({required this.pageIndex});
final int pageIndex;
@override
ValueListenable<bool> get canPopNotifier => canPop;
@override
void onPopInvokedWithResult(bool didPop, result) {
if (currentPage != -1) {
setState(() {
currentPage = -1;
});
}
Widget build(BuildContext context) {
return Material(
child: _buildPage(),
);
}
@override
void onPopInvoked(bool didPop) {
if (currentPage != -1) {
setState(() {
currentPage = -1;
});
}
Widget _buildPage() {
return switch (pageIndex) {
0 => const ExploreSettings(),
1 => const ReaderSettings(),
2 => const AppearanceSettings(),
3 => const LocalFavoritesSettings(),
4 => const AppSettings(),
5 => const NetworkSettings(),
6 => const AboutSettings(),
7 => const DebugPage(),
_ => throw UnimplementedError()
};
}
}

View File

@@ -9,7 +9,7 @@ import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'dart:io' as io;
@@ -308,7 +308,7 @@ class DesktopWebview {
useWindowPositionAndSize: true,
userDataFolderWindows: "${App.dataPath}\\webview",
title: "webview",
proxy: AppDio.proxy,
proxy: await getProxy(),
));
_webview!.addOnWebMessageReceivedCallback(onMessage);
_webview!.setOnNavigation((s) {

View File

@@ -112,7 +112,7 @@ abstract class CBZ {
var ext = e.path.split('.').last;
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
});
if(files.isEmpty) {
if (files.isEmpty) {
cache.deleteSync(recursive: true);
throw Exception('No images found in the archive');
}
@@ -141,8 +141,7 @@ abstract class CBZ {
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
);
dest.createSync();
coverFile.copyMem(
FilePath.join(dest.path, 'cover.${coverFile.extension}'));
coverFile.copyMem(FilePath.join(dest.path, 'cover.${coverFile.extension}'));
if (metaData.chapters == null) {
for (var i = 0; i < files.length; i++) {
var src = files[i];
@@ -233,17 +232,19 @@ abstract class CBZ {
}
}
var cover = comic.coverFile;
await cover
.copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
await cover.copyMem(
FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
final metaData = ComicMetaData(
title: comic.title,
author: comic.subtitle,
tags: comic.tags,
chapters: chapters,
);
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
jsonEncode(
ComicMetaData(
title: comic.title,
author: comic.subtitle,
tags: comic.tags,
chapters: chapters,
).toJson(),
),
jsonEncode(metaData),
);
await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString(
_buildComicInfoXml(metaData),
);
var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync();
@@ -252,7 +253,54 @@ abstract class CBZ {
return cbz;
}
static String _buildComicInfoXml(ComicMetaData data) {
final buffer = StringBuffer();
buffer.writeln('<?xml version="1.0" encoding="utf-8"?>');
buffer.writeln('<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">');
buffer.writeln(' <Title>${_escapeXml(data.title)}</Title>');
buffer.writeln(' <Series>${_escapeXml(data.title)}</Series>');
if (data.author.isNotEmpty) {
buffer.writeln(' <Writer>${_escapeXml(data.author)}</Writer>');
}
if (data.tags.isNotEmpty) {
var tags = data.tags;
if (tags.length > 5) {
tags = tags.sublist(0, 5);
}
buffer.writeln(' <Genre>${_escapeXml(tags.join(', '))}</Genre>');
}
if (data.chapters != null && data.chapters!.isNotEmpty) {
final chaptersInfo = data.chapters!.map((chapter) =>
'${_escapeXml(chapter.title)}: ${chapter.start}-${chapter.end}'
).join('; ');
buffer.writeln(' <Notes>Chapters: $chaptersInfo</Notes>');
}
buffer.writeln(' <Manga>Unknown</Manga>');
buffer.writeln(' <BlackAndWhite>Unknown</BlackAndWhite>');
final now = DateTime.now();
buffer.writeln(' <Year>${now.year}</Year>');
buffer.writeln('</ComicInfo>');
return buffer.toString();
}
static String _escapeXml(String text) {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
static _compress(String src, String dst) async {
await ZipFile.compressFolderAsync(src, dst, 4);
}
}

58
lib/utils/channel.dart Normal file
View File

@@ -0,0 +1,58 @@
import 'dart:async';
import 'dart:collection';
class Channel<T> {
final Queue<T> _queue;
final int size;
Channel(this.size) : _queue = Queue<T>();
Completer? _releaseCompleter;
Completer? _pushCompleter;
var currentSize = 0;
var isClosed = false;
Future<void> push(T item) async {
if (currentSize >= size) {
_releaseCompleter ??= Completer();
return _releaseCompleter!.future.then((_) {
if (isClosed) {
return;
}
_queue.addLast(item);
currentSize++;
});
}
_queue.addLast(item);
currentSize++;
_pushCompleter?.complete();
_pushCompleter = null;
}
Future<T?> pop() async {
while (_queue.isEmpty) {
if (isClosed) {
return null;
}
_pushCompleter ??= Completer();
await _pushCompleter!.future;
}
var item = _queue.removeFirst();
currentSize--;
if (_releaseCompleter != null && currentSize < size) {
_releaseCompleter!.complete();
_releaseCompleter = null;
}
return item;
}
void close() {
isClosed = true;
_pushCompleter?.complete();
_releaseCompleter?.complete();
}
}

View File

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

View File

@@ -11,7 +11,6 @@ import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/data.dart';
import 'package:venera/utils/ext.dart';
import 'package:webdav_client/webdav_client.dart' hide File;
import 'package:rhttp/rhttp.dart' as rhttp;
import 'package:venera/utils/translations.dart';
import 'io.dart';
@@ -23,10 +22,12 @@ class DataSync with ChangeNotifier {
}
LocalFavoritesManager().addListener(onDataChanged);
ComicSourceManager().addListener(onDataChanged);
Future.delayed(const Duration(seconds: 1), () {
var controller = WindowFrame.of(App.rootContext);
controller.addCloseListener(_handleWindowClose);
});
if (App.isDesktop) {
Future.delayed(const Duration(seconds: 1), () {
var controller = WindowFrame.of(App.rootContext);
controller.addCloseListener(_handleWindowClose);
});
}
}
void onDataChanged() {
@@ -119,25 +120,19 @@ class DataSync with ChangeNotifier {
String user = config[1];
String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient(
url,
user: user,
password: pass,
adapter: RHttpAdapter(
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
userAgent: "venera v${App.version}",
),
),
adapter: RHttpAdapter(),
);
try {
appdata.settings['dataVersion']++;
await appdata.saveData(false);
var data = await exportAppData();
var data = await exportAppData(
appdata.settings['disableSyncFields'].toString().isNotEmpty
);
var time =
(DateTime.now().millisecondsSinceEpoch ~/ 86400000).toString();
var filename = time;
@@ -192,19 +187,11 @@ class DataSync with ChangeNotifier {
String user = config[1];
String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient(
url,
user: user,
password: pass,
adapter: RHttpAdapter(
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
userAgent: "venera v${App.version}",
),
),
adapter: RHttpAdapter(),
);
try {

View File

@@ -107,4 +107,15 @@ abstract class MapOrNull{
static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? i){
return i == null ? null : Map<K, V>.from(i);
}
}
extension FutureExt<T> on Future<T>{
/// Wrap the future to make sure it will return at least the duration.
Future<T> minTime(Duration duration) async {
var res = await Future.wait([
this,
Future.delayed(duration),
]);
return res[0];
}
}

View File

@@ -28,6 +28,8 @@ final _resolver = MimeTypeResolver()
..addMagicNumber([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed')
// rar
..addMagicNumber([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar')
// avif
..addMagicNumber([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 'image/avif')
;
FileType detectFileType(List<int> data) {

View File

@@ -132,15 +132,15 @@ extension DirectoryExtension on Directory {
/// Sanitize the file name. Remove invalid characters and trim the file name.
String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
if (fileName.endsWith('.')) {
while (fileName.endsWith('.')) {
fileName = fileName.substring(0, fileName.length - 1);
}
var maxLength = 255;
var length = maxLength ?? 255;
if (dir != null) {
if (!dir.endsWith('/') && !dir.endsWith('\\')) {
dir = "$dir/";
}
maxLength -= dir.length;
length -= dir.length;
}
final invalidChars = RegExp(r'[<>:"/\\|?*]');
final sanitizedFileName = fileName.replaceAll(invalidChars, ' ');
@@ -148,11 +148,11 @@ String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
if (trimmedFileName.isEmpty) {
throw Exception('Invalid File Name: Empty length.');
}
if (maxLength <= 0) {
if (length <= 0) {
throw Exception('Invalid File Name: Max length is less than 0.');
}
if (trimmedFileName.length > maxLength) {
trimmedFileName = trimmedFileName.substring(0, maxLength);
if (trimmedFileName.length > length) {
trimmedFileName = trimmedFileName.substring(0, length);
}
return trimmedFileName;
}
@@ -362,7 +362,7 @@ Future<void> saveFile(
}
}
class _IOOverrides extends IOOverrides {
final class _IOOverrides extends IOOverrides {
@override
Directory createDirectory(String path) {
if (App.isAndroid) {

67
lib/utils/opencc.dart Normal file
View File

@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:flutter/services.dart';
abstract class OpenCC {
static late final Map<int, int> _s2t;
static late final Map<int, int> _t2s;
static Future<void> init() async {
var data = await rootBundle.load("assets/opencc.txt");
var txt = utf8.decode(data.buffer.asUint8List());
_s2t = <int, int>{};
_t2s = <int, int>{};
for (var line in txt.split('\n')) {
if (line.isEmpty || line.startsWith('#') || line.length != 2) continue;
var s = line.runes.elementAt(0);
var t = line.runes.elementAt(1);
_s2t[s] = t;
_t2s[t] = s;
}
}
static bool hasChineseSimplified(String text) {
if (text != "监禁") {
return false;
}
for (var rune in text.runes) {
if (_s2t.containsKey(rune)) {
return true;
}
}
return false;
}
static bool hasChineseTraditional(String text) {
for (var rune in text.runes) {
if (_t2s.containsKey(rune)) {
return true;
}
}
return false;
}
static String simplifiedToTraditional(String text) {
var sb = StringBuffer();
for (var rune in text.runes) {
if (_s2t.containsKey(rune)) {
sb.write(String.fromCharCodes([_s2t[rune]!]));
} else {
sb.write(String.fromCharCodes([rune]));
}
}
return sb.toString();
}
static String traditionalToSimplified(String text) {
var sb = StringBuffer();
for (var rune in text.runes) {
if (_t2s.containsKey(rune)) {
sb.write(String.fromCharCodes([_t2s[rune]!]));
} else {
sb.write(String.fromCharCodes([rune]));
}
}
return sb.toString();
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/image.dart';
@@ -74,6 +75,9 @@ Future<Isolate> _runIsolate(
return Isolate.spawn<SendPort>(
(sendPort) => overrideIO(
() async {
if (App.isAndroid) {
await SAFTaskWorker().init();
}
var receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);

View File

@@ -35,8 +35,10 @@ extension TagsTranslation on String{
/// 对tag进行处理后进行翻译: 代表'或'的分割符'|', namespace.
static String _translateTags(String tag){
if (tag.contains('|')) {
var splits = tag.split(' | ');
return enTagsTranslations[splits[0]]??enTagsTranslations[splits[1]]??tag;
var splits = tag.split('|');
return enTagsTranslations[splits[0].trim()]
?? enTagsTranslations[splits[1].trim()]
?? tag;
} else if(tag.contains(':')) {
var splits = tag.split(':');
if(_haveNamespace(splits[0])) {

28
patch/font.dart Normal file
View File

@@ -0,0 +1,28 @@
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:dio/dio.dart';
void main() async {
const harmonySansLink = "https://developer.huawei.com/images/download/general/HarmonyOS-Sans.zip";
var dio = Dio();
await dio.download(harmonySansLink, "HarmonyOS-Sans.zip");
await extractFileToDisk("HarmonyOS-Sans.zip", "./assets/");
File("HarmonyOS-Sans.zip").deleteSync();
var pubspec = await File("pubspec.yaml").readAsString();
pubspec = pubspec.replaceFirst("# fonts:",
""" fonts:
- family: HarmonyOS Sans
fonts:
- asset: assets/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf
""");
await File("pubspec.yaml").writeAsString(pubspec);
var mainDart = await File("lib/main.dart").readAsString();
mainDart = mainDart.replaceFirst("Noto Sans CJK", "HarmonyOS Sans");
await File("lib/main.dart").writeAsString(mainDart);
print("Successfully patched font.");
}

Some files were not shown because too many files have changed in this diff Show More