346 Commits

Author SHA1 Message Date
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
nyne
97768b4945 Merge pull request #317 from venera-app/v1.4.0-dev
V1.4.0
2025-04-05 22:06:21 +08:00
2481780ab3 fix issues reported by analyzer. 2025-04-05 22:03:54 +08:00
nyne
49481bfa6a Fix windows arm64 build script 2025-04-05 21:32:31 +08:00
211850d73e Improve comic source importing UI 2025-04-05 21:22:00 +08:00
fcf0334d55 Fix the issue that the downloaded chapters was not saved when download a comic without select chapters. Close #305 2025-04-05 20:58:06 +08:00
aa8eec5792 Improve UI. 2025-04-05 20:48:04 +08:00
6eb0060dd6 Add debug page. 2025-04-05 20:29:30 +08:00
c096f5a2d8 Add dynamic category part. 2025-04-05 20:11:05 +08:00
554b9f2a77 Fix search sources in search results page. 2025-04-05 19:31:41 +08:00
f87afbe397 Fix issues with empty chapter list. 2025-04-05 18:00:55 +08:00
6ff30f8ac3 Improve chapter display. 2025-04-05 17:48:49 +08:00
118941f239 Fix the mouse scrolling issue when multiple scroll lists are nested. 2025-04-05 17:45:29 +08:00
d91bca6913 [Comic Source] Improve data conversion 2025-04-05 17:18:53 +08:00
463ad5b5bc [Comic Source] New model PageJumpTarget. All page jump operations now use PageJumpTarget. 2025-04-04 22:47:43 +08:00
971fc1da92 Update version code. 2025-04-03 13:04:25 +08:00
37af7e266a Allow changing chapter by volume key. Close #250 2025-04-03 13:03:39 +08:00
276e23354d Smooth scroll for comments page. 2025-04-03 11:53:43 +08:00
3da00595b7 Add a setting for long press position. Close #287 2025-04-02 16:23:51 +08:00
nyne
d3c115ee0c fix log 2025-04-02 09:41:10 +08:00
dcc94c5b3d Fix crash on Android. 2025-04-01 21:07:29 +08:00
a116b5b615 Update AGP to 8.9.0 2025-04-01 20:36:24 +08:00
05fcb23a4d Limit download directory length. Close #311 2025-04-01 15:49:22 +08:00
daa6e8ce18 Show comic pages in details page. 2025-04-01 15:13:09 +08:00
8665994572 Write logs to file. 2025-04-01 14:57:11 +08:00
90441af989 Fix the issue where local comics page can not been opened when there is a comic with empty chapter list. Close #309 2025-03-31 16:10:14 +08:00
7631fab86b Prevent window from closing while uploading data 2025-03-31 15:46:41 +08:00
cd9b07bb3e Fix restoring window placement on linux 2025-03-31 12:26:32 +08:00
6c179ceb95 Add UA to WebDav requests. Close #308 2025-03-30 18:27:52 +08:00
ec48dbef57 Update linux icon 2025-03-30 18:23:43 +08:00
cd1cc1229e Remove native linux window decoration. 2025-03-30 15:42:43 +08:00
nyne
bda299e1f8 Merge pull request #304 from venera-app/v1.3.4-dev
V1.3.4
2025-03-28 19:23:50 +08:00
nyne
78ea129564 fix analyze error 2025-03-28 19:22:02 +08:00
nyne
f3b4598bb6 Fix the issue of not being able to read local comics. 2025-03-28 18:54:32 +08:00
nyne
7bc4c69a32 Add windows arm64 build script 2025-03-28 18:29:56 +08:00
nyne
a8e55e0151 Improve the long press to zoom feature. 2025-03-28 18:03:44 +08:00
nyne
fddd959545 fix windows arm64 build. 2025-03-28 18:02:36 +08:00
nyne
ebf6846bf1 fix windows arm64 build. 2025-03-28 16:18:23 +08:00
0f2d0bb9f9 Update version code. 2025-03-28 10:59:48 +08:00
48338e4ef7 Fix implicit data writing. Close #280 2025-03-28 10:58:47 +08:00
8d8e345d82 Fix invalid space when using Galley mode with multiple images on screen. Close #277 2025-03-27 23:00:06 +08:00
nyne
fcbf6a6277 Update issue checker 2025-03-27 21:28:07 +08:00
d83d679eb9 Implement writeImageToClipboard on macOS. 2025-03-27 19:40:51 +08:00
d6087e5f59 Implement writeImageToClipboard on Linux. 2025-03-27 14:52:05 +08:00
37371bee6c Merge remote-tracking branch 'origin/linux-window' into v1.3.4-dev
# Conflicts:
#	assets/translation.json
2025-03-27 13:13:18 +08:00
45fe5f503a Improve blur effect. 2025-03-27 13:11:20 +08:00
d440ed6424 Improve the long press to zoom feature.
Close #287
2025-03-27 13:04:19 +08:00
d812332613 Add image copy functionality.
Currently only supports Windows.
Close #260
2025-03-26 22:50:00 +08:00
dee8d17b1e Increase the range of comic tile size. Close #275 2025-03-26 19:55:56 +08:00
nyne
c0d461ebd9 Update prompt. 2025-03-26 18:41:27 +08:00
nyne
45e2a1142a Update model 2025-03-26 18:25:36 +08:00
nyne
533c2b2507 Update issue_check.yml 2025-03-26 18:15:52 +08:00
nyne
29b7e0d646 Add a workflow to check issues. 2025-03-26 17:47:59 +08:00
b1870b65d6 Translations for page selector. Close #286 2025-03-25 16:49:44 +08:00
1103076009 Improved page switching via keyboard. Close #293 2025-03-25 16:36:08 +08:00
51739355c8 Add clipboard methods to js engine. 2025-03-25 16:24:05 +08:00
1b4f67b314 The line starts with 'class' is considered as first line. 2025-03-25 16:18:43 +08:00
d9b23dadf0 Improve linux window. 2025-03-24 21:34:38 +08:00
ba8831caa6 Add option to show page number in reader settings 2025-03-24 18:54:48 +08:00
2b1684b0fc Added a 'Back to Top' button. Close #276 2025-03-23 17:11:23 +08:00
cd3f09efae Make sure the app quits when the window is closed. 2025-03-23 16:48:07 +08:00
d05eaf8c7e Improve WebDav UI 2025-03-23 16:42:55 +08:00
03628f2afa Improve gesture for continuous mode. 2025-03-22 11:11:20 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
9dae28e366 add missing targets (#282)
check for macOS
2025-03-19 18:58:58 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
11e66328c4 add rust-toolchain.toml (#281) 2025-03-19 16:51:24 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
73d4e28ed0 pin rust to 1.85.1 for android (#279) 2025-03-19 13:47:27 +08:00
nyne
169676fd9e Merge pull request #274 from venera-app/v1.3.3-dev
V1.3.3
2025-03-18 17:48:21 +08:00
332497cf90 Increase scroll indicator size. 2025-03-18 17:42:15 +08:00
5f15c08eef Update version code and dependencies. Close #265 2025-03-18 17:09:58 +08:00
3f6b3152b2 [Android] Support opening search page with shared text. Close #261 2025-03-18 16:19:32 +08:00
f5b3b36acb Fix webdav proxy. 2025-03-18 13:08:10 +08:00
fd8607777e Fix deleted comic sources will be restore after webdav sync. 2025-03-18 11:06:30 +08:00
fa951cac95 Support multiple webdav authentication methods. Close #271 2025-03-18 10:52:01 +08:00
55ad652191 Fix an issue where it was impossible to read a new chapter of a downloaded comic. Close #256 2025-03-17 19:18:18 +08:00
533497ead1 Improve aggregated search ui. 2025-03-17 18:51:27 +08:00
角砂糖
00cdc18ddd NDK r28 (#267) 2025-03-16 08:02:05 +08:00
角砂糖
474d9aa6f1 Default to displaying the last read chapter group. (#264) 2025-03-16 07:58:28 +08:00
ffa0c8f887 Prefer noto fonts. 2025-03-15 17:53:52 +08:00
0f3f3ea270 Add continuous mode for comic list. 2025-03-15 17:43:43 +08:00
ɴᴇᴋᴏ
b752caa079 Update translation.json (#257) 2025-03-14 09:34:30 +08:00
309df2143b Make sure the follow updates is initialized correctly. 2025-03-13 16:09:17 +08:00
8e964468ea Fix an issue where error message from webdav is null. 2025-03-13 15:54:18 +08:00
ca8f09807b Add a refresh button to network favorites page. 2025-03-09 16:38:39 +08:00
68b214e295 Fix comic list was not updated after delete a comic in favorites page. 2025-03-09 16:34:50 +08:00
00c0a64de0 Improve scroll bar of favorites page. 2025-03-09 12:55:36 +08:00
nyne
dbc2c27db0 Merge pull request #245 from venera-app/v1.3.2-dev
v1.3.2
2025-03-06 13:16:57 +08:00
fffb3dc973 Use rhttp to make webdav requests. 2025-03-05 21:47:28 +08:00
0ca8a28639 Update version code. 2025-03-05 17:46:07 +08:00
6426ebaf16 Add initial page setting. Close #240 2025-03-05 17:44:20 +08:00
316f61394d Try to fix #241 2025-03-04 22:17:21 +08:00
04ab75cf92 Fix WindowFrame on Android. 2025-03-04 21:50:22 +08:00
4828a57e1a Improve follow updates. Close #235 2025-03-04 19:30:24 +08:00
d089163220 Fix comment overflow. Close #237 2025-03-04 15:36:02 +08:00
7b5c13200d Improve init. Close #236 2025-03-04 15:30:40 +08:00
0f6874f8d7 Close reader when user click the close button on window frame. 2025-03-03 20:50:11 +08:00
4af15b9139 Improve fullscreen 2025-03-03 19:28:20 +08:00
9fe49217dc Add error status to data sync component. 2025-03-03 19:04:16 +08:00
76c56964a5 Fix archive download when using custom download path on Android. 2025-03-02 17:40:04 +08:00
e8afbca7b2 Fix empty reader page when current chapter is last chapter of the chapter group. 2025-03-01 09:29:04 +08:00
5843d7c919 Fix sidebar. 2025-03-01 09:20:33 +08:00
shenmo
de98dfaa1b Fix font problem on Linux ARM64 (#231) 2025-02-27 23:04:36 +08:00
AnxuNA
30cbfb54ef Fix Fullscreen switch (#229)
* Fix Fullscreen switch for windows

* Fix Fullscreen switch for windows
2025-02-26 10:43:28 +08:00
c633021963 Merge remote-tracking branch 'origin/master' into v1.3.2-dev 2025-02-26 09:34:02 +08:00
nyne
4640831e69 Disable blank issue 2025-02-25 21:43:30 +08:00
af7a7c220e Update issue templates. 2025-02-25 21:34:59 +08:00
fd19f6bf7d Fixed crash caused by empty chapter list. 2025-02-23 20:24:43 +08:00
96b4125613 Update issue template 2025-02-23 18:40:07 +08:00
nyne
587c5d8040 Merge pull request #222 from venera-app/v1.3.1-dev
V1.3.1
2025-02-22 21:36:44 +08:00
nyne
72730361c8 Merge branch 'master' into v1.3.1-dev 2025-02-22 21:34:56 +08:00
38d5563534 Show download status on reader chapter view. 2025-02-22 21:08:30 +08:00
5a886f7504 Improve ui 2025-02-22 19:31:23 +08:00
1464b7d5e5 Improve changing chapter gesture with continuous mode. 2025-02-22 11:33:58 +08:00
5645d805f5 Improve changing chapter gesture with continuous mode. 2025-02-22 10:41:56 +08:00
7fe81ae418 Improve switch pages gesture with gallery mode. 2025-02-21 22:53:01 +08:00
be0daddd82 Notify changes after the updating is completed. 2025-02-21 17:01:12 +08:00
buste
3efc4794d0 Fix webdav prevent immediate upload when webdavAutoSync toggle (#221) 2025-02-21 16:46:22 +08:00
角砂糖
4eff50dbed Fix history of maxPage when maxPage in reader is 1 (#220)
Due to the change of page and maxPage before, the history of maxPage should be real maxPage.
If not, when maxPage in reader is 1, the maxPage in history will be none or the last ep's real maxPage.
2025-02-21 14:25:38 +08:00
f3c191f7f3 update dependencies. 2025-02-21 14:24:36 +08:00
a014587a94 Do not switch chapters if the current chapter is the first or last chapter in the chapter group. 2025-02-21 14:13:05 +08:00
bf51cd5cee Improve checking follow updates. 2025-02-21 13:36:14 +08:00
3f10473fb6 Fix invalid number of available source updates. 2025-02-21 13:21:03 +08:00
fba49233c8 Refactor 2025-02-21 13:14:28 +08:00
8adf61b54f Fix multi-image mode 2025-02-21 13:00:15 +08:00
nyne
e829f567e5 Revert "Improve WebDAV data sync version handling and force sync (#207)" (#218)
This reverts commit a630771f0b.
2025-02-21 10:25:06 +08:00
ɴᴇᴋᴏ
701573ee19 Update zh-TW (#217) 2025-02-21 09:13:15 +08:00
角砂糖
7b601058eb Change history of page and maxPage (#216) 2025-02-21 09:12:53 +08:00
角砂糖
24b7319bb5 Add option to differentiate images per page for landscape and portrait orientations (#214) 2025-02-21 09:12:01 +08:00
角砂糖
26adfc6c4f Fix missing chapterGroup when continueRead (#213) 2025-02-21 09:09:01 +08:00
6db00eaf71 Fix variable type 2025-02-20 23:05:54 +08:00
buste
bbf31a4bbe Add AppImage build support (#210) 2025-02-20 22:59:08 +08:00
36ab104c81 Update version code. 2025-02-20 19:33:47 +08:00
a63d458707 Improve history with grouped chapters. 2025-02-20 19:16:26 +08:00
011619340f Fixed the update time was not updated after checking. 2025-02-20 16:21:39 +08:00
40b9b5b329 Fixed downloading cover. Close #208 2025-02-20 13:25:56 +08:00
edc2cb066b Fixed download speed display. 2025-02-20 13:16:09 +08:00
bd5d10e919 Improve comic chapters. 2025-02-20 13:08:55 +08:00
2b3c7a8564 Add a button to mark all comics as read. 2025-02-19 22:51:02 +08:00
buste
a630771f0b Improve WebDAV data sync version handling and force sync (#207)
* Fix WebDAV auto sync default setting initialization

* Improve WebDAV data sync version handling and  force sync
2025-02-19 22:43:23 +08:00
ee0da9a26a Fix the wrong sorting of follow_updates_page. Close #206 2025-02-19 22:38:18 +08:00
a471e79ef2 Improve init 2025-02-19 17:32:05 +08:00
26a1d68913 Fix invalid template. 2025-02-19 16:45:55 +08:00
buste
d0d27206cd Fix thumbnail tap functionality to navigate to the correct reader page (#205) 2025-02-19 11:09:18 +08:00
buste
90f0c9dab3 Fix comic menu cannot work in history_page when use mobile device (#204) 2025-02-18 23:11:51 +08:00
buste
0c54a9be11 Improve WebDAV: add auto sync option and improve settings UI (#203) 2025-02-18 22:22:09 +08:00
5fb0d2327d Improve history display. Part of #200 2025-02-18 19:39:15 +08:00
d73e152cec Fixed an issue where clicking on local comics on the home page would cause the history to be lost. Close #196 2025-02-18 18:49:25 +08:00
bd53416968 Improve webdav settings 2025-02-18 18:37:57 +08:00
buste
c28f4d40c2 Add selection in history page and refresh home page after history changed (#199) 2025-02-18 11:30:30 +08:00
nyne
7994ffb6a4 Merge pull request #197 from venera-app/v1.3.0-dev
V1.3.0
2025-02-15 22:35:27 +08:00
b8e4cc5937 translate tags on block dialog. 2025-02-15 21:51:05 +08:00
14837e2543 Fixes an issue where the read status was not updated. 2025-02-15 21:36:51 +08:00
afd3bfb7f5 Fix chapters display 2025-02-15 21:27:43 +08:00
d004fcd944 Improve updating configs. 2025-02-15 18:21:21 +08:00
3ff2f6aa36 When adding a favorite, also add the update time. 2025-02-15 16:32:51 +08:00
5c162d2800 Display number on local favorites. 2025-02-15 16:16:06 +08:00
198966920e Add "Copy Title" to local favorites page. 2025-02-15 16:08:15 +08:00
317e0f87e5 Add follow updates feature. Close #189 2025-02-15 16:05:38 +08:00
562ac9a95b Improve UI. 2025-02-15 11:27:33 +08:00
0c7bc78541 Improve the UI of comments page. 2025-02-15 11:20:53 +08:00
94098eea77 Show last reading position on Comic Details page. 2025-02-15 10:59:37 +08:00
a2b113ca20 Fix duplicate hero tag. 2025-02-15 10:35:09 +08:00
c9b7ea97bf Fixed the issue where addHistoryAsync did not update the cache. 2025-02-14 22:35:57 +08:00
23f9763fe8 Support chapter groups. 2025-02-14 17:55:10 +08:00
e7aad5f0d1 Avoid updating history too frequently. 2025-02-14 11:46:38 +08:00
22c01b4fd0 Improve reader performance 2025-02-14 11:35:03 +08:00
350bcf4ffc Add a setting for number of images preloaded. Close #192 2025-02-14 10:58:21 +08:00
d179b39b64 Improve comic image loading retry 2025-02-14 10:46:49 +08:00
ef2e621da2 Update state management. 2025-02-14 10:42:12 +08:00
193f5f73ff fix update comics 2025-02-14 10:14:48 +08:00
2333c6df85 fix version check 2025-02-14 10:12:30 +08:00
铺盖崽
455c6c1356 简化linux arm打包流程 (#193)
* Update main.yml

* Update main.yml
2025-02-14 08:50:04 +08:00
bd24cfad46 flutter 3.29 & update version code 2025-02-13 20:02:56 +08:00
985e46ff88 Fix the change chapter gesture 2025-02-13 16:59:54 +08:00
31e391ddae Fix history 2025-02-13 16:51:38 +08:00
fec1926774 Fix webview 2025-02-13 12:14:57 +08:00
nyne
7cd0a20785 Merge pull request #191 from venera-app/v1.2.5-dev
V1.2.5
2025-02-13 11:05:20 +08:00
ed124d0419 Fix calculation 2025-02-13 11:01:42 +08:00
14c3e9ea43 Fixed the storage of chapter read information. 2025-02-13 10:47:54 +08:00
d2aca7ce44 Improve sorting images when importing comic. 2025-02-13 10:09:08 +08:00
34194559f5 Improve chapters display 2025-02-13 10:05:38 +08:00
18c5d5d85a Fix image overflow 2025-02-13 09:49:05 +08:00
9b1bafcbe1 Improve gesture 2025-02-13 09:43:36 +08:00
dd7e2d6744 Improve aggregated_search_page 2025-02-11 21:13:57 +08:00
51c2bf0d6f [windows] Replace desktop_webview_window with flutter_inappwebview 2025-02-11 20:08:02 +08:00
53e5ebbbf6 Update version code 2025-02-11 19:21:44 +08:00
c600d99c58 Add Reverse Tap to Turn Page. Close #186 2025-02-11 19:02:16 +08:00
f4804faf52 Improve reader gesture. Close #185 2025-02-11 18:51:27 +08:00
c7d72347a9 typo 2025-02-11 17:55:17 +08:00
a4e2d4f6e4 Update js api 2025-02-11 13:58:17 +08:00
5c7cd7a304 Improve multi-folder favorites management. 2025-02-11 13:51:19 +08:00
9fb63e47ea Fix deleting comic in favorites page. 2025-02-11 13:23:51 +08:00
fc66e8ae2d Fix getLocale 2025-02-11 13:16:16 +08:00
d04c872491 Merge branch 'v1.3.0-dev' 2025-02-11 13:09:17 +08:00
426936082e Fix description overflow 2025-02-11 13:08:24 +08:00
5129530e56 Update issue template. 2025-02-11 11:07:55 +08:00
3735249de6 Fix the issue where page is not reloaded after changing search options in search results page. 2025-02-09 21:15:31 +08:00
nyne
8868a02a7e Merge pull request #183 from venera-app/v1.2.4-dev
V1.2.4
2025-02-09 19:59:26 +08:00
nyne
e1b95c9e23 Merge branch 'master' into v1.2.4-dev 2025-02-09 19:57:42 +08:00
0b65b4ab53 Update version code 2025-02-09 19:32:10 +08:00
df4263f969 Add ability to manage search sources. Close #174 2025-02-09 19:29:51 +08:00
17ef17ca5b Add a button for managing network folders. 2025-02-09 18:22:38 +08:00
nyne
e55c45a589 Support Linux arm64. Close #176 2025-02-09 15:11:46 +08:00
591f2836d4 Improve windows build script. 2025-02-09 13:45:30 +08:00
8ab4f7a34b Fix the issue where cache files are not deleted. 2025-02-09 11:38:19 +08:00
614c01872b Fix auto language filter. Close #171 2025-02-08 21:10:43 +08:00
6be258092a Remove confirmation prompt from deb. Close #177 2025-02-08 20:40:45 +08:00
ce50812857 Fix invalid image order when exporting comic as pdf. 2025-02-08 19:37:04 +08:00
f0b1135eb7 Allow batch export. Close #179 2025-02-08 18:23:49 +08:00
shenmo
cc0f070df5 Use Ubuntu 22.04 to run the workflow. (#178) 2025-02-07 19:19:39 +08:00
35429c132c Improve comic page performance 2025-02-07 18:15:36 +08:00
998d4c31d3 Improve importing comic: If the archive has only one directory, set working dir as it. 2025-02-07 17:32:51 +08:00
0122bb8f28 fix windows font 2025-02-07 17:28:03 +08:00
33a9fa062b flutter 3.27.4 2025-02-07 17:19:26 +08:00
13081332f2 Improve tags display 2025-02-07 17:19:04 +08:00
Pacalini
cdc6c95579 pre-search: enable suggestions for EN (#175) 2025-02-07 17:16:41 +08:00
buste
3aca3baafc Fix ensure searchTarget is properly initialized for aggregatedSearch mode (#173)
Set searchTarget = defaultSearchTarget when aggregatedSearch is enabled, ensuring correct initialization and preventing missing suggestions on first input.

Without this fix, when opening the search page for the first time with aggregatedSearch enabled by default, entering an ID that matches a comic source does not trigger the "Open comic" suggestion. However, after toggling aggregatedSearch off and then back on, the same ID input correctly displays the suggestion.
2025-02-07 17:03:52 +08:00
58d6ccdde1 Fix an issue where an application turns to a white screen after finishing cloudflare verification. Close #169 2025-02-05 21:21:20 +08:00
23404b86f6 Record the last state of the favorite pane. 2025-02-05 20:40:14 +08:00
UjuiUjuMandan
965187e9de replace raw.githubusercontent.com 2025-02-05 20:21:15 +08:00
140 changed files with 17830 additions and 6174 deletions

View File

@@ -7,6 +7,32 @@ body:
attributes:
value: |
Thank you for reporting a problem, please complete the title and fill in the following information.
感谢您的反馈,请填写完整标题并填写以下信息。
**Please do not report any issues related to config files.**
**请不要报告与配置文件相关的任何问题。**
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
- type: dropdown
id: bugType
attributes:
label: Bug type
description: What type of bug are you reporting?
options:
- Crash
- UI
- Performance
- Security
- Reader
- JS Engine
- Comic Source
- Other
validations:
required: true
- type: textarea
id: what-happened
attributes:
@@ -19,7 +45,8 @@ body:
attributes:
label: Version
description: |
App version
App version.
Please try to update if it is not the latest version
validations:
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -7,6 +7,16 @@ body:
attributes:
value: |
Welcome to make a feature request, please fill in the following information after completing the title.
欢迎提出功能建议,请填写完整标题后填写以下信息。
**Please do not report any issues related to config files.**
**请不要报告与配置文件相关的任何问题。**
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
- type: textarea
id: what-happened
attributes:

View File

@@ -1,9 +0,0 @@
name: other
description: Other contents
body:
- type: textarea
id: what-happened
attributes:
label: Content
validations:
required: true

View File

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

29
.github/workflows/issue_check.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Check Issue
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
check:
name: Check Issue
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Check Issue
id: check
uses: wgh136/gpt_issue_checker@v1.0.2
with:
api-url: ${{ secrets.API_URL }}
api-key: ${{ secrets.API_KEY }}
github-token: ${{ secrets.GITHUB_TOKEN }}
prompt: "You are a repository issue checker. The project is a comic app that supports view local or network comics using config files. To view a comic source, user must add a config file. User should not report any issue related to config file to the project repository because there is another repository for managing config files. You are given an issue content and you need to decide whether to close the issue. If you decide to close the issue, you should also provide a comment explaining why you are closing the issue. If you decide not to close the issue, you should provide a comment which is a summary of the issue. You should response with a JSON object with the following keys: should_close, should_comment, comment."
model: "gpt-4o"

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
@@ -26,6 +26,9 @@ jobs:
echo "$CERTIFICATE" | base64 --decode > signing_certificate.p12
security import signing_certificate.p12 -k ~/Library/Keychains/login.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
- name: Check rust-toolchain.toml
run: rustup show
# Step 2: Build the Flutter macOS app
- name: Build Flutter macOS App
run: flutter build macos --release
@@ -39,12 +42,18 @@ jobs:
ln -s /Applications dist/dmg_contents/Applications
hdiutil create -volname "venera" -srcfolder dist/dmg_contents -ov -format UDZO "dist/venera.dmg"
- name: Add version to filename
run: |
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
mkdir -p result
mv dist/venera.dmg result/venera-$APP_VERSION.dmg
# Step 4: Attach and upload artifacts (optional)
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: venera.dmg
path: dist/venera.dmg
name: macos_build
path: result/
Build_IOS:
runs-on: macos-15
steps:
@@ -54,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: |
@@ -62,12 +71,17 @@ jobs:
mv /Users/runner/work/venera/venera/build/ios/iphoneos/Runner.app /Users/runner/work/venera/venera/build/ios/iphoneos/Payload
cd /Users/runner/work/venera/venera/build/ios/iphoneos/
zip -r venera-ios.ipa Payload
- name: Add version to filename
run: |
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
mkdir -p result
mv build/ios/iphoneos/venera-ios.ipa result/venera-ios-$APP_VERSION.ipa
- uses: actions/upload-artifact@v4
with:
name: app-ios.ipa
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa
name: ios_build
path: result/
Build_Android:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
@@ -86,10 +100,8 @@ jobs:
with:
distribution: 'oracle'
java-version: '17'
- name: Setup Rust
run: |
rustup update
rustup default stable
- name: Check rust-toolchain.toml
run: rustup show
- run: flutter pub get
- run: flutter build apk --release
- uses: actions/upload-artifact@v4
@@ -118,7 +130,7 @@ jobs:
name: windows_build
path: build/windows/Venera-*
Build_Linux:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
@@ -130,7 +142,7 @@ jobs:
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
- run: python3 debian/build.py x64
- run: dart run flutter_to_arch
- run: |
sudo rm -rf build/linux/arch/app.tar.gz
@@ -145,19 +157,37 @@ jobs:
with:
name: arch_build
path: build/linux/arch/
Build_Linux_ARM64:
runs-on: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'master'
flutter-version-file: pubspec.yaml
- run: |
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
- uses: actions/upload-artifact@v4
with:
name: deb_arm64_build
path: build/linux/x64/release/debian # This is a bug related to flutter_to_debian, but it's not a big deal.
Release:
runs-on: ubuntu-latest
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux]
runs-on: ubuntu-22.04
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux, Build_Linux_ARM64]
if: github.event_name == 'release' # 仅在 push 事件时执行
steps:
- uses: actions/download-artifact@v4
with:
name: venera.dmg
name: macos_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: app-ios.ipa
name: ios_build
path: outputs
- uses: actions/download-artifact@v4
with:
@@ -175,6 +205,10 @@ jobs:
with:
name: arch_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: deb_arm64_build
path: outputs
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
@@ -186,5 +220,6 @@ jobs:
outputs/*.exe
outputs/*.deb
outputs/*.zst
outputs/*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.ACTION_GITHUB_TOKEN }}

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

@@ -0,0 +1,76 @@
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
git commit -m "Updated source with latest release"
git push
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.

64
alt_store.json Normal file
View File

@@ -0,0 +1,64 @@
{
"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.4.5",
"versionDate": "2025-06-18",
"versionDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
"localizedDescription": "A comic reader that supports reading local and network comics",
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
"tintColor": "#0784FC",
"category": "utilities",
"size": 14960268,
"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.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"
}
]
}

View File

@@ -32,7 +32,13 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android {
namespace = "com.github.wgh136.venera"
compileSdk = flutter.compileSdkVersion
ndkVersion "25.1.8937393"
ndkVersion "28.0.13004108"
packaging {
jniLibs {
useLegacyPackaging true
}
}
splits{
abi {
@@ -67,7 +73,6 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.github.wgh136.venera"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
@@ -79,6 +84,9 @@ android {
buildTypes {
release {
// Temporarily solution to fix crash
minifyEnabled false
shrinkResources false
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
}
@@ -125,6 +133,6 @@ flutter {
}
dependencies {
implementation "androidx.activity:activity-ktx:1.9.2"
implementation "androidx.activity:activity-ktx:1.10.1"
implementation 'androidx.documentfile:documentfile:1.0.1'
}

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
@@ -47,14 +48,17 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="exhentai.org" android:pathPrefix="/g" />
</intent-filter>
<intent-filter android:label="@string/share_text">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<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

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.util.Log
@@ -40,6 +41,41 @@ class MainActivity : FlutterFragmentActivity() {
private val nextLocalRequestCode = AtomicInteger()
private val sharedTexts = ArrayList<String>()
private var textShareHandler: ((String) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent?.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null)
handleSharedText(text)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null)
handleSharedText(text)
}
}
}
private fun handleSharedText(text: String) {
if (textShareHandler != null) {
textShareHandler?.invoke(text)
} else {
sharedTexts.add(text)
}
}
private fun <I, O> startContractForResult(
contract: ActivityResultContract<I, O>,
input: I,
@@ -134,6 +170,26 @@ class MainActivity : FlutterFragmentActivity() {
val mimeType = req.arguments<String>()
openFile(res, mimeType!!)
}
val shareTextChannel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/text_share")
shareTextChannel.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
textShareHandler = {text ->
events.success(text)
}
if (sharedTexts.isNotEmpty()) {
for (text in sharedTexts) {
events.success(text)
}
sharedTexts.clear()
}
}
override fun onCancel(arguments: Any?) {
textShareHandler = null
}
})
}
private fun getProxy(): String {

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">搜索</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">搜尋</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">Search</string>
</resources>

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -18,8 +18,8 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.3.2' apply false
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
id "com.android.application" version '8.9.0' 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}
@@ -176,7 +214,7 @@ let Convert = {
decryptAesCbc: (value, key, iv) => {
return sendMessage({
method: "convert",
type: "aes-ecb",
type: "aes-cbc",
value: value,
key: key,
iv: iv,
@@ -496,7 +534,7 @@ let Network = {
/**
* [fetch] function for sending HTTP requests. Same api as the browser fetch.
* @param url {string}
* @param options {{method: string, headers: Object, body: any}}
* @param [options] {{method?: string, headers?: Object, body?: any}}
* @returns {Promise<{ok: boolean, status: number, statusText: string, headers: {}, arrayBuffer: (function(): Promise<ArrayBuffer>), text: (function(): Promise<string>), json: (function(): Promise<any>)}>}
* @since 1.2.0
*/
@@ -921,7 +959,7 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
* @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined}
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null
* @param isFavorite {boolean | null | undefined} - favorite status.
* @param subId {string?} - a param which is passed to comments api
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[]?} - related comics
@@ -1086,6 +1124,19 @@ class ComicSource {
});
}
translation = {}
/**
* Translate given string with the current locale using the translation object.
* @param key {string}
* @returns {string}
* @since 1.2.5
*/
translate(key) {
let locale = APP.locale;
return this.translation[locale]?.[key] ?? key;
}
init() { }
static sources = {}
@@ -1283,13 +1334,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?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
* @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
})
},
@@ -1345,3 +1398,44 @@ let APP = {
})
}
}
/**
* Set clipboard text
* @param text {string}
* @returns {Promise<void>}
*
* @since 1.3.4
*/
function setClipboard(text) {
return sendMessage({
method: 'setClipboard',
text: text
})
}
/**
* Get clipboard text
* @returns {Promise<string>}
*
* @since 1.3.4
*/
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

@@ -106,7 +106,8 @@
"Continuous (Right to Left)": "连续(从右到左)",
"Continuous (Top to Bottom)": "连续(从上到下)",
"Auto page turning interval": "自动翻页间隔",
"The number of pic in screen (Only Gallery Mode)": "同屏幕图片数量(仅画廊模式)",
"The number of pic in screen for landscape (Only Gallery Mode)": "横屏同屏幕图片数量(仅画廊模式)",
"The number of pic in screen for portrait (Only Gallery Mode)": "竖屏同屏幕图片数量(仅画廊模式)",
"Theme Mode": "主题模式",
"System": "系统",
"Light": "浅色",
@@ -139,18 +140,18 @@
"Block": "屏蔽",
"Add new favorite to": "添加新收藏到",
"Move favorite after reading": "阅读后移动收藏",
"Delete folder?" : "除文件夾?",
"Delete folder '@f' ?" : "删除文件夹 '@f' ",
"Delete folder?": "除文件夹?",
"Delete folder '@f' ?": "删除文件夹 '@f' ?",
"Import from file": "从文件导入",
"Failed to import": "导入失败",
"Cache Limit": "缓存限制",
"Set Cache Limit": "设置缓存限制",
"Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
"Select a directory which contains the comic directories.": "选择一个包含漫画文件夹的目录",
"Help": "帮助",
"Export as cbz": "导出为cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一个归档文件",
"Select an archive file (cbz, zip, 7z, cb7)": "选择一个归档文件 (cbz, zip, 7z, cb7)",
"An archive file": "一个归档文件",
"Fullscreen": "全屏",
"Exit": "退出",
"View more": "查看更多",
@@ -189,6 +190,7 @@
"Operation": "操作",
"Upload": "上传",
"Saved": "已保存",
"Saved Failed": "保存失败",
"Sync Data": "同步数据",
"Syncing Data": "正在同步数据",
"Data Sync": "数据同步",
@@ -196,9 +198,9 @@
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
"Added": "已添加",
"Turn page by volume keys": "使用音量键翻页",
"Display time & battery info in reader":"在阅读器中显示时间和电量信息",
"EhViewer downloads":"EhViewer下载",
"Select an EhViewer database and a download folder.":"选择EhViewer的下载数据导出的db文件与存放下载内容的目录",
"Display time & battery info in reader": "在阅读器中显示时间和电量信息",
"EhViewer downloads": "EhViewer下载",
"Select an EhViewer database and a download folder.": "选择EhViewer的下载数据导出的db文件与存放下载内容的目录",
"(EhViewer)Default": "(EhViewer)默认",
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若通过EhViewer数据库导入漫画程序将会按其中的下载标签自动创建收藏文件夹。",
"Multi-Select": "进入多选模式",
@@ -232,14 +234,16 @@
"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": "删除所有无效的本地收藏",
"Deleted @a favorite items.": "已删除 @a 条无效收藏",
"New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
"A new version is available. Do you want to update now?": "有新版本可用。您要现在更新吗?",
"No new version available": "没有新版本可用",
"Export as pdf": "导出为pdf",
"Export as epub": "导出为epub",
@@ -286,15 +290,15 @@
"Copy the title successfully": "复制标题成功",
"The comic is invalid, please long press to delete, you can double click the title to copy": "该漫画已失效, 请长按删除, 可以双击标题进行复制",
"No search results found": "未找到搜索结果",
"Added @c comics to download queue." : "已添加 @c 本漫画到下载队列",
"Added @c comics to download queue.": "已添加 @c 本漫画到下载队列",
"Download started": "下载已开始",
"Click favorite": "点击收藏",
"End": "末尾",
"None": "无",
"View Detail": "查看详情",
"Select a directory which contains multiple archive files." : "选择一个包含多个归档文件的目录",
"Multiple archive files" : "多个归档文件",
"No valid comics found" : "未找到有效的漫画",
"Select a directory which contains multiple archive files.": "选择一个包含多个归档文件的目录",
"Multiple archive files": "多个归档文件",
"No valid comics found": "未找到有效的漫画",
"Enable DNS Overrides": "启用DNS覆写",
"DNS Overrides": "DNS覆写",
"Custom Image Processing": "自定义图片处理",
@@ -321,7 +325,93 @@
"Manage": "管理",
"Verify": "验证",
"Cloudflare verification required": "需要Cloudflare验证",
"Success": "成功"
"Success": "成功",
"Compressing": "压缩中",
"Exporting": "导出中",
"Search Sources": "搜索源",
"Removed": "已移除",
"Added to favorites": "已添加到收藏",
"Not added": "未添加",
"Create a folder": "新建收藏夹",
"Created successfully": "创建成功",
"name": "名称",
"Reverse tap to turn Pages": "反转点击翻页",
"Show all": "显示全部",
"Number of images preloaded": "预加载图片数量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading": "上次阅读",
"Replies": "回复",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates.": "选择一个文件夹以追更",
"Choose Folder": "选择文件夹",
"No folders available": "没有可用的文件夹",
"Updating comics...": "更新漫画中...",
"Automatic update checking enabled.": "已启用自动更新检查",
"The app will check for updates at most once a day.": "APP将每天最多检查一次更新",
"Change Folder": "更改文件夹",
"Check Now": "立即检查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫画",
"The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新",
"Disable": "禁用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功后, APP将自动与服务器同步数据",
"Cache cleared": "缓存已清除",
"Disabled": "已禁用",
"Auto Sync Data": "自动同步数据",
"Mark all as read": "全部标记为已读",
"Do you want to mark all as read?": "您要全部标记为已读吗?",
"Swipe down for previous chapter": "向下滑动查看上一章",
"Swipe up for next chapter": "向上滑动查看下一章",
"Initial Page": "初始页面",
"Home Page": "主页",
"Favorites Page": "收藏页面",
"Explore Page": "探索页面",
"Categories Page": "分类页面",
"Convert to local": "转换为本地",
"Refresh": "刷新",
"Paging": "分页",
"Continuous": "连续",
"Display mode of comic list": "漫画列表的显示模式",
"Show Page Number": "显示页码",
"Jump to page": "跳转到页面",
"Page": "页面",
"Jump": "跳转",
"Copy Image": "复制图片",
"A valid WebDav directory URL": "有效的WebDav目录URL",
"Shut Down": "关闭",
"Uploading data...": "正在上传数据...",
"Pages": "页数",
"Long press zoom position": "长按缩放位置",
"Press position": "按压位置",
"Screen center": "屏幕中心",
"Suggestions": "建议",
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
"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": "首頁",
@@ -331,7 +421,7 @@
"Settings": "設定",
"Search": "搜尋",
"History": "歷史",
"Local": "本",
"Local": "本",
"Import": "匯入",
"Comic Source": "漫畫源",
"Accounts": "帳戶",
@@ -343,14 +433,14 @@
"help": "幫助",
"Select": "選擇",
"Selected @a comics": "已選擇 @a 部漫畫",
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 載 @b 頁, 接收到 @c 部漫畫",
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 載 @b 頁, 接收到 @c 部漫畫",
"Downloading": "下載中",
"Back": "後退",
"Delete": "刪除",
"Full Screen": "全螢幕",
"Auto Page Turning": "自動翻頁",
"Chapters": "章節",
"Save Image": "存圖片",
"Save Image": "存圖片",
"Share": "分享",
"Details": "詳情",
"Description": "描述",
@@ -358,19 +448,19 @@
"Add to favorites": "加入收藏",
"Error": "錯誤",
"Retry": "重試",
"Folders": "文件夾",
"Delete Folder": "刪除文件夾",
"Folders": "資料夾",
"Delete Folder": "刪除資料夾",
"Rename": "重新命名",
"Reorder": "重新排序",
"Network": "網路",
"more": "更多",
"Select a folder": "選擇一個文件夾",
"Folder": "文件夾",
"Select a folder": "選擇一個資料夾",
"Folder": "資料夾",
"Confirm": "確認",
"Remove comic from favorite?": "從收藏中移除漫畫?",
"Move": "移動",
"Move to folder": "移動到文件夾",
"Copy to folder": "複製到文件夾",
"Move to folder": "移動到資料夾",
"Copy to folder": "複製到資料夾",
"Delete Comic": "刪除漫畫",
"Delete @c comics?": "刪除 @c 本漫畫?",
"Add comic source": "添加漫畫源",
@@ -382,43 +472,43 @@
"Check updates": "檢查更新",
"Edit": "編輯",
"Update": "更新",
"Log in": "登",
"Log in": "登",
"Log out": "登出",
"Re-login": "重新登",
"Click if login expired": "點擊此處如果登已過期",
"Login": "登",
"Username": "用戶名",
"Re-login": "重新登",
"Click if login expired": "點擊此處如果登已過期",
"Login": "登",
"Username": "使用者名稱",
"Password": "密碼",
"Continue": "繼續",
"Create Account": "建帳戶",
"Create Account": "建帳戶",
"Next": "前進",
"Login with webview": "過網頁登",
"Login with webview": "過網頁登",
"Read": "閱讀",
"Download": "下載",
"Favorite": "收藏",
"Comments": "評論",
"Information": "信息",
"Information": "資訊",
"Uploader": "上傳者",
"Upload Time": "上傳時間",
"Preview": "預覽",
"Comment": "評論",
"Submit": "提交",
"Add": "添加",
"New Folder": "新建文件夾",
"New Folder": "建立資料夾",
"Reading": "閱讀中",
"Appearance": "外觀",
"Local Favorites": "本收藏",
"Local Favorites": "本收藏",
"APP": "應用",
"About": "關於",
"Display mode of comic tile": "漫畫縮圖的顯示模式",
"Display mode of comic tile": "漫畫縮圖的顯示模式",
"Detailed": "詳細",
"Brief": "簡潔",
"Size of comic tile": "漫畫縮圖的大小",
"Size of comic tile": "漫畫縮圖的大小",
"Explore Pages": "探索頁面",
"Category Pages": "分類頁面",
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
"Keyword blocking": "關鍵詞屏蔽",
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
"Keyword blocking": "關鍵字封鎖",
"Tap to turn Pages": "點擊翻頁",
"Page animation": "頁面動畫",
"Reading mode": "閱讀模式",
@@ -429,10 +519,11 @@
"Continuous (Right to Left)": "連續(從右到左)",
"Continuous (Top to Bottom)": "連續(從上到下)",
"Auto page turning interval": "自動翻頁間隔",
"The number of pic in screen (Only Gallery Mode)": "同螢幕圖片數量(僅畫廊模式)",
"The number of pic in screen for landscape (Only Gallery Mode)": "橫向同螢幕圖片數量(僅畫廊模式)",
"The number of pic in screen for portrait (Only Gallery Mode)": "直向同螢幕圖片數量(僅畫廊模式)",
"Theme Mode": "主題模式",
"System": "系統",
"Light": "色",
"Light": "色",
"Dark": "深色",
"Theme Color": "主題顏色",
"Red": "紅色",
@@ -442,38 +533,38 @@
"Orange": "橙色",
"Blue": "藍色",
"App": "應用",
"Data": "數據",
"Storage Path for local comics": "本漫畫的儲路徑",
"Set New Storage Path": "設新的儲路徑",
"Set": "設",
"Cache Size": "緩存大小",
"Clear Cache": "清除緩存",
"Data": "資料",
"Storage Path for local comics": "本漫畫的儲路徑",
"Set New Storage Path": "設新的儲路徑",
"Set": "設",
"Cache Size": "快取大小",
"Clear Cache": "清除快取",
"Clear": "清除",
"Log": "日誌",
"Open Log": "打開日誌",
"Open": "打開",
"User": "用戶",
"User": "使用者",
"Language": "語言",
"Proxy": "代理",
"Venera is a free and open-source app for comic reading.": "Venera是一個免費的開源漫畫閱讀應用。",
"Check for updates": "檢查更新",
"Check": "檢查",
"Network Favorite Pages": "網路收藏頁面",
"Block": "屏蔽",
"Block": "封鎖",
"Add new favorite to": "添加新收藏到",
"Move favorite after reading": "閱讀後移動收藏",
"Delete folder?" : "刪除文件夾?",
"Delete folder '@f' ?" : "刪除文件夾 '@f' ",
"Delete folder?": "刪除資料夾?",
"Delete folder '@f' ?": "刪除資料夾 '@f' ",
"Import from file": "從文件匯入",
"Failed to import": "匯入失敗",
"Cache Limit": "緩存限制",
"Set Cache Limit": "設置緩存限制",
"Cache Limit": "快取限制",
"Set Cache Limit": "設定快取限制",
"Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
"Select a directory which contains the comic directories.": "選擇一個包含漫畫資料夾的目錄",
"Help": "幫助",
"Export as cbz": "匯出為cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一個歸檔文件",
"Select an archive file (cbz, zip, 7z, cb7)": "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"An archive file": "一個歸檔文件",
"Fullscreen": "全螢幕",
"Exit": "退出",
"View more": "查看更多",
@@ -483,15 +574,15 @@
"Date Desc": "日期降序",
"Start": "開始",
"Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據",
"Import App Data": "匯入應用數據",
"Export App Data": "匯出應用資料",
"Import App Data": "匯入應用資料",
"Export": "匯出",
"Download Threads": "下載線程數",
"Download Threads": "下載執行緒數",
"Update Time": "更新時間",
"Copy ID": "複製ID",
"Copy URL": "複製URL",
"Create": "建",
"Folder Name": "文件夾名稱",
"Create": "建",
"Folder Name": "資料夾名稱",
"Ranking": "排行",
"Download Selected": "下載選中",
"Download All": "下載全部",
@@ -502,9 +593,9 @@
"Updates Available": "更新可用",
"Unselected": "未選擇",
"Long press and drag to reorder.": "長按並拖動以重新排序。",
"Limit image width": "限圖片寬度",
"Limit image width": "限圖片寬度",
"When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式",
"Open link": "打開鏈接",
"Open link": "打開連結",
"Open comic": "打開漫畫",
"Move To First": "移動到最前",
"Cancel": "取消",
@@ -512,15 +603,16 @@
"Pause": "暫停",
"Operation": "操作",
"Upload": "上傳",
"Saved": "已存",
"Sync Data": "同步數據",
"Syncing Data": "正在同步數據",
"Data Sync": "數據同步",
"Saved": "已存",
"Saved Failed": "儲存失敗",
"Sync Data": "同步資料",
"Syncing Data": "正在同步資料",
"Data Sync": "資料同步",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個文件夾",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
"Added": "已添加",
"Turn page by volume keys": "使用音量鍵翻頁",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量信息",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量資訊",
"EhViewer downloads": "EhViewer下載",
"Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料匯出的db檔案與存放下載內容的目錄",
"(EhViewer)Default": "(EhViewer)預設",
@@ -534,45 +626,47 @@
"Select in range": "區間選擇",
"Finished": "已完成",
"Updating": "更新中",
"Update Comics Info": "更新漫畫信息",
"Create Folder": "新建文件夾",
"Select an image on screen": "選擇幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載列",
"Update Comics Info": "更新漫畫資訊",
"Create Folder": "建立資料夾",
"Select an image on screen": "選擇幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載列",
"Authorization Required": "需要身份驗證",
"Sync": "同步",
"The folder is Linked to @source": "文件夾已關聯到 @source",
"Source Folder": "源文件夾",
"Use a config file": "使用配置文件",
"The folder is Linked to @source": "資料夾已關聯到 @source",
"Source Folder": "來源資料夾",
"Use a config file": "使用設定檔",
"Comic Source list": "漫畫源列表",
"View": "查看",
"Copy": "複製",
"Copied": "已複製",
"Search History": "搜歷史",
"Clear Search History": "清除搜歷史",
"Search in": "搜於",
"Search History": "搜歷史",
"Clear Search History": "清除搜歷史",
"Search in": "搜於",
"Clear History": "清除歷史",
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
"No Explore Pages": "沒有探索頁面",
"Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面",
"Group @group": "第 @group 組",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁",
"Also remove files on disk": "同時刪除磁盤上的文件",
"Copy to app local path": "將漫畫複製到本地儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本地收藏",
"Remove local favorite and history": "刪除本機收藏和歷史記錄",
"Also remove files on disk": "同時刪除磁碟上的文件",
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
"Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
"A new version is available. Do you want to update now?": "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf",
"Export as epub": "匯出為epub",
"Aggregated Search": "聚合搜",
"No search results found": "未找到搜結果",
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載列",
"Aggregated Search": "聚合搜",
"No search results found": "未找到搜結果",
"Added @c comics to download queue.": "已添加 @c 本漫畫到下載列",
"Download started": "下載已開始",
"Click favorite": "點擊收藏",
"Local comic collection is not supported at present": "本收藏暫不支",
"Local comic collection is not supported at present": "本收藏暫不支",
"The cover cannot be uncollected here": "封面不能在此取消收藏",
"Uncollected the image": "取消收藏圖片",
"Successfully collected": "收藏成功",
@@ -581,7 +675,7 @@
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在圖片瀏覽頁面, 你可以根據你的閱讀模式橫向或者縱向滑動快速收藏圖片",
"Calculate your favorite from @a comics and @b images": "從 @a 本漫畫和 @b 張圖片中, 計算你最喜歡的",
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括號後是圖片數量或圖片數比漫畫頁數",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支收藏此章節",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支收藏此章節",
"Author: ": "作者: ",
"Tags: ": "標籤: ",
"Comics(number): ": "漫畫(數量): ",
@@ -589,7 +683,7 @@
"Time Filter": "時間篩選",
"Image Favorites Greater Than": "圖片收藏數大於",
"Collection time": "收藏時間",
"Not enable": "不用",
"Not enable": "不用",
"Double Tap": "雙擊",
"Swipe": "滑動",
"favoritesCompareComicPages": "收藏數與漫畫頁數比較",
@@ -600,7 +694,7 @@
"Favorite Num": "收藏數",
"Favorite Num Compare Comic Pages": "收藏數比漫畫頁數",
"All": "全部",
"Last Week": "上",
"Last Week": "上",
"Last Month": "上月",
"Last Half Year": "半年",
"Last Year": "一年",
@@ -616,21 +710,21 @@
"End": "末尾",
"None": "無",
"View Detail": "查看詳情",
"Select a directory which contains multiple archive files." : "選擇一個包含多個歸檔文件的目錄",
"Multiple archive files" : "多個歸檔文件",
"No valid comics found" : "未找到有效的漫畫",
"Select a directory which contains multiple archive files.": "選擇一個包含多個歸檔文件的目錄",
"Multiple archive files": "多個歸檔文件",
"No valid comics found": "未找到有效的漫畫",
"Enable DNS Overrides": "啟用DNS覆寫",
"DNS Overrides": "DNS覆寫",
"Custom Image Processing": "自定義圖片處理",
"Custom Image Processing": "自圖片處理",
"Enable": "啟用",
"Aggregated": "聚合",
"Default Search Target": "默認搜索目標",
"Default Search Target": "預設搜尋目標",
"Auto Language Filters": "自動語言篩選",
"Check for updates on startup": "啟動時檢查更新",
"Start Time": "開始時間",
"End Time": "結束時間",
"Custom": "自定義",
"Reset": "重",
"Custom": "自",
"Reset": "重",
"Tags": "標籤",
"Authors": "作者",
"Comics": "漫畫",
@@ -638,13 +732,99 @@
"New Version": "新版本",
"@c updates": "@c 項更新",
"No updates": "無更新",
"Set comic source list url": "設漫畫源列表URL",
"Set comic source list url": "設漫畫源列表URL",
"Deselect All": "取消全選",
"Add keyword": "添加關鍵",
"Keyword": "關鍵",
"Add keyword": "添加關鍵",
"Keyword": "關鍵",
"Manage": "管理",
"Verify": "驗證",
"Cloudflare verification required": "需要Cloudflare驗證",
"Success": "成功"
"Success": "成功",
"Compressing": "壓縮中",
"Exporting": "匯出中",
"Search Sources": "搜尋源",
"Removed": "已移除",
"Added to favorites": "已添加到收藏",
"Not added": "未添加",
"Create a folder": "建立收藏夾",
"Created successfully": "建立成功",
"name": "名稱",
"Reverse tap to turn Pages": "反轉點擊翻頁",
"Show all": "顯示全部",
"Number of images preloaded": "預載入圖片數量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading": "上次閱讀",
"Replies": "回覆",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates.": "選擇一個資料夾以追更",
"Choose Folder": "選擇資料夾",
"No folders available": "沒有可用的資料夾",
"Updating comics...": "更新漫畫中...",
"Automatic update checking enabled.": "已啟用自動更新檢查",
"The app will check for updates at most once a day.": "APP將每天最多檢查一次更新",
"Change Folder": "更改資料夾",
"Check Now": "立即檢查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫畫",
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
"Disable": "停用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與伺服器同步資料",
"Cache cleared": "快取已清除",
"Disabled": "已停用",
"Auto Sync Data": "自動同步資料",
"Mark all as read": "全部標記為已讀",
"Do you want to mark all as read?": "您要全部標記為已讀嗎?",
"Swipe down for previous chapter": "向下滑動查看上一章",
"Swipe up for next chapter": "向上滑動查看下一章",
"Initial Page": "初始頁面",
"Home Page": "首頁",
"Favorites Page": "收藏頁面",
"Explore Page": "探索頁面",
"Categories Page": "分類頁面",
"Convert to local": "轉換為本地",
"Refresh": "刷新",
"Paging": "分頁",
"Continuous": "連續",
"Display mode of comic list": "漫畫列表的顯示模式",
"Show Page Number": "顯示頁碼",
"Jump to page": "跳轉到頁面",
"Page": "頁面",
"Jump": "跳轉",
"Copy Image": "複製圖片",
"A valid WebDav directory URL": "有效的WebDav目錄URL",
"Shut Down": "關閉",
"Uploading data...": "正在上傳數據...",
"Pages": "頁數",
"Long press zoom position": "長按縮放位置",
"Press position": "按壓位置",
"Screen center": "螢幕中心",
"Suggestions": "建議",
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
"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": "滑鼠滾動速度"
}
}

11
debian/build.py vendored
View File

@@ -1,5 +1,7 @@
import subprocess
import sys
arch = sys.argv[1]
debianContent = ''
desktopContent = ''
version = ''
@@ -12,7 +14,14 @@ with open('pubspec.yaml', 'r') as f:
version = str.split(str.split(f.read(), 'version: ')[1], '+')[0]
with open('debian/debian.yaml', 'w') as f:
f.write(debianContent.replace('{{Version}}', version))
content = debianContent.replace('{{Version}}', version)
if arch == 'x64':
content = content.replace('{{Arch}}', 'x64')
content = content.replace('{{Architecture}}', 'amd64')
elif arch == 'arm64':
content = content.replace('{{Arch}}', 'arm64')
content = content.replace('{{Architecture}}', 'arm64')
f.write(content)
with open('debian/gui/venera.desktop', 'w') as f:
f.write(desktopContent.replace('{{Version}}', version))

6
debian/debian.yaml vendored
View File

@@ -1,13 +1,13 @@
flutter_app:
command: venera
arch: x64
arch: {{Arch}}
parent: /usr/local/lib
nonInteractive: false
nonInteractive: true
control:
Package: venera
Version: {{Version}}
Architecture: amd64
Architecture: {{Architecture}}
Priority: optional
Depends: libwebkit2gtk-4.1-0, libgtk-3-0
Maintainer: nyne

BIN
debian/gui/venera.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 64 KiB

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
@@ -576,7 +615,7 @@ If `load` function is implemented, `loadNext` function will be ignored.
This part is used to load comic details.
### Settings
#### Settings
```javascript
/*
@@ -635,7 +674,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

@@ -80,7 +80,7 @@ class _AppbarState extends State<Appbar> {
var content = Container(
decoration: BoxDecoration(
color: widget.backgroundColor ??
context.colorScheme.surface.toOpacity(0.72),
context.colorScheme.surface.toOpacity(0.86),
),
height: _kAppBarHeight + context.padding.top,
child: Row(
@@ -231,7 +231,7 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
child: BlurEffect(
blur: 15,
child: Material(
color: context.colorScheme.surface.toOpacity(0.72),
color: context.colorScheme.surface.toOpacity(0.86),
elevation: 0,
borderRadius: BorderRadius.circular(radius),
child: body,
@@ -274,6 +274,7 @@ class AppTabBar extends StatefulWidget {
this.controller,
required this.tabs,
this.actionButton,
this.withUnderLine = true,
});
final TabController? controller;
@@ -282,6 +283,8 @@ class AppTabBar extends StatefulWidget {
final Widget? actionButton;
final bool withUnderLine;
@override
State<AppTabBar> createState() => _AppTabBarState();
}
@@ -396,14 +399,16 @@ class _AppTabBarState extends State<AppTabBar> {
key: tabBarKey,
height: _kTabHeight,
width: double.infinity,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
decoration: widget.withUnderLine
? BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
)
: null,
child: widget.tabs.isEmpty ? const SizedBox() : child,
);
}
@@ -627,6 +632,7 @@ class _TabViewBodyState extends State<TabViewBody> {
void didChangeDependencies() {
super.didChangeDependencies();
_controller = widget.controller ?? DefaultTabController.of(context);
_currentIndex = _controller.index;
_controller.addListener(updateIndex);
}

View File

@@ -17,20 +17,23 @@ ImageProvider? _findImageProvider(Comic comic) {
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
fallbackToLocalCover: comic is FavoriteItem,
);
}
return image;
}
class ComicTile extends StatelessWidget {
const ComicTile(
{super.key,
required this.comic,
this.enableLongPressed = true,
this.badge,
this.menuOptions,
this.onTap,
this.onLongPressed});
const ComicTile({
super.key,
required this.comic,
this.enableLongPressed = true,
this.badge,
this.menuOptions,
this.onTap,
this.onLongPressed,
this.heroID,
});
final Comic comic;
@@ -44,6 +47,8 @@ class ComicTile extends StatelessWidget {
final VoidCallback? onLongPressed;
final int? heroID;
void _onTap() {
if (onTap != null) {
onTap!();
@@ -55,6 +60,7 @@ class ComicTile extends StatelessWidget {
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
heroID: heroID,
),
);
}
@@ -137,8 +143,7 @@ class ComicTile extends StatelessWidget {
.isExist(comic.id, ComicType(comic.sourceKey.hashCode))
: false;
var history = appdata.settings['showHistoryStatusOnTile']
? HistoryManager()
.findSync(comic.id, ComicType(comic.sourceKey.hashCode))
? HistoryManager().find(comic.id, ComicType(comic.sourceKey.hashCode))
: null;
if (history?.page == 0) {
history!.page = 1;
@@ -210,63 +215,94 @@ class ComicTile extends StatelessWidget {
Widget _buildDetailedMode(BuildContext context) {
return LayoutBuilder(builder: (context, constrains) {
final height = constrains.maxHeight - 16;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _onTap,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row(
children: [
Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
width: height * 0.68,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
),
SizedBox.fromSize(
size: const Size(16, 5),
),
Expanded(
child: _ComicDescription(
title: comic.maxPage == null
? comic.title.replaceAll("\n", "")
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
subtitle: comic.subtitle ?? '',
description: comic.description,
badge: badge ?? comic.language,
tags: comic.tags,
maxLines: 2,
enableTranslate: ComicSource.find(comic.sourceKey)
?.enableTagsTranslate ??
false,
rating: comic.stars,
),
),
],
Widget image = Container(
width: height * 0.68,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
));
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
);
if (heroID != null) {
image = Hero(
tag: "cover$heroID",
child: image,
);
}
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _onTap,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row(
children: [
image,
SizedBox.fromSize(
size: const Size(16, 5),
),
Expanded(
child: _ComicDescription(
title: comic.maxPage == null
? comic.title.replaceAll("\n", "")
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
subtitle: comic.subtitle ?? '',
description: comic.description,
badge: badge ?? comic.language,
tags: comic.tags,
maxLines: 2,
enableTranslate:
ComicSource.find(comic.sourceKey)?.enableTagsTranslate ??
false,
rating: comic.stars,
),
),
],
),
),
);
});
}
Widget _buildBriefMode(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
Widget image = Container(
decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.toOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
);
if (heroID != null) {
image = Hero(
tag: "cover$heroID",
child: image,
);
}
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _onTap,
@@ -278,24 +314,7 @@ class ComicTile extends StatelessWidget {
child: Stack(
children: [
Positioned.fill(
child: Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.toOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
),
child: image,
),
Align(
alignment: Alignment.bottomRight,
@@ -316,7 +335,12 @@ class ComicTile extends StatelessWidget {
}
var children = <Widget>[];
for (var line in text.split('\n')) {
var lines = text.split('\n');
lines.removeWhere((e) => e.trim().isEmpty);
if (lines.length > 3) {
lines = lines.sublist(0, 3);
}
for (var line in lines) {
children.add(Container(
margin: const EdgeInsets.fromLTRB(2, 0, 2, 2),
padding: constraints.maxWidth < 80
@@ -445,7 +469,9 @@ class ComicTile extends StatelessWidget {
children: [
for (var word in all)
OptionChip(
text: word,
text: (comic.tags?.contains(word) ?? false)
? word.translateTagIfNeed
: word,
isSelected: words.contains(word),
onTap: () {
setState(() {
@@ -538,10 +564,8 @@ class _ComicDescription extends StatelessWidget {
softWrap: true,
overflow: TextOverflow.ellipsis,
),
const SizedBox(
height: 4,
),
if (tags != null)
const SizedBox(height: 4),
if (tags != null && tags!.isNotEmpty)
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
if (constraints.maxHeight < 22) {
@@ -550,7 +574,7 @@ class _ComicDescription extends StatelessWidget {
int cnt = (constraints.maxHeight - 22).toInt() ~/ 25;
return Container(
clipBehavior: Clip.antiAlias,
height: 22 + cnt * 25,
height: 21 + cnt * 24,
width: double.infinity,
decoration: const BoxDecoration(),
child: Wrap(
@@ -562,31 +586,30 @@ class _ComicDescription extends StatelessWidget {
children: [
for (var s in tags!)
Container(
height: 22,
padding: const EdgeInsets.fromLTRB(3, 2, 3, 2),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45,
height: 21,
padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45,
),
decoration: BoxDecoration(
color: s == "Unavailable"
? context.colorScheme.errorContainer
: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
decoration: BoxDecoration(
color: s == "Unavailable"
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius:
const BorderRadius.all(Radius.circular(8)),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
))),
),
),
],
),
).toAlign(Alignment.topCenter);
@@ -607,23 +630,26 @@ class _ComicDescription extends StatelessWidget {
style: const TextStyle(
fontSize: 12.0,
),
maxLines: (tags == null || tags!.isEmpty) ? 3 : 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (badge != null)
Container(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(8)),
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Center(
child: Text(
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
style: const TextStyle(fontSize: 12),
),
child: Center(
child: Text(
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
style: const TextStyle(fontSize: 12),
),
)),
),
),
],
)
],
@@ -737,16 +763,27 @@ class SliverGridComics extends StatefulWidget {
class _SliverGridComicsState extends State<SliverGridComics> {
List<Comic> comics = [];
List<int> heroIDs = [];
static int _nextHeroID = 0;
void generateHeroID() {
heroIDs.clear();
for (var i = 0; i < comics.length; i++) {
heroIDs.add(_nextHeroID++);
}
}
@override
void didUpdateWidget(covariant SliverGridComics oldWidget) {
if (oldWidget.comics != widget.comics) {
if (!comics.isEqualTo(widget.comics)) {
comics.clear();
for (var comic in widget.comics) {
if (isBlocked(comic) == null) {
comics.add(comic);
}
}
generateHeroID();
}
super.didUpdateWidget(oldWidget);
}
@@ -758,6 +795,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
comics.add(comic);
}
}
generateHeroID();
HistoryManager().addListener(update);
super.initState();
}
@@ -783,6 +821,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
Widget build(BuildContext context) {
return _SliverGridComics(
comics: comics,
heroIDs: heroIDs,
selection: widget.selections,
onLastItemBuild: widget.onLastItemBuild,
badgeBuilder: widget.badgeBuilder,
@@ -796,6 +835,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
class _SliverGridComics extends StatelessWidget {
const _SliverGridComics({
required this.comics,
required this.heroIDs,
this.onLastItemBuild,
this.badgeBuilder,
this.menuBuilder,
@@ -806,6 +846,8 @@ class _SliverGridComics extends StatelessWidget {
final List<Comic> comics;
final List<int> heroIDs;
final Map<Comic, bool>? selection;
final void Function()? onLastItemBuild;
@@ -837,11 +879,13 @@ class _SliverGridComics extends StatelessWidget {
onLongPressed: onLongPressed != null
? () => onLongPressed!(comics[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
@@ -1103,7 +1147,7 @@ class ComicListState extends State<ComicList> {
setState(() {});
});
}
if (_loading[page] == true) {
if (_data[page] != null || _loading[page] == true) {
return;
}
_loading[page] = true;
@@ -1113,8 +1157,8 @@ class ComicListState extends State<ComicList> {
if (!mounted) return;
if (res.success) {
if (res.data.isEmpty) {
_data[page] = const [];
setState(() {
_data[page] = const [];
_maxPage = page;
});
} else {
@@ -1164,6 +1208,11 @@ class ComicListState extends State<ComicList> {
@override
Widget build(BuildContext context) {
var type = appdata.settings['comicListDisplayMode'];
return type == 'paging' ? buildPagingMode() : buildContinuousMode();
}
Widget buildPagingMode() {
if (_error != null) {
return Column(
children: [
@@ -1212,6 +1261,85 @@ class ComicListState extends State<ComicList> {
],
);
}
Widget buildContinuousMode() {
if (_error != null && _data.isEmpty) {
return Column(
children: [
if (widget.errorLeading != null) widget.errorLeading!,
_buildPageSelector(),
Expanded(
child: NetworkError(
withAppbar: false,
message: _error!,
retry: () {
setState(() {
_error = null;
});
},
),
),
],
);
}
if (_data[_page] == null) {
_loadPage(_page);
return Column(
children: [
if (widget.errorLeading != null) widget.errorLeading!,
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
),
],
);
}
return SmoothCustomScrollView(
key: enablePageStorage ? PageStorageKey('scroll$_page') : null,
controller: widget.controller,
slivers: [
if (widget.leadingSliver != null) widget.leadingSliver!,
SliverGridComics(
comics: _data.values.expand((element) => element).toList(),
menuBuilder: widget.menuBuilder,
onLastItemBuild: () {
if (_error == null && (_maxPage == null || _page < _maxPage!)) {
_loadPage(_data.length + 1);
}
},
),
if (_error != null)
SliverToBoxAdapter(
child: Column(
children: [
Row(
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Expanded(child: Text(_error!, maxLines: 3)),
],
),
const SizedBox(height: 8),
Center(
child: OutlinedButton(
onPressed: () {
setState(() {
_error = null;
});
},
child: Text("Retry".tl),
),
),
],
).paddingHorizontal(16).paddingVertical(8),
)
else if (_maxPage == null || _page < _maxPage!)
const SliverListLoadingIndicator(),
if (widget.trailingSliver != null) widget.trailingSliver!,
],
);
}
}
class StarRating extends StatelessWidget {
@@ -1498,17 +1626,20 @@ class _SMClipper extends CustomClipper<Rect> {
}
class SimpleComicTile extends StatelessWidget {
const SimpleComicTile({super.key, required this.comic, this.onTap});
const SimpleComicTile(
{super.key, required this.comic, this.onTap, this.withTitle = false});
final Comic comic;
final void Function()? onTap;
final bool withTitle;
@override
Widget build(BuildContext context) {
var image = _findImageProvider(comic);
var child = image == null
Widget child = image == null
? const SizedBox()
: AnimatedImage(
image: image,
@@ -1518,26 +1649,51 @@ class SimpleComicTile extends StatelessWidget {
filterQuality: FilterQuality.medium,
);
return AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ?? () {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
child = Container(
width: 98,
height: 136,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
);
child = AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ??
() {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
child: child,
);
if (withTitle) {
child = Column(
mainAxisSize: MainAxisSize.min,
children: [
child,
const SizedBox(height: 4),
SizedBox(
width: 92,
child: Center(
child: Text(
comic.title.replaceAll('\n', ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
return child;
}
}

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_page.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

@@ -37,9 +37,11 @@ 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);
if (image != null && image is! String) return;
return _showInputDialog(title, validator, image);
case 'showSelectDialog':
var title = message['title'];
var options = message['options'];
@@ -124,12 +126,13 @@ mixin class JsUiApi {
controller?.close();
}
Future<String?> _showInputDialog(String title, JSInvokable? validator) async {
Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async {
String? result;
var func = validator == null ? null : JSAutoFreeFunction(validator);
await showInputDialog(
context: App.rootContext,
title: title,
image: image,
onConfirm: (v) {
if (func != null) {
var res = func.call([v]);

View File

@@ -148,3 +148,44 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
return false;
}
}
class SliverLazyToBoxAdapter extends StatelessWidget {
/// Creates a sliver that contains a single box widget which can be lazy loaded.
const SliverLazyToBoxAdapter({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SliverList.list(children: [
SizedBox(),
child,
]);
}
}
class SliverAnimatedVisibility extends StatelessWidget {
const SliverAnimatedVisibility({
super.key,
required this.visible,
required this.child,
});
final bool visible;
final Widget child;
@override
Widget build(BuildContext context) {
var child = visible ? this.child : const SizedBox.shrink();
return SliverToBoxAdapter(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: child,
),
);
}
}

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

@@ -61,7 +61,7 @@ class _MenuRoute<T> extends PopupRoute<T> {
child: BlurEffect(
borderRadius: BorderRadius.circular(4),
child: Material(
color: context.colorScheme.surface.toOpacity(0.78),
color: context.colorScheme.surface.toOpacity(0.92),
borderRadius: BorderRadius.circular(4),
child: Container(
width: width,

View File

@@ -125,11 +125,11 @@ class OverlayWidgetState extends State<OverlayWidget> {
void showDialogMessage(BuildContext context, String title, String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
builder: (context) => ContentDialog(
title: title,
content: Text(message).paddingHorizontal(16),
actions: [
TextButton(
FilledButton(
onPressed: context.pop,
child: Text("OK".tl),
)
@@ -168,7 +168,15 @@ Future<void> showConfirmDialog({
}
class LoadingDialogController {
void Function()? closeDialog;
double? _progress;
String? _message;
void Function()? _closeDialog;
void Function(double? value)? _serProgress;
void Function(String message)? _setMessage;
bool closed = false;
@@ -177,63 +185,86 @@ class LoadingDialogController {
return;
}
closed = true;
if (closeDialog == null) {
Future.microtask(closeDialog!);
if (_closeDialog == null) {
Future.microtask(_closeDialog!);
} else {
closeDialog!();
_closeDialog!();
}
}
void setProgress(double? value) {
if (closed) {
return;
}
_serProgress?.call(value);
}
void setMessage(String message) {
if (closed) {
return;
}
_setMessage?.call(message);
}
}
LoadingDialogController showLoadingDialog(BuildContext context,
{void Function()? onCancel,
bool barrierDismissible = true,
bool allowCancel = true,
String? message,
String cancelButtonText = "Cancel"}) {
LoadingDialogController showLoadingDialog(
BuildContext context, {
void Function()? onCancel,
bool barrierDismissible = true,
bool allowCancel = true,
String? message,
String cancelButtonText = "Cancel",
bool withProgress = false,
}) {
var controller = LoadingDialogController();
controller._message = message;
if (withProgress) {
controller._progress = 0;
}
var loadingDialogRoute = DialogRoute(
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return Dialog(
child: Container(
width: 100,
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const SizedBox(
width: 30,
height: 30,
child: CircularProgressIndicator(),
),
const SizedBox(
width: 16,
),
Text(
message ?? 'Loading',
style: const TextStyle(fontSize: 16),
),
const Spacer(),
if (allowCancel)
TextButton(
onPressed: () {
controller.close();
onCancel?.call();
},
child: Text(cancelButtonText.tl))
],
),
),
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return StatefulBuilder(builder: (context, setState) {
controller._serProgress = (value) {
setState(() {
controller._progress = value;
});
};
controller._setMessage = (message) {
setState(() {
controller._message = message;
});
};
return ContentDialog(
title: controller._message ?? 'Loading',
content: LinearProgressIndicator(
value: controller._progress,
backgroundColor: context.colorScheme.surfaceContainer,
).paddingHorizontal(16).paddingVertical(16),
actions: [
FilledButton(
onPressed: allowCancel
? () {
controller.close();
onCancel?.call();
}
: null,
child: Text(cancelButtonText.tl),
)
],
);
});
},
);
var navigator = Navigator.of(context, rootNavigator: true);
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
controller.closeDialog = () {
controller._closeDialog = () {
navigator.removeRoute(loadingDialogRoute);
};
@@ -259,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(
@@ -326,6 +359,7 @@ Future<void> showInputDialog({
String confirmText = "Confirm",
String cancelText = "Cancel",
RegExp? inputValidator,
String? image,
}) {
var controller = TextEditingController(text: initialValue);
bool isLoading = false;
@@ -338,14 +372,23 @@ 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),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
errorText: error,
),
).paddingHorizontal(12),
],
),
actions: [
Button.filled(
isLoading: isLoading,
@@ -444,9 +487,7 @@ Future<int?> showSelectDialog({
child: Text('Cancel'.tl),
),
FilledButton(
onPressed: current == null
? null
: context.pop,
onPressed: current == null ? null : context.pop,
child: Text('Confirm'.tl),
),
],

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;
@@ -187,7 +194,8 @@ 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(),
@@ -202,14 +210,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 +252,7 @@ class NaviPaneState extends State<NaviPane>
icon: Icon(action.icon),
onPressed: action.onTap,
),
)
),
],
),
),
@@ -261,21 +274,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 +296,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 +334,7 @@ class NaviPaneState extends State<NaviPane>
key: ValueKey(index + widget.paneItems.length),
),
),
const SizedBox(
height: 16,
)
const SizedBox(height: 16),
],
),
),
@@ -334,12 +343,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 +378,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 +409,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 +490,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 +579,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 +593,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

@@ -51,10 +51,32 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
static bool _isMouseScroll = App.isDesktop;
late int id;
static int _id = 0;
var activeChildren = <int>{};
ScrollState? parent;
@override
void initState() {
_controller = widget.controller ?? ScrollController();
super.initState();
id = _id;
_id++;
}
@override
void didChangeDependencies() {
parent = ScrollState.maybeOf(context);
super.didChangeDependencies();
}
@override
void dispose() {
parent?.onChildInactive(id);
super.dispose();
}
@override
@@ -66,8 +88,7 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
const BouncingScrollPhysics(),
);
}
return Listener(
behavior: HitTestBehavior.translucent,
var child = Listener(
onPointerDown: (event) {
_futurePosition = null;
if (_isMouseScroll) {
@@ -77,6 +98,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
}
},
onPointerSignal: (pointerSignal) {
if (activeChildren.isNotEmpty) {
return;
}
if (pointerSignal is PointerScrollEvent) {
if (HardwareKeyboard.instance.isShiftPressed) {
return;
@@ -93,17 +117,28 @@ 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!;
_controller.animateTo(
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((_) {
)
.then((_) {
var current = _controller.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
@@ -111,8 +146,14 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
});
}
},
child: ScrollControllerProvider._(
child: ScrollState._(
controller: _controller,
onChildActive: (id) {
activeChildren.add(id);
},
onChildInactive: (id) {
activeChildren.remove(id);
},
child: widget.builder(
context,
_controller,
@@ -122,25 +163,215 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
),
),
);
if (parent != null) {
return MouseRegion(
onEnter: (_) {
parent!.onChildActive(id);
},
onExit: (_) {
parent!.onChildInactive(id);
},
child: child,
);
}
return child;
}
}
class ScrollControllerProvider extends InheritedWidget {
const ScrollControllerProvider._({
class ScrollState extends InheritedWidget {
const ScrollState._({
required this.controller,
required super.child,
required this.onChildActive,
required this.onChildInactive,
});
final ScrollController controller;
static ScrollController of(BuildContext context) {
final ScrollControllerProvider? provider =
context.dependOnInheritedWidgetOfExactType<ScrollControllerProvider>();
return provider!.controller;
final void Function(int id) onChildActive;
final void Function(int id) onChildInactive;
static ScrollState of(BuildContext context) {
final ScrollState? provider =
context.dependOnInheritedWidgetOfExactType<ScrollState>();
return provider!;
}
static ScrollState? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ScrollState>();
}
@override
bool updateShouldNotify(ScrollControllerProvider oldWidget) {
bool updateShouldNotify(ScrollState oldWidget) {
return oldWidget.controller != controller;
}
}
class AppScrollBar extends StatefulWidget {
const AppScrollBar({
super.key,
required this.controller,
required this.child,
this.topPadding = 0,
});
final ScrollController controller;
final Widget child;
final double topPadding;
@override
State<AppScrollBar> createState() => _AppScrollBarState();
}
class _AppScrollBarState extends State<AppScrollBar> {
late final ScrollController _scrollController;
double minExtent = 0;
double maxExtent = 0;
double position = 0;
double viewHeight = 0;
final _scrollIndicatorSize = App.isDesktop ? 36.0 : 54.0;
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
@override
void initState() {
super.initState();
_scrollController = widget.controller;
_scrollController.addListener(onChanged);
Future.microtask(onChanged);
_dragGestureRecognizer = VerticalDragGestureRecognizer()
..onUpdate = onUpdate;
}
void onUpdate(DragUpdateDetails details) {
if (maxExtent - minExtent <= 0 ||
viewHeight == 0 ||
details.primaryDelta == null) {
return;
}
var offset = details.primaryDelta!;
var positionOffset =
offset / (viewHeight - _scrollIndicatorSize) * (maxExtent - minExtent);
_scrollController.jumpTo((position + positionOffset).clamp(
minExtent,
maxExtent,
));
}
void onChanged() {
if (_scrollController.positions.isEmpty) return;
var position = _scrollController.position;
if (position.minScrollExtent != minExtent ||
position.maxScrollExtent != maxExtent ||
position.pixels != this.position) {
setState(() {
minExtent = position.minScrollExtent;
maxExtent = position.maxScrollExtent;
this.position = position.pixels;
});
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constrains) {
var scrollHeight = (maxExtent - minExtent);
var height = constrains.maxHeight - widget.topPadding;
viewHeight = height;
var top = scrollHeight == 0
? 0.0
: (position - minExtent) /
scrollHeight *
(height - _scrollIndicatorSize);
return Stack(
children: [
Positioned.fill(
child: widget.child,
),
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: Column(
children: [
const Spacer(),
Icon(Icons.arrow_drop_up, size: 18),
Icon(Icons.arrow_drop_down, size: 18),
const Spacer(),
],
).paddingLeft(4),
),
),
),
),
),
],
);
},
);
}
}
class _ScrollIndicatorPainter extends CustomPainter {
final Color backgroundColor;
final Color shadowColor;
const _ScrollIndicatorPainter({
required this.backgroundColor,
required this.shadowColor,
});
@override
void paint(Canvas canvas, Size size) {
var path = Path()
..moveTo(size.width, 0)
..lineTo(size.width, size.height)
..arcToPoint(
Offset(size.width, 0),
radius: Radius.circular(size.width),
);
canvas.drawShadow(path, shadowColor, 2, true);
var backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
path = Path()
..moveTo(size.width, 0)
..lineTo(size.width, size.height)
..arcToPoint(
Offset(size.width, 0),
radius: Radius.circular(size.width),
);
canvas.drawPath(path, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! _ScrollIndicatorPainter ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.shadowColor != shadowColor;
}
}

View File

@@ -1,15 +1,13 @@
part of 'components.dart';
class SideBarRoute<T> extends PopupRoute<T> {
SideBarRoute(this.title, this.widget,
SideBarRoute(this.widget,
{this.showBarrier = true,
this.useSurfaceTintColor = false,
required this.width,
this.addBottomPadding = true,
this.addTopPadding = true});
final String? title;
final Widget widget;
final bool showBarrier;
@@ -36,11 +34,7 @@ class SideBarRoute<T> extends PopupRoute<T> {
Animation<double> secondaryAnimation) {
bool showSideBar = MediaQuery.of(context).size.width > width;
Widget body = SidebarBody(
title: title,
widget: widget,
autoChangeTitleBarColor: !useSurfaceTintColor,
);
Widget body = widget;
if (addTopPadding) {
body = Padding(
@@ -129,97 +123,13 @@ class SideBarRoute<T> extends PopupRoute<T> {
}
}
class SidebarBody extends StatefulWidget {
const SidebarBody(
{required this.title,
required this.widget,
required this.autoChangeTitleBarColor,
super.key});
final String? title;
final Widget widget;
final bool autoChangeTitleBarColor;
@override
State<SidebarBody> createState() => _SidebarBodyState();
}
class _SidebarBodyState extends State<SidebarBody> {
bool top = true;
@override
Widget build(BuildContext context) {
Widget body = Expanded(child: widget.widget);
if (widget.autoChangeTitleBarColor) {
body = NotificationListener<ScrollNotification>(
onNotification: (notifications) {
if (notifications.metrics.pixels ==
notifications.metrics.minScrollExtent &&
!top) {
setState(() {
top = true;
});
} else if (notifications.metrics.pixels !=
notifications.metrics.minScrollExtent &&
top) {
setState(() {
top = false;
});
}
return false;
},
child: body,
);
}
return Column(
children: [
if (widget.title != null)
Container(
height: 60 + MediaQuery.of(context).padding.top,
color: top
? null
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: Row(
children: [
const SizedBox(
width: 8,
),
Tooltip(
message: "Back".tl,
child: IconButton(
iconSize: 25,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
),
const SizedBox(
width: 10,
),
Text(
widget.title!,
style: const TextStyle(fontSize: 22),
)
],
),
),
body
],
);
}
}
Future<void> showSideBar(BuildContext context, Widget widget,
{String? title,
bool showBarrier = true,
{bool showBarrier = true,
bool useSurfaceTintColor = false,
double width = 500,
bool addTopPadding = false}) {
return Navigator.of(context).push(
SideBarRoute(
title,
widget,
showBarrier: showBarrier,
useSurfaceTintColor: useSurfaceTintColor,

View File

@@ -6,61 +6,102 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:window_manager/window_manager.dart';
const _kTitleBarHeight = 36.0;
class WindowFrameController extends StateController {
bool useDarkTheme = false;
class WindowFrameController extends InheritedWidget {
/// Whether the window frame is hidden.
final bool isWindowFrameHidden;
bool isHideWindowFrame = false;
/// Sets the visibility of the window frame.
final void Function(bool) setWindowFrame;
void setDarkTheme() {
useDarkTheme = true;
update();
}
/// Adds a listener that will be called when close button is clicked.
/// The listener should return `true` to allow the window to be closed.
final void Function(WindowCloseListener listener) addCloseListener;
void resetTheme() {
useDarkTheme = false;
update();
}
/// Removes a close listener.
final void Function(WindowCloseListener listener) removeCloseListener;
VoidCallback openSideBar = () {};
const WindowFrameController._create({
required this.isWindowFrameHidden,
required this.setWindowFrame,
required this.addCloseListener,
required this.removeCloseListener,
required super.child,
});
void hideWindowFrame() {
isHideWindowFrame = true;
update();
}
void showWindowFrame() {
isHideWindowFrame = false;
update();
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
return false;
}
}
class WindowFrame extends StatelessWidget {
class WindowFrame extends StatefulWidget {
const WindowFrame(this.child, {super.key});
final Widget child;
@override
Widget build(BuildContext context) {
StateController.putIfNotExists<WindowFrameController>(
WindowFrameController());
if (App.isMobile) return child;
return StateBuilder<WindowFrameController>(builder: (controller) {
if (controller.isHideWindowFrame) return child;
State<WindowFrame> createState() => _WindowFrameState();
var body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
child: child,
static WindowFrameController of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
}
}
typedef WindowCloseListener = bool Function();
class _WindowFrameState extends State<WindowFrame> {
bool isWindowFrameHidden = false;
bool useDarkTheme = false;
var closeListeners = <WindowCloseListener>[];
/// Sets the visibility of the window frame.
void setWindowFrame(bool show) {
setState(() {
isWindowFrameHidden = !show;
});
}
/// Adds a listener that will be called when close button is clicked.
/// The listener should return `true` to allow the window to be closed.
void addCloseListener(WindowCloseListener listener) {
closeListeners.add(listener);
}
/// Removes a close listener.
void removeCloseListener(WindowCloseListener listener) {
closeListeners.remove(listener);
}
void _onClose() {
for (var listener in closeListeners) {
if (!listener()) {
return;
}
}
exit(0);
}
@override
Widget build(BuildContext context) {
if (App.isMobile) return widget.child;
Widget body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: isWindowFrameHidden
? null
: const EdgeInsets.only(top: _kTitleBarHeight),
),
child: widget.child,
),
),
if (!isWindowFrameHidden)
Positioned(
top: 0,
left: 0,
@@ -69,7 +110,7 @@ class WindowFrame extends StatelessWidget {
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
brightness: controller.useDarkTheme ? Brightness.dark : null,
brightness: useDarkTheme ? Brightness.dark : null,
),
child: Builder(builder: (context) {
return SizedBox(
@@ -91,12 +132,14 @@ class WindowFrame extends StatelessWidget {
'Venera',
style: TextStyle(
fontSize: 13,
color: (controller.useDarkTheme ||
color: (useDarkTheme ||
context.brightness == Brightness.dark)
? Colors.white
: Colors.black,
),
).toAlign(Alignment.centerLeft).paddingLeft(4+(App.isMacOS?25:0)),
)
.toAlign(Alignment.centerLeft)
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
),
),
if (kDebugMode)
@@ -104,7 +147,10 @@ class WindowFrame extends StatelessWidget {
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) const WindowButtons()
if (!App.isMacOS)
_WindowButtons(
onClose: _onClose,
)
],
),
);
@@ -112,70 +158,33 @@ class WindowFrame extends StatelessWidget {
),
),
)
],
);
],
);
if (App.isLinux) {
return VirtualWindowFrame(child: body);
} else {
return body;
}
});
}
if (App.isLinux) {
body = VirtualWindowFrame(child: body);
}
Widget buildMenuButton(
WindowFrameController controller, BuildContext context) {
return InkWell(
onTap: () {
controller.openSideBar();
},
child: SizedBox(
width: 42,
height: double.infinity,
child: Center(
child: CustomPaint(
size: const Size(18, 20),
painter: _MenuPainter(
color: (controller.useDarkTheme ||
Theme.of(context).brightness == Brightness.dark)
? Colors.white
: Colors.black),
),
),
));
return WindowFrameController._create(
isWindowFrameHidden: isWindowFrameHidden,
setWindowFrame: setWindowFrame,
addCloseListener: addCloseListener,
removeCloseListener: removeCloseListener,
child: body,
);
}
}
class _MenuPainter extends CustomPainter {
final Color color;
class _WindowButtons extends StatefulWidget {
const _WindowButtons({required this.onClose});
_MenuPainter({this.color = Colors.black});
final void Function() onClose;
@override
void paint(Canvas canvas, Size size) {
final paint = getPaint(color);
final path = Path()
..moveTo(0, size.height / 4)
..lineTo(size.width, size.height / 4)
..moveTo(0, size.height / 4 * 2)
..lineTo(size.width, size.height / 4 * 2)
..moveTo(0, size.height / 4 * 3)
..lineTo(size.width, size.height / 4 * 3);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
State<_WindowButtons> createState() => _WindowButtonsState();
}
class WindowButtons extends StatefulWidget {
const WindowButtons({super.key});
@override
State<WindowButtons> createState() => _WindowButtonsState();
}
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
bool isMaximized = false;
@override
@@ -264,9 +273,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
color: !dark ? Colors.white : Colors.black,
),
hoverColor: Colors.red,
onPressed: () {
windowManager.close();
},
onPressed: widget.onClose,
)
],
),
@@ -489,7 +496,7 @@ class WindowPlacement {
static Future<WindowPlacement> get current async {
var rect = await windowManager.getBounds();
if(validate(rect)) {
if (validate(rect)) {
lastValidRect = rect;
} else {
rect = lastValidRect ?? defaultPlacement.rect;
@@ -553,22 +560,18 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
}
Widget _buildVirtualWindowFrame(BuildContext context) {
return DecoratedBox(
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_isMaximized ? 0 : 8),
color: Colors.transparent,
border: Border.all(
color: Theme.of(context).dividerColor,
width: (_isMaximized || _isFullScreen) ? 0 : 1,
),
boxShadow: <BoxShadow>[
if (!_isMaximized && !_isFullScreen)
BoxShadow(
color: Colors.black.toOpacity(0.1),
offset: Offset(0.0, _isFocused ? 4 : 2),
blurRadius: 6,
)
BoxShadow(
color: Colors.black.toOpacity(_isFocused ? 0.4 : 0.2),
blurRadius: 4,
)
],
),
clipBehavior: Clip.antiAlias,
child: widget.child,
);
}
@@ -577,7 +580,10 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
Widget build(BuildContext context) {
return DragToResizeArea(
enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null,
child: _buildVirtualWindowFrame(context),
child: Padding(
padding: EdgeInsets.all(_isMaximized ? 0 : 4),
child: _buildVirtualWindowFrame(context),
),
);
}
@@ -634,5 +640,5 @@ TransitionBuilder VirtualWindowFrameInit() {
}
void debug() {
ComicSource.reload();
ComicSourceManager().reload();
}

View File

@@ -3,14 +3,17 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/history.dart';
import 'appdata.dart';
import 'favorites.dart';
import 'local.dart';
export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.2.3";
final version = "1.5.1";
bool get isAndroid => Platform.isAndroid;
@@ -27,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" &&
@@ -44,6 +51,7 @@ class _App {
late String dataPath;
late String cachePath;
String? externalStoragePath;
final rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -51,8 +59,16 @@ class _App {
BuildContext get rootContext => rootNavigatorKey.currentContext!;
final Appdata data = appdata;
final HistoryManager history = HistoryManager();
final LocalFavoritesManager favorites = LocalFavoritesManager();
final LocalManager local = LocalManager();
void rootPop() {
rootNavigatorKey.currentState?.pop();
rootNavigatorKey.currentState?.maybePop();
}
void pop() {
@@ -66,6 +82,19 @@ class _App {
Future<void> init() async {
cachePath = (await getApplicationCacheDirectory()).path;
dataPath = (await getApplicationSupportDirectory()).path;
if (isAndroid) {
externalStoragePath = (await getExternalStorageDirectory())!.path;
}
isInitialized = true;
}
Future<void> initComponents() async {
await Future.wait([
data.init(),
history.init(),
favorites.init(),
local.init(),
]);
}
Function? _forceRebuildHandler;

View File

@@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart';
const double _kBackGestureWidth = 20.0;
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
@@ -115,7 +116,14 @@ 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,

View File

@@ -3,28 +3,32 @@ 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';
class _Appdata {
final _Settings settings = _Settings();
class Appdata with Init {
Appdata._create();
final Settings settings = Settings._create();
var searchHistory = <String>[];
bool _isSavingData = false;
Future<void> saveData([bool sync = true]) async {
if (_isSavingData) {
await Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 20));
return _isSavingData;
});
while (_isSavingData) {
await Future.delayed(const Duration(milliseconds: 20));
}
_isSavingData = true;
var data = jsonEncode(toJson());
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
await file.writeAsString(data);
_isSavingData = false;
try {
var data = jsonEncode(toJson());
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
await file.writeAsString(data);
} finally {
_isSavingData = false;
}
if (sync) {
DataSync().uploadData();
}
@@ -51,33 +55,8 @@ class _Appdata {
saveData();
}
Future<void> init() async {
var dataPath = (await getApplicationSupportDirectory()).path;
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];
}
}
searchHistory = List.from(json['searchHistory']);
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
}
}
Map<String, dynamic> toJson() {
return {
'settings': settings._data,
'searchHistory': searchHistory,
};
return {'settings': settings._data, 'searchHistory': searchHistory};
}
/// Following fields are related to device-specific data and should not be synced.
@@ -104,16 +83,57 @@ class _Appdata {
var implicitData = <String, dynamic>{};
void writeImplicitData() {
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
file.writeAsString(jsonEncode(implicitData));
void writeImplicitData() async {
while (_isSavingData) {
await Future.delayed(const Duration(milliseconds: 20));
}
_isSavingData = true;
try {
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
await file.writeAsString(jsonEncode(implicitData));
} finally {
_isSavingData = false;
}
}
@override
Future<void> doInit() async {
var dataPath = (await getApplicationSupportDirectory()).path;
var file = File(FilePath.join(dataPath, 'appdata.json'));
if (!await file.exists()) {
return;
}
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();
}
try {
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
}
} 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();
}
}
}
final appdata = _Appdata();
final appdata = Appdata._create();
class _Settings with ChangeNotifier {
_Settings();
class Settings with ChangeNotifier {
Settings._create();
final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief
@@ -126,19 +146,23 @@ class _Settings with ChangeNotifier {
'explore_pages': [],
'categories': [],
'favorites': [],
'searchSources': null,
'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false,
'blockedWords': [],
'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumber': 1, // 1 - 5
'readerScreenPicNumberForLandscape': 1, // 1 - 5
'readerScreenPicNumberForPortrait': 1, // 1 - 5
'enableTapToTurnPages': true,
'reverseTapToTurnPages': false,
'enablePageAnimation': true,
'language': 'system', // system, zh-CN, zh-TW, en-US
'cacheSize': 2048, // in MB
'downloadThreads': 5,
'enableLongPressToZoom': true,
'longPressZoomPosition': "press", // press, center
'checkUpdateOnStart': false,
'limitImageWidth': true,
'webdav': [], // empty means not configured
@@ -155,7 +179,19 @@ class _Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://raw.githubusercontent.com/venera-app/venera-configs/master/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
};
operator [](String key) {
@@ -164,6 +200,45 @@ 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();
}
@@ -184,9 +259,12 @@ const defaultCustomImageProcessing = '''
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
*/
function processImage(image, cid, eid, page, sourceKey) {
let image = new Promise((resolve, reject) => {
let futureImage = new Promise((resolve, reject) => {
resolve(image);
});
return image;
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

@@ -34,24 +34,28 @@ class CategoryButtonData {
});
}
class CategoryItem {
final String label;
final PageJumpTarget target;
const CategoryItem(this.label, this.target);
}
abstract class BaseCategoryPart {
String get title;
List<String> get categories;
List<String>? get categoryParams => null;
List<CategoryItem> get categories;
bool get enableRandom;
String get categoryType;
/// Data class for building a part of category page.
const BaseCategoryPart();
}
class FixedCategoryPart extends BaseCategoryPart {
@override
final List<String> categories;
final List<CategoryItem> categories;
@override
bool get enableRandom => false;
@@ -59,19 +63,12 @@ class FixedCategoryPart extends BaseCategoryPart {
@override
final String title;
@override
final String categoryType;
@override
final List<String>? categoryParams;
/// A [BaseCategoryPart] that show fixed tags on category page.
const FixedCategoryPart(this.title, this.categories, this.categoryType,
[this.categoryParams]);
const FixedCategoryPart(this.title, this.categories);
}
class RandomCategoryPart extends BaseCategoryPart {
final List<String> tags;
final List<CategoryItem> all;
final int randomNumber;
@@ -81,71 +78,63 @@ class RandomCategoryPart extends BaseCategoryPart {
@override
bool get enableRandom => true;
@override
final String categoryType;
List<String> _categories() {
if (randomNumber >= tags.length) {
return tags;
List<CategoryItem> _categories() {
if (randomNumber >= all.length) {
return all;
}
var start = math.Random().nextInt(tags.length - randomNumber);
return tags.sublist(start, start + randomNumber);
var start = math.Random().nextInt(all.length - randomNumber);
return all.sublist(start, start + randomNumber);
}
@override
List<String> get categories => _categories();
List<CategoryItem> get categories => _categories();
/// A [BaseCategoryPart] that show random tags on category page.
/// A [BaseCategoryPart] that show a part of random tags on category page.
const RandomCategoryPart(
this.title, this.tags, this.randomNumber, this.categoryType);
this.title,
this.all,
this.randomNumber,
);
}
class RandomCategoryPartWithRuntimeData extends BaseCategoryPart {
final Iterable<String> Function() loadTags;
class DynamicCategoryPart extends BaseCategoryPart {
final JSAutoFreeFunction loader;
final int randomNumber;
final String sourceKey;
@override
final String title;
@override
bool get enableRandom => true;
@override
final String categoryType;
static final random = math.Random();
List<String> _categories() {
var tags = loadTags();
if (randomNumber >= tags.length) {
return tags.toList();
List<CategoryItem> get categories {
var data = loader([]);
if (data is! List) {
throw "DynamicCategoryPart loader must return a List";
}
final start = random.nextInt(tags.length - randomNumber);
var res = List.filled(randomNumber, '');
int index = -1;
for (var s in tags) {
index++;
if (start > index) {
continue;
} else if (index == start + randomNumber) {
break;
var res = <CategoryItem>[];
for (var item in data) {
if (item is! Map) {
throw "DynamicCategoryPart loader must return a List of Map";
}
res[index - start] = s;
var label = item['label'];
var target = PageJumpTarget.parse(sourceKey, item['target']);
if (label is! String) {
throw "Category label must be a String";
}
res.add(CategoryItem(label, target));
}
return res;
}
@override
List<String> get categories => _categories();
bool get enableRandom => false;
/// A [BaseCategoryPart] that show random tags on category page.
RandomCategoryPartWithRuntimeData(
this.title, this.loadTags, this.randomNumber, this.categoryType);
@override
final String title;
/// A [BaseCategoryPart] that show dynamic tags on category page.
const DynamicCategoryPart(this.title, this.loader, this.sourceKey);
}
CategoryData getCategoryDataWithKey(String key) {
for (var source in ComicSource._sources) {
for (var source in ComicSource.all()) {
if (source.categoryData?.key == key) {
return source.categoryData!;
}

View File

@@ -11,8 +11,11 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/pages/category_comics_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
@@ -27,81 +30,29 @@ part 'parser.dart';
part 'models.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
part 'types.dart';
/// 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);
class ComicSourceManager with ChangeNotifier, Init {
final List<ComicSource> _sources = [];
typedef LoginFunction = Future<Res<bool>> Function(String, String);
static ComicSourceManager? _instance;
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
ComicSourceManager._create();
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
String id, String? ep);
factory ComicSourceManager() => _instance ??= ComicSourceManager._create();
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
String id, String? subId, int page, String? replyTo);
List<ComicSource> all() => List.from(_sources);
typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo);
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);
/// [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 HandleClickTagEvent = Map<String, 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);
class ComicSource {
static final List<ComicSource> _sources = [];
static final List<Function> _listeners = [];
static void addListener(Function listener) {
_listeners.add(listener);
}
static void removeListener(Function listener) {
_listeners.remove(listener);
}
static void notifyListeners() {
for (var listener in _listeners) {
listener();
}
}
static List<ComicSource> all() => List.from(_sources);
static ComicSource? find(String key) =>
ComicSource? find(String key) =>
_sources.firstWhereOrNull((element) => element.key == key);
static ComicSource? fromIntKey(int key) =>
ComicSource? fromIntKey(int key) =>
_sources.firstWhereOrNull((element) => element.key.hashCode == key);
static Future<void> init() async {
@override
@protected
Future<void> doInit() async {
await JsEngine().ensureInit();
final path = "${App.dataPath}/comic_source";
if (!(await Directory(path).exists())) {
Directory(path).create();
@@ -120,26 +71,49 @@ class ComicSource {
}
}
static Future reload() async {
Future reload() async {
_sources.clear();
JsEngine().runCode("ComicSource.sources = {};");
await init();
await doInit();
notifyListeners();
}
static void add(ComicSource source) {
void add(ComicSource source) {
_sources.add(source);
notifyListeners();
}
static void remove(String key) {
void remove(String key) {
_sources.removeWhere((element) => element.key == key);
notifyListeners();
}
static final availableUpdates = <String, String>{};
bool get isEmpty => _sources.isEmpty;
static bool get isEmpty => _sources.isEmpty;
/// Key is the source key, value is the version.
final _availableUpdates = <String, String>{};
void updateAvailableUpdates(Map<String, String> updates) {
_availableUpdates.addAll(updates);
notifyListeners();
}
Map<String, String> get availableUpdates => Map.from(_availableUpdates);
void notifyStateChange() {
notifyListeners();
}
}
class ComicSource {
static List<ComicSource> all() => ComicSourceManager().all();
static ComicSource? find(String key) => ComicSourceManager().find(key);
static ComicSource? fromIntKey(int key) =>
ComicSourceManager().fromIntKey(key);
static bool get isEmpty => ComicSourceManager().isEmpty;
/// Name of this source.
final String name;
@@ -210,6 +184,9 @@ class ComicSource {
final HandleClickTagEvent? handleClickTagEvent;
/// Callback when a tag suggestion is selected in search.
final TagSuggestionSelectFunc? onTagSuggestionSelected;
final LinkHandler? linkHandler;
final bool enableTagsSuggestions;
@@ -285,6 +262,7 @@ class ComicSource {
this.idMatcher,
this.translations,
this.handleClickTagEvent,
this.onTagSuggestionSelected,
this.linkHandler,
this.enableTagsSuggestions,
this.enableTagsTranslate,
@@ -321,7 +299,7 @@ class AccountConfig {
this.onLoginWithWebviewSuccess,
this.cookieFields,
this.validateCookies,
) : infoItems = const [];
) : infoItems = const [];
}
class AccountInfoItem {
@@ -377,7 +355,7 @@ class ExplorePagePart {
/// - category:categoryName
///
/// End with `@`+`param` if the category has a parameter.
final String? viewMore;
final PageJumpTarget? viewMore;
const ExplorePagePart(this.title, this.comics, this.viewMore);
}
@@ -417,15 +395,20 @@ class SearchOptions {
const SearchOptions(this.options, this.label, this.type, this.defaultVal);
String get defaultValue => defaultVal ?? options.keys.first;
String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? "";
}
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.
///
@@ -436,7 +419,7 @@ 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 {
@@ -451,6 +434,9 @@ class RankingData {
}
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.
@@ -461,7 +447,7 @@ 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

@@ -37,6 +37,8 @@ class FavoriteData {
final AddOrDelFavFunc? addOrDelFavorite;
final bool singleFolderForSingleComic;
const FavoriteData({
required this.key,
required this.title,
@@ -49,6 +51,7 @@ class FavoriteData {
this.allFavoritesId,
this.addOrDelFavorite,
this.isOldToNewSort,
this.singleFolderForSingleComic = false,
});
}

View File

@@ -111,6 +111,29 @@ class Comic {
@override
int get hashCode => id.hashCode ^ sourceKey.hashCode;
@override
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 {
@@ -128,7 +151,7 @@ class ComicDetails with HistoryMixin {
final Map<String, List<String>> tags;
/// id-name
final Map<String, String>? chapters;
final ComicChapters? chapters;
final List<String>? thumbnails;
@@ -166,7 +189,9 @@ class ComicDetails with HistoryMixin {
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
var res = <String, List<String>>{};
map.forEach((key, value) {
res[key] = List<String>.from(value);
if (value is List) {
res[key] = List<String>.from(value);
}
});
return res;
}
@@ -177,9 +202,7 @@ class ComicDetails with HistoryMixin {
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
chapters = json["chapters"] == null
? null
: Map<String, String>.from(json["chapters"]),
chapters = ComicChapters.fromJsonOrNull(json["chapters"]),
sourceKey = json["sourceKey"],
comicId = json["comicId"],
thumbnails = ListOrNull.from(json["thumbnails"]),
@@ -260,6 +283,41 @@ class ComicDetails with HistoryMixin {
}
return null;
}
String? _validateUpdateTime(String time) {
time = time.split(" ").first;
var segments = time.split("-");
if (segments.length != 3) return null;
var year = int.tryParse(segments[0]);
var month = int.tryParse(segments[1]);
var day = int.tryParse(segments[2]);
if (year == null || month == null || day == null) return null;
if (year < 2000 || year > 3000) return null;
if (month < 1 || month > 12) return null;
if (day < 1 || day > 31) return null;
return "$year-$month-$day";
}
String? findUpdateTime() {
if (updateTime != null) {
return _validateUpdateTime(updateTime!);
}
const acceptedNamespaces = [
"更新",
"最後更新",
"最后更新",
"update",
"last update",
];
for (var entry in tags.entries) {
if (acceptedNamespaces.contains(entry.key.toLowerCase()) &&
entry.value.isNotEmpty) {
var value = entry.value.first;
return _validateUpdateTime(value);
}
}
return null;
}
}
class ArchiveInfo {
@@ -272,3 +330,232 @@ class ArchiveInfo {
description = json["description"],
id = json["id"];
}
class ComicChapters {
final Map<String, String>? _chapters;
final Map<String, Map<String, String>>? _groupedChapters;
/// Create a ComicChapters object with a flat map
const ComicChapters(Map<String, String> this._chapters)
: _groupedChapters = null;
/// Create a ComicChapters object with a grouped map
const ComicChapters.grouped(
Map<String, Map<String, String>> this._groupedChapters)
: _chapters = null;
factory ComicChapters.fromJson(dynamic json) {
if (json is! Map) throw ArgumentError("Invalid json type");
var chapters = <String, String>{};
var groupedChapters = <String, Map<String, String>>{};
for (var entry in json.entries) {
var key = entry.key;
var value = entry.value;
if (key is! String) throw ArgumentError("Invalid key type");
if (value is Map) {
groupedChapters[key] = Map.from(value);
} else {
chapters[key] = value.toString();
}
}
if (chapters.isNotEmpty) {
return ComicChapters(chapters);
} else if (groupedChapters.isNotEmpty) {
return ComicChapters.grouped(groupedChapters);
} else {
// return a empty list.
return ComicChapters(chapters);
}
}
static fromJsonOrNull(dynamic json) {
if (json == null) return null;
return ComicChapters.fromJson(json);
}
Map<String, dynamic> toJson() {
if (_chapters != null) {
return _chapters;
} else {
return _groupedChapters!;
}
}
/// Whether the chapters are grouped
bool get isGrouped => _groupedChapters != null;
/// All group names
Iterable<String> get groups => _groupedChapters?.keys ?? [];
/// All chapters.
/// If the chapters are grouped, all groups will be merged.
Map<String, String> get allChapters {
if (_chapters != null) return _chapters;
var res = <String, String>{};
for (var entry in _groupedChapters!.values) {
res.addAll(entry);
}
return res;
}
/// Get a group of chapters by name
Map<String, String> getGroup(String group) {
return _groupedChapters![group] ?? {};
}
/// Get a group of chapters by index(0-based)
Map<String, String> getGroupByIndex(int index) {
return _groupedChapters!.values.elementAt(index);
}
/// Get total number of chapters
int get length {
return isGrouped
? _groupedChapters!.values.map((e) => e.length).reduce((a, b) => a + b)
: _chapters!.length;
}
/// Get the number of groups
int get groupCount => _groupedChapters?.length ?? 0;
/// Iterate all chapter ids
Iterable<String> get ids sync* {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
yield* entry.keys;
}
} else {
yield* _chapters!.keys;
}
}
/// Iterate all chapter titles
Iterable<String> get titles sync* {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
yield* entry.values;
}
} else {
yield* _chapters!.values;
}
}
String? operator [](String key) {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
if (entry.containsKey(key)) return entry[key];
}
return null;
} else {
return _chapters![key];
}
}
}
class PageJumpTarget {
final String sourceKey;
final String page;
final Map<String, dynamic>? attributes;
const PageJumpTarget(this.sourceKey, this.page, this.attributes);
static PageJumpTarget parse(String sourceKey, dynamic value) {
if (value is Map) {
if (value['page'] != null) {
return PageJumpTarget(
sourceKey,
value["page"] ?? "search",
value["attributes"],
);
} else if (value["action"] != null) {
// old version `onClickTag`
var page = value["action"];
if (page == "search") {
return PageJumpTarget(
sourceKey,
"search",
{
"text": value["keyword"],
},
);
} else if (page == "category") {
return PageJumpTarget(
sourceKey,
"category",
{
"category": value["keyword"],
"param": value["param"],
},
);
} else {
return PageJumpTarget(sourceKey, page, null);
}
}
} else if (value is String) {
// old version string encoding. search: `search:keyword`, category: `category:keyword` or `category:keyword@param`
var segments = value.split(":");
var page = segments[0];
if (page == "search") {
return PageJumpTarget(
sourceKey,
"search",
{
"text": segments[1],
},
);
} else if (page == "category") {
var c = segments[1];
if (c.contains('@')) {
var parts = c.split('@');
return PageJumpTarget(
sourceKey,
"category",
{
"category": parts[0],
"param": parts[1],
},
);
} else {
return PageJumpTarget(
sourceKey,
"category",
{
"category": c,
},
);
}
} else {
return PageJumpTarget(sourceKey, page, null);
}
}
return PageJumpTarget(sourceKey, "Invalid Data", null);
}
void jump(BuildContext context) {
if (page == "search") {
context.to(
() => SearchResultPage(
text: attributes?["text"] ?? attributes?["keyword"] ?? "",
sourceKey: sourceKey,
options: List.from(attributes?["options"] ?? []),
),
);
} else if (page == "category") {
var key = ComicSource.find(sourceKey)!.categoryData!.key;
context.to(
() => CategoryComicsPage(
categoryKey: key,
category: attributes?["category"] ??
(throw ArgumentError("Category name is required")),
options: List.from(attributes?["options"] ?? []),
param: attributes?["param"],
),
);
} else {
Log.error("Page Jump", "Unknown page: $page");
}
}
}

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++;
}
}
@@ -82,7 +87,7 @@ class ComicSourceParser {
js = js.replaceAll("\r\n", "\n");
var line1 = js
.split('\n')
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
.firstWhereOrNull((e) => e.trim().startsWith("class "));
if (line1 == null ||
!line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) {
@@ -90,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,
}),
);
}
}
@@ -149,6 +157,7 @@ class ComicSourceParser {
_parseIdMatch(),
_parseTranslation(),
_parseClickTagEvent(),
_parseTagSuggestionSelectFunc(),
_parseLinkHandler(),
_getValue("search.enableTagsSuggestions") ?? false,
_getValue("comic.enableTagsTranslate") ?? false,
@@ -175,8 +184,10 @@ class ComicSourceParser {
}
bool _checkExists(String index) {
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined");
return JsEngine().runCode(
"ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined",
);
}
dynamic _getValue(String index) {
@@ -277,16 +288,24 @@ class ComicSourceParser {
if (type == "singlePageWithMultiPart") {
loadMultiPart = () async {
try {
var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load()");
return Res(List.from(res.keys
.map((e) => ExplorePagePart(
e,
(res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!))
.toList(),
null))
.toList()));
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load()",
);
return Res(
List.from(
res.keys
.map(
(e) => ExplorePagePart(
e,
(res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!))
.toList(),
null,
),
)
.toList(),
),
);
} catch (e, s) {
Log.error("Data Analysis", "$e\n$s");
return Res.error(e.toString());
@@ -297,11 +316,15 @@ class ComicSourceParser {
loadPage = (int page) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})",
);
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
subData: res["maxPage"]);
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -311,10 +334,13 @@ class ComicSourceParser {
loadNext = (next) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})");
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})",
);
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
List.generate(
res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"],
);
} catch (e, s) {
@@ -326,8 +352,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) {
@@ -336,7 +363,7 @@ class ComicSourceParser {
(e['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
e['viewMore'],
PageJumpTarget.parse(_key!, e['viewMore']),
);
}),
),
@@ -350,19 +377,22 @@ class ComicSourceParser {
loadMixed = (index) async {
try {
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})");
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})",
);
var list = <Object>[];
for (var data in (res['data'] as List)) {
if (data is List) {
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
} else if (data is Map) {
list.add(ExplorePagePart(
data['title'],
(data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
data['viewMore'],
));
list.add(
ExplorePagePart(
data['title'],
(data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!);
}).toList(),
data['viewMore'],
),
);
}
}
return Res(list, subData: res['maxPage']);
@@ -372,21 +402,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;
}
@@ -404,50 +438,163 @@ class ComicSourceParser {
var categoryParts = <BaseCategoryPart>[];
for (var c in doc["parts"]) {
final String name = c["name"];
final String type = c["type"];
final List<String> tags = List.from(c["categories"]);
final String itemType = c["itemType"];
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
final String? groupParam = c["groupParam"];
if (groupParam != null) {
categoryParams = List.filled(tags.length, groupParam);
if (c["categories"] != null && c["categories"] is! List) {
continue;
}
if (type == "fixed") {
categoryParts
.add(FixedCategoryPart(name, tags, itemType, categoryParams));
} else if (type == "random") {
categoryParts.add(
RandomCategoryPart(name, tags, c["randomNumber"] ?? 1, itemType));
List? categories = c["categories"];
if (categories == null || categories[0] is Map) {
// new format
final String name = c["name"];
final String type = c["type"];
final cs = categories
?.map(
(e) => CategoryItem(
e['label'],
PageJumpTarget.parse(_key!, e['target']),
),
)
.toList();
if (type != "dynamic" && (cs == null || cs.isEmpty)) {
continue;
}
if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs!));
} else if (type == "random") {
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!),
);
}
} else {
// old format
final String name = c["name"];
final String type = c["type"];
final List<String> tags = List.from(c["categories"]);
final String itemType = c["itemType"];
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
final String? groupParam = c["groupParam"];
if (groupParam != null) {
categoryParams = List.filled(tags.length, groupParam);
}
var cs = <CategoryItem>[];
for (int i = 0; i < tags.length; i++) {
PageJumpTarget target;
if (itemType == 'category') {
target = PageJumpTarget(_key!, 'category', {
"category": tags[i],
"param": categoryParams?.elementAtOrNull(i),
});
} else if (itemType == 'search') {
target = PageJumpTarget(_key!, 'search', {"keyword": tags[i]});
} else if (itemType == 'search_with_namespace') {
target = PageJumpTarget(_key!, 'search', {
"keyword": "$name:$tags[i]",
});
} else {
target = PageJumpTarget(_key!, itemType, null);
}
cs.add(CategoryItem(tags[i], target));
}
if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs));
} else if (type == "random") {
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>{};
@@ -462,7 +609,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 {
@@ -471,9 +618,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());
@@ -487,8 +637,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) {
@@ -499,25 +651,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() {
@@ -534,12 +699,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;
@@ -554,9 +721,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());
@@ -570,8 +740,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) {
@@ -620,6 +792,9 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic = _getValue(
"favorites.singleFolderForSingleComic",
);
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) {
@@ -672,9 +847,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());
@@ -694,8 +872,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) {
@@ -773,6 +953,7 @@ class ComicSourceParser {
deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc,
isOldToNewSort: isOldToNewSort,
singleFolderForSingleComic: singleFolderForSingleComic ?? false,
);
}
@@ -785,8 +966,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());
@@ -976,9 +1158,25 @@ class ComicSourceParser {
var res = JsEngine().runCode("""
ComicSource.sources.$_key.comic.onClickTag(${jsonEncode(namespace)}, ${jsonEncode(tag)})
""");
var r = Map<String, String?>.from(res);
if (res is! Map) {
return null;
}
var r = Map<String, dynamic>.from(res);
r.removeWhere((key, value) => value == null);
return Map.from(r);
return PageJumpTarget.parse(_key!, r);
};
}
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";
};
}
@@ -1025,7 +1223,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

@@ -0,0 +1,53 @@
part of 'comic_source.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
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 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 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 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);
/// [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 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);

View File

@@ -1,4 +1,6 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart';
@@ -6,6 +8,7 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/image_provider/local_favorite_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/utils/tags_translation.dart';
import 'dart:io';
@@ -154,6 +157,50 @@ class FavoriteItemWithFolderInfo extends FavoriteItem {
);
}
class FavoriteItemWithUpdateInfo extends FavoriteItem {
String? updateTime;
DateTime? lastCheckTime;
bool hasNewUpdate;
FavoriteItemWithUpdateInfo(
FavoriteItem item,
this.updateTime,
this.hasNewUpdate,
int? lastCheckTime,
) : lastCheckTime = lastCheckTime == null
? null
: DateTime.fromMillisecondsSinceEpoch(lastCheckTime),
super(
id: item.id,
name: item.name,
coverPath: item.coverPath,
author: item.author,
type: item.type,
tags: item.tags,
);
@override
String get description {
var updateTime = this.updateTime ?? "Unknown";
var sourceName = type.comicSource?.name ?? "Unknown";
return "$updateTime | $sourceName";
}
@override
operator ==(Object other) {
return other is FavoriteItemWithUpdateInfo &&
other.updateTime == updateTime &&
other.hasNewUpdate == hasNewUpdate &&
super == other;
}
@override
int get hashCode =>
super.hashCode ^ updateTime.hashCode ^ hasNewUpdate.hashCode;
}
class LocalFavoritesManager with ChangeNotifier {
factory LocalFavoritesManager() =>
cache ?? (cache = LocalFavoritesManager._create());
@@ -164,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
late Database _db;
late Map<String, int> counts;
int get totalComics {
int total = 0;
for (var t in counts.values) {
total += t;
}
return total;
}
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 (
@@ -179,7 +241,8 @@ class LocalFavoritesManager with ChangeNotifier {
source_folder text
);
""");
for (var folder in _getFolderNamesWithDB()) {
var folderNames = _getFolderNamesWithDB();
for (var folder in folderNames) {
var columns = _db.select("""
pragma table_info("$folder");
""");
@@ -188,7 +251,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("""
@@ -201,6 +264,22 @@ class LocalFavoritesManager with ChangeNotifier {
break;
}
}
await appdata.ensureInit();
// Make sure the follow updates folder is ready
var followUpdateFolder = appdata.settings['followUpdatesFolder'];
if (followUpdateFolder is String &&
folderNames.contains(followUpdateFolder)) {
prepareTableForFollowUpdates(followUpdateFolder, false);
} else {
appdata.settings['followUpdatesFolder'] = null;
}
initCounts();
}
void initCounts() {
for (var folder in folderNames) {
counts[folder] = count(folder);
}
}
List<String> find(String id, ComicType type) {
@@ -294,7 +373,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;
@@ -302,6 +381,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"
@@ -367,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
);
""");
notifyListeners();
counts[name] = 0;
return name;
}
@@ -429,7 +557,8 @@ class LocalFavoritesManager with ChangeNotifier {
/// add comic to a folder.
/// return true if success, false if already exists
bool addComic(String folder, FavoriteItem comic, [int? order]) {
bool addComic(String folder, FavoriteItem comic,
[int? order, String? updateTime]) {
_modifiedAfterLastCache = true;
if (!existsFolder(folder)) {
throw Exception("Folder does not exists");
@@ -468,6 +597,23 @@ class LocalFavoritesManager with ChangeNotifier {
values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, minValue(folder) - 1]);
}
if (updateTime != null) {
var columns = _db.select("""
pragma table_info("$folder");
""");
if (columns.any((element) => element["name"] == "last_update_time")) {
_db.execute("""
update "$folder"
set last_update_time = ?
where id == ? and type == ?;
""", [updateTime, comic.id, comic.type.value]);
}
}
if (counts[folder] == null) {
counts[folder] = count(folder);
} else {
counts[folder] = counts[folder]! + 1;
}
notifyListeners();
return true;
}
@@ -507,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners();
}
void batchMoveFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
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
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
if (counts[sourceFolder] != null) {
counts[sourceFolder] = counts[sourceFolder]! - items.length;
} else {
counts[sourceFolder] = count(sourceFolder);
}
notifyListeners();
}
void batchCopyFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
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
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
notifyListeners();
}
/// delete a folder
void deleteFolder(String name) {
_modifiedAfterLastCache = true;
@@ -517,14 +759,10 @@ class LocalFavoritesManager with ChangeNotifier {
delete from folder_order
where folder_name == ?;
""", [name]);
counts.remove(name);
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);
@@ -532,6 +770,60 @@ class LocalFavoritesManager with ChangeNotifier {
delete from "$folder"
where id == ? and type == ?;
""", [id, type.value]);
if (counts[folder] != null) {
counts[folder] = counts[folder]! - 1;
} else {
counts[folder] = count(folder);
}
notifyListeners();
}
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
_modifiedAfterLastCache = true;
_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");
notifyListeners();
}
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
_modifiedAfterLastCache = true;
_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");
notifyListeners();
}
@@ -562,11 +854,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();
}
@@ -591,14 +898,18 @@ class LocalFavoritesManager with ChangeNotifier {
set folder_name = ?
where folder_name == ?;
""", [after, before]);
counts[after] = counts[before] ?? 0;
counts.remove(before);
notifyListeners();
}
void onRead(String id, ComicType type) async {
if (appdata.settings['moveFavoriteAfterRead'] == "none") {
markAsRead(id, type);
return;
}
_modifiedAfterLastCache = true;
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
for (final folder in folderNames) {
var rows = _db.select("""
select * from "$folder"
@@ -627,9 +938,13 @@ class LocalFavoritesManager with ChangeNotifier {
UPDATE "$folder"
SET
$updateLocationSql
${followUpdatesFolder == folder ? "has_new_update = 0," : ""}
time = ?
WHERE id == ?;
""", [newTime, id]);
WHERE id == ? and type == ?;
""", [newTime, id, type.value]);
if (followUpdatesFolder == folder) {
updateFollowUpdatesUI();
}
}
}
notifyListeners();
@@ -662,10 +977,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("""
@@ -673,15 +988,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)) {
@@ -692,12 +1010,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) {
@@ -733,7 +1053,7 @@ class LocalFavoritesManager with ChangeNotifier {
}
}
void updateInfo(String folder, FavoriteItem comic) {
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
_db.execute("""
update "$folder"
set name = ?, author = ?, cover_path = ?, tags = ?
@@ -746,7 +1066,9 @@ class LocalFavoritesManager with ChangeNotifier {
comic.id,
comic.type.value
]);
notifyListeners();
if (notify) {
notifyListeners();
}
}
String folderToJson(String folder) {
@@ -783,7 +1105,136 @@ class LocalFavoritesManager with ChangeNotifier {
}
}
void prepareTableForFollowUpdates(String table, [bool clearData = true]) {
// check if the table has the column "last_update_time" "has_new_update" "last_check_time"
var columns = _db.select("""
pragma table_info("$table");
""");
if (!columns.any((element) => element["name"] == "last_update_time")) {
_db.execute("""
alter table "$table"
add column last_update_time TEXT;
""");
}
if (!columns.any((element) => element["name"] == "has_new_update")) {
_db.execute("""
alter table "$table"
add column has_new_update int;
""");
}
if (clearData) {
_db.execute("""
update "$table"
set has_new_update = 0;
""");
}
if (!columns.any((element) => element["name"] == "last_check_time")) {
_db.execute("""
alter table "$table"
add column last_check_time int;
""");
}
}
void updateUpdateTime(
String folder,
String id,
ComicType type,
String updateTime,
) {
var oldTime = _db.select("""
select last_update_time from "$folder"
where id == ? and type == ?;
""", [id, type.value]).first['last_update_time'];
var hasNewUpdate = oldTime != updateTime;
_db.execute("""
update "$folder"
set last_update_time = ?, has_new_update = ?, last_check_time = ?
where id == ? and type == ?;
""", [
updateTime,
hasNewUpdate ? 1 : 0,
DateTime.now().millisecondsSinceEpoch,
id,
type.value,
]);
}
void updateCheckTime(
String folder,
String id,
ComicType type,
) {
_db.execute("""
update "$folder"
set last_check_time = ?
where id == ? and type == ?;
""", [DateTime.now().millisecondsSinceEpoch, id, type.value]);
}
int countUpdates(String folder) {
return _db.select("""
select count(*) as c from "$folder"
where has_new_update == 1;
""").first['c'];
}
List<FavoriteItemWithUpdateInfo> getUpdates(String folder) {
if (!existsFolder(folder)) {
return [];
}
var res = _db.select("""
select * from "$folder"
where has_new_update == 1;
""");
return res
.map(
(e) => FavoriteItemWithUpdateInfo(
FavoriteItem.fromRow(e),
e['last_update_time'],
e['has_new_update'] == 1,
e['last_check_time'],
),
)
.toList();
}
List<FavoriteItemWithUpdateInfo> getComicsWithUpdatesInfo(String folder) {
if (!existsFolder(folder)) {
return [];
}
var res = _db.select("""
select * from "$folder";
""");
return res
.map(
(e) => FavoriteItemWithUpdateInfo(
FavoriteItem.fromRow(e),
e['last_update_time'],
e['has_new_update'] == 1,
e['last_check_time'],
),
)
.toList();
}
void markAsRead(String id, ComicType type) {
var folder = appdata.settings['followUpdatesFolder'];
if (!existsFolder(folder)) {
return;
}
_db.execute("""
update "$folder"
set has_new_update = 0
where id == ? and type == ?;
""", [id, type.value]);
}
void close() {
_db.dispose();
}
void notifyChanges() {
notifyListeners();
}
}

View File

@@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:convert';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.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);
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 futures = <Future>[];
for (var comic in comicsToUpdate) {
var future = updateComic(comic, folder).then((result) {
current++;
if (result.updated) {
updated++;
}
if (result.errorMessage != null) {
errors++;
}
stream.add(
UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
});
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;
}
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

@@ -0,0 +1,66 @@
import 'package:flutter/widgets.dart';
abstract class GlobalState {
static final _state = <Pair<Object?, State>>[];
static void register(State state, [Object? key]) {
_state.add(Pair(key, state));
}
static T find<T extends State>([Object? key]) {
for (var pair in _state) {
if ((key == null || pair.left == key) && pair.right is T) {
return pair.right as T;
}
}
throw Exception('State not found');
}
static T? findOrNull<T extends State>([Object? key]) {
for (var pair in _state) {
if ((key == null || pair.left == key) && pair.right is T) {
return pair.right as T;
}
}
return null;
}
static void unregister(State state, [Object? key]) {
_state.removeWhere(
(pair) => (key == null || pair.left == key) && pair.right == state);
}
}
class Pair<K, V> {
K left;
V right;
Pair(this.left, this.right);
}
abstract class AutomaticGlobalState<T extends StatefulWidget>
extends State<T> {
@override
@mustCallSuper
void initState() {
super.initState();
GlobalState.register(this, key);
}
@override
@mustCallSuper
void dispose() {
super.dispose();
GlobalState.unregister(this, key);
}
Object? get key;
void update() {
setState(() {});
}
void refresh() {
update();
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:ffi' as ffi;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -9,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';
@@ -49,17 +51,24 @@ class History implements Comic {
@override
String cover;
/// index of chapters. 1-based.
int ep;
/// index of pages. 1-based.
int page;
/// index of chapter groups. 1-based.
/// If [group] is not null, [ep] is the index of chapter in the group.
int? group;
@override
String id;
/// readEpisode is a set of episode numbers that have been read.
///
/// The number of episodes is 1-based.
Set<int> readEpisode;
/// For normal chapters, it is a set of chapter numbers.
/// For grouped chapters, it is a set of strings in the format of "group_number-chapter_number".
/// 1-based.
Set<String> readEpisode;
@override
int? maxPage;
@@ -68,29 +77,17 @@ class History implements Comic {
{required HistoryMixin model,
required this.ep,
required this.page,
Set<int>? readChapters,
this.group,
Set<String>? readChapters,
DateTime? time})
: type = model.historyType,
title = model.title,
subtitle = model.subTitle ?? '',
cover = model.cover,
id = model.id,
readEpisode = readChapters ?? <int>{},
readEpisode = readChapters ?? <String>{},
time = time ?? DateTime.now();
Map<String, dynamic> toMap() => {
"type": type.value,
"time": time.millisecondsSinceEpoch,
"title": title,
"subtitle": subtitle,
"cover": cover,
"ep": ep,
"page": page,
"id": id,
"readEpisode": readEpisode.toList(),
"max_page": maxPage
};
History.fromMap(Map<String, dynamic> map)
: type = HistoryType(map["type"]),
time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
@@ -100,8 +97,9 @@ class History implements Comic {
ep = map["ep"],
page = map["page"],
id = map["id"],
readEpisode = Set<int>.from(
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}),
readEpisode = Set<String>.from(
(map["readEpisode"] as List<dynamic>?)?.toSet() ??
const <String>{}),
maxPage = map["max_page"];
@override
@@ -118,35 +116,11 @@ class History implements Comic {
ep = row["ep"],
page = row["page"],
id = row["id"],
readEpisode = Set<int>.from((row["readEpisode"] as String)
readEpisode = Set<String>.from((row["readEpisode"] as String)
.split(',')
.where((element) => element != "")
.map((e) => int.parse(e))),
maxPage = row["max_page"];
static Future<History> findOrCreate(
HistoryMixin model, {
int ep = 0,
int page = 0,
}) async {
var history = await HistoryManager().find(model.id, model.historyType);
if (history != null) {
return history;
}
history = History.fromModel(model: model, ep: ep, page: page);
HistoryManager().addHistory(history);
return history;
}
static Future<History> createIfNull(
History? history, HistoryMixin model) async {
if (history != null) {
return history;
}
history = History.fromModel(model: model, ep: 0, page: 0);
HistoryManager().addHistory(history);
return history;
}
.where((element) => element != "")),
maxPage = row["max_page"],
group = row["chapter_group"];
@override
bool operator ==(Object other) {
@@ -159,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,
@@ -210,7 +189,11 @@ class HistoryManager with ChangeNotifier {
int get length => _db.select("select count(*) from history;").first[0] as int;
Map<String, bool>? _cachedHistory;
/// Cache of history ids. Improve the performance of find operation.
Map<String, bool>? _cachedHistoryIds;
/// Cache records recently modified by the app. Improve the performance of listeners.
final cachedHistories = <String, History>{};
bool isInitialized = false;
@@ -231,23 +214,73 @@ class HistoryManager with ChangeNotifier {
ep int,
page int,
readEpisode text,
max_page int
max_page int,
chapter_group int
);
""");
var columns = _db.select("PRAGMA table_info(history);");
if (!columns.any((element) => element["name"] == "chapter_group")) {
_db.execute("alter table history add column chapter_group int;");
}
notifyListeners();
ImageFavoriteManager().init();
isInitialized = true;
}
static const _insertHistorySql = """
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page, chapter_group)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""";
static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
return Isolate.run(() {
var db = sqlite3.fromPointer(ffi.Pointer.fromAddress(dbAddr));
db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
newItem.cover,
newItem.time.millisecondsSinceEpoch,
newItem.type.value,
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage,
newItem.group
]);
});
}
bool _haveAsyncTask = false;
/// Create a isolate to add history to prevent blocking the UI thread.
Future<void> addHistoryAsync(History newItem) async {
while (_haveAsyncTask) {
await Future.delayed(Duration(milliseconds: 20));
}
_haveAsyncTask = true;
await _addHistoryAsync(_db.handle.address, newItem);
_haveAsyncTask = false;
if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners();
}
/// add history. if exists, update time.
///
/// This function would be called when user start reading.
Future<void> addHistory(History newItem) async {
_db.execute("""
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [
void addHistory(History newItem) {
_db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
@@ -257,9 +290,18 @@ class HistoryManager with ChangeNotifier {
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage
newItem.maxPage,
newItem.group
]);
updateCache();
if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners();
}
@@ -269,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
@@ -278,27 +345,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners();
}
Future<History?> find(String id, ComicType type) async {
return findSync(id, type);
}
void updateCache() {
_cachedHistory = {};
_cachedHistoryIds = {};
var res = _db.select("""
select * from history;
select id from history;
""");
for (var element in res) {
_cachedHistory![element["id"] as String] = true;
_cachedHistoryIds![element["id"] as String] = true;
}
for (var key in cachedHistories.keys.toList()) {
if (!_cachedHistoryIds!.containsKey(key)) {
cachedHistories.remove(key);
}
}
}
History? findSync(String id, ComicType type) {
if (_cachedHistory == null) {
History? find(String id, ComicType type) {
if (_cachedHistoryIds == null) {
updateCache();
}
if (!_cachedHistory!.containsKey(id)) {
if (!_cachedHistoryIds!.containsKey(id)) {
return null;
}
if (cachedHistories.containsKey(id)) {
return cachedHistories[id];
}
var res = _db.select("""
select * from history
@@ -340,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

@@ -396,7 +396,7 @@ class ImageFavoriteManager with ChangeNotifier {
var token = ServicesBinding.rootIsolateToken!;
var count = ImageFavoriteManager().length;
if (count == 0) {
return Future.value(ImageFavoritesComputed([], [], []));
return Future.value(ImageFavoritesComputed([], [], [], 0));
} else if (count > 100) {
return Isolate.run(() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
@@ -436,8 +436,10 @@ class ImageFavoriteManager with ChangeNotifier {
Map<String, int> authorCount = {};
Map<ImageFavoritesComic, int> comicImageCount = {};
Map<ImageFavoritesComic, int> comicMaxPages = {};
int count = 0;
for (var comic in comics) {
count += comic.images.length;
for (var tag in comic.tags) {
String finalTag = tag;
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
@@ -492,6 +494,7 @@ class ImageFavoriteManager with ChangeNotifier {
.map((comic) => TextWithCount(comic.key.title, comic.value))
.take(maxLength)
.toList(),
count,
);
}
@@ -524,11 +527,14 @@ class ImageFavoritesComputed {
/// 基于喜欢的图片数排序
final List<TextWithCount> comics;
final int count;
/// 计算后的图片收藏数据
const ImageFavoritesComputed(
this.tags,
this.authors,
this.comics,
this.count,
);
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;

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

@@ -97,7 +97,7 @@ class ImageFavoritesProvider
if (localComic == null) {
return null;
}
var epIndex = localComic.chapters?.keys.toList().indexOf(eid) ?? -1;
var epIndex = localComic.chapters?.ids.toList().indexOf(eid) ?? -1;
if (epIndex == -1 && localComic.hasChapters) {
return null;
}

View File

@@ -3,6 +3,8 @@ 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;
import 'package:html/dom.dart' as dom;
@@ -22,8 +24,11 @@ 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';
import 'consts.dart';
@@ -40,7 +45,7 @@ class JavaScriptRuntimeException implements Exception {
}
}
class JsEngine with _JSEngineApi, JsUiApi {
class JsEngine with _JSEngineApi, JsUiApi, Init {
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache;
@@ -64,14 +69,24 @@ class JsEngine with _JSEngineApi, JsUiApi {
responseType: ResponseType.plain, validateStatus: (status) => true));
}
Future<void> init() async {
static Uint8List? _jsInitCache;
static void cacheJsInit(Uint8List jsInit) {
_jsInitCache = jsInit;
}
@override
@protected
Future<void> doInit() async {
if (!_closed) {
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();
@@ -80,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi {
(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');
}
@@ -91,6 +112,7 @@ class JsEngine with _JSEngineApi, JsUiApi {
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":
@@ -156,9 +178,30 @@ class JsEngine with _JSEngineApi, JsUiApi {
case "UI":
return handleUIMessage(Map.from(message));
case "getLocale":
return "${App.locale.languageCode}-${App.locale.countryCode}";
return "${App.locale.languageCode}_${App.locale.countryCode}";
case "getPlatform":
return Platform.operatingSystem;
case "setClipboard":
return Clipboard.setData(ClipboardData(text: message["text"]));
case "getClipboard":
return Future.sync(() async {
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;
@@ -183,7 +226,7 @@ class JsEngine with _JSEngineApi, JsUiApi {
responseType: ResponseType.plain,
validateStatus: (status) => true,
));
var proxy = await AppDio.getProxy();
var proxy = await getProxy();
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
@@ -360,6 +403,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":

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';
@@ -9,7 +11,6 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'app.dart';
@@ -34,7 +35,7 @@ class LocalComic with HistoryMixin implements Comic {
/// key: chapter id, value: chapter title
///
/// chapter id is the name of the directory in `LocalManager.path/$directory`
final Map<String, String>? chapters;
final ComicChapters? chapters;
bool get hasChapters => chapters != null;
@@ -67,7 +68,7 @@ class LocalComic with HistoryMixin implements Comic {
subtitle = row[2] as String,
tags = List.from(jsonDecode(row[3] as String)),
directory = row[4] as String,
chapters = MapOrNull.from(jsonDecode(row[5] as String)),
chapters = ComicChapters.fromJsonOrNull(jsonDecode(row[5] as String)),
cover = row[6] as String,
comicType = ComicType(row[7] as int),
downloadedChapters = List.from(jsonDecode(row[8] as String)),
@@ -99,22 +100,51 @@ class LocalComic with HistoryMixin implements Comic {
"tags": tags,
"description": description,
"sourceKey": sourceKey,
"chapters": chapters?.toJson(),
};
}
@override
int? get maxPage => null;
void read() async {
var history = await HistoryManager().find(id, comicType);
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 ?? firstDownloadedChapterGroup,
history: history ??
History.fromModel(
model: this,
@@ -264,6 +294,7 @@ class LocalManager with ChangeNotifier {
}
_checkPathValidation();
_checkNoMedia();
await ComicSourceManager().ensureInit();
restoreDownloadingTasks();
}
@@ -391,7 +422,7 @@ class LocalManager with ChangeNotifier {
var directory = Directory(comic.baseDir);
if (comic.hasChapters) {
var cid =
ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
directory = Directory(FilePath.join(directory.path, cid));
}
var files = <File>[];
@@ -420,12 +451,30 @@ class LocalManager with ChangeNotifier {
return files.map((e) => "file://${e.path}").toList();
}
bool isDownloaded(String id, ComicType type, [int? ep]) {
bool isDownloaded(String id, ComicType type,
[int? ep, ComicChapters? chapters]) {
var comic = find(id, type);
if (comic == null) return false;
if (comic.chapters == null || ep == null) return true;
if (chapters != null) {
if (comic.chapters?.length != chapters.length) {
// update
add(LocalComic(
id: comic.id,
title: comic.title,
subtitle: comic.subtitle,
tags: comic.tags,
directory: comic.directory,
chapters: chapters,
cover: comic.cover,
comicType: comic.comicType,
downloadedChapters: comic.downloadedChapters,
createdAt: comic.createdAt,
));
}
}
return comic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(ep - 1));
.contains((chapters ?? comic.chapters)!.ids.elementAtOrNull(ep - 1));
}
List<DownloadTask> downloadingTasks = [];
@@ -441,6 +490,10 @@ class LocalManager with ChangeNotifier {
if (comic != null) {
return Directory(FilePath.join(path, comic.directory));
}
const comicDirectoryMaxLength = 80;
if (name.length > comicDirectoryMaxLength) {
name = name.substring(0, comicDirectoryMaxLength);
}
var dir = findValidDirectoryName(path, name);
return Directory(FilePath.join(path, dir)).create().then((value) => value);
}
@@ -509,9 +562,9 @@ class LocalManager with ChangeNotifier {
var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true);
}
// Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
// Deleting a local comic means that it's no longer available, thus both favorite and history should be deleted.
if (c.comicType == ComicType.local) {
if (HistoryManager().findSync(c.id, c.comicType) != null) {
if (HistoryManager().find(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType);
}
var folders = LocalFavoritesManager().find(c.id, c.comicType);
@@ -522,6 +575,99 @@ 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, 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;
}
}
});
}
}
enum LocalSortType {

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
class LogItem {
final LogLevel level;
@@ -28,8 +28,7 @@ class Log {
static bool ignoreLimitation = false;
/// only for debug
static const String? logFile = null;
static bool isMuted = false;
static void printWarning(String text) {
debugPrint('\x1B[33m$text\x1B[0m');
@@ -39,7 +38,21 @@ class Log {
debugPrint('\x1B[31m$text\x1B[0m');
}
static IOSink? _file;
static void addLog(LogLevel level, String title, String content) {
if (isMuted) return;
if (_file == null && App.isInitialized) {
Directory dir;
if (App.isAndroid) {
dir = Directory(App.externalStoragePath!);
} else {
dir = Directory(App.dataPath);
}
var file = dir.joinFile("logs.txt");
_file = file.openWrite();
}
if (!ignoreLimitation && content.length > maxLogLength) {
content = "${content.substring(0, maxLogLength)}...";
}
@@ -62,8 +75,8 @@ class Log {
}
_logs.add(newLog);
if(logFile != null) {
File(logFile!).writeAsString(newLog.toString(), mode: FileMode.append);
if(_file != null) {
_file!.write(newLog.toString());
}
if (_logs.length > maxLogNumber) {
var res = _logs.remove(

View File

@@ -1,238 +0,0 @@
import 'package:flutter/material.dart';
class SimpleController extends StateController {
final void Function()? refreshFunction;
final Map<String, dynamic> Function()? control;
SimpleController({this.refreshFunction, this.control});
@override
void refresh() {
(refreshFunction ?? super.refresh)();
}
Map<String, dynamic> get controlMap => control?.call() ?? {};
}
abstract class StateController {
static final _controllers = <StateControllerWrapped>[];
static T put<T extends StateController>(T controller,
{Object? tag, bool autoRemove = false}) {
_controllers.add(StateControllerWrapped(controller, autoRemove, tag));
return controller;
}
static T putIfNotExists<T extends StateController>(T controller,
{Object? tag, bool autoRemove = false}) {
return findOrNull<T>(tag: tag) ??
put(controller, tag: tag, autoRemove: autoRemove);
}
static T find<T extends StateController>({Object? tag}) {
try {
return _controllers
.lastWhere((element) =>
element.controller is T && (tag == null || tag == element.tag))
.controller as T;
} catch (e) {
throw StateError("$T with tag $tag Not Found");
}
}
static List<T> findAll<T extends StateController>({Object? tag}) {
return _controllers
.where((element) =>
element.controller is T && (tag == null || tag == element.tag))
.map((e) => e.controller as T)
.toList();
}
static T? findOrNull<T extends StateController>({Object? tag}) {
try {
return _controllers
.lastWhere((element) =>
element.controller is T && (tag == null || tag == element.tag))
.controller as T;
} catch (e) {
return null;
}
}
static void remove<T>([Object? tag, bool check = false]) {
for (int i = _controllers.length - 1; i >= 0; i--) {
var element = _controllers[i];
if (element.controller is T && (tag == null || tag == element.tag)) {
if (check && !element.autoRemove) {
continue;
}
_controllers.removeAt(i);
return;
}
}
}
static SimpleController putSimpleController(
void Function() onUpdate, Object? tag,
{void Function()? refresh, Map<String, dynamic> Function()? control}) {
var controller = SimpleController(refreshFunction: refresh, control: control);
controller.stateUpdaters.add(Pair(null, onUpdate));
_controllers.add(StateControllerWrapped(controller, false, tag));
return controller;
}
List<Pair<Object?, void Function()>> stateUpdaters = [];
void update([List<Object>? ids]) {
if (ids == null) {
for (var element in stateUpdaters) {
element.right();
}
} else {
for (var element in stateUpdaters) {
if (ids.contains(element.left)) {
element.right();
}
}
}
}
void dispose() {
_controllers.removeWhere((element) => element.controller == this);
}
void refresh() {
update();
}
}
class StateControllerWrapped {
StateController controller;
bool autoRemove;
Object? tag;
StateControllerWrapped(this.controller, this.autoRemove, this.tag);
}
class StateBuilder<T extends StateController> extends StatefulWidget {
const StateBuilder({
super.key,
this.init,
this.dispose,
this.initState,
this.tag,
required this.builder,
this.id,
});
final T? init;
final void Function(T controller)? dispose;
final void Function(T controller)? initState;
final Object? tag;
final Widget Function(T controller) builder;
Widget builderWrapped(StateController controller) {
return builder(controller as T);
}
void initStateWrapped(StateController controller) {
return initState?.call(controller as T);
}
void disposeWrapped(StateController controller) {
return dispose?.call(controller as T);
}
final Object? id;
@override
State<StateBuilder> createState() => _StateBuilderState<T>();
}
class _StateBuilderState<T extends StateController>
extends State<StateBuilder> {
late T controller;
@override
void initState() {
if (widget.init != null) {
StateController.put(widget.init!, tag: widget.tag, autoRemove: true);
}
try {
controller = StateController.find<T>(tag: widget.tag);
} catch (e) {
throw "Controller Not Found";
}
controller.stateUpdaters.add(Pair(widget.id, () {
if (mounted) {
setState(() {});
}
}));
widget.initStateWrapped(controller);
super.initState();
}
@override
void dispose() {
widget.disposeWrapped(controller);
StateController.remove<T>(widget.tag, true);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.builderWrapped(controller);
}
abstract class StateWithController<T extends StatefulWidget> extends State<T> {
late final SimpleController _controller;
void refresh() {
_controller.update();
}
@override
@mustCallSuper
void initState() {
_controller = StateController.putSimpleController(
() {
if (mounted) {
setState(() {});
}
},
tag,
refresh: refresh,
control: () => control,
);
super.initState();
}
@override
@mustCallSuper
void dispose() {
_controller.dispose();
super.dispose();
}
void update() {
_controller.update();
}
Object? get tag;
Map<String, dynamic> get control => {};
}
class Pair<M, V>{
M left;
V right;
Pair(this.left, this.right);
Pair.fromMap(Map<M, V> map, M key): left = key, right = map[key]
?? (throw Exception("Pair not found"));
}

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,16 +1,22 @@
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';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/comic_source_page.dart';
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';
@@ -29,24 +35,83 @@ extension _FutureInit<T> on Future<T> {
}
Future<void> init() async {
await Rhttp.init();
await SAFTaskWorker().init().wait();
await AppTranslation.init().wait();
await appdata.init().wait();
await App.init().wait();
await HistoryManager().init().wait();
await TagsTranslation.readData().wait();
await LocalFavoritesManager().init().wait();
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
await JsEngine().init().wait();
await ComicSource.init().wait();
await LocalManager().init().wait();
await SingleInstanceCookieJar.createInstance();
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}");
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() {
if (appdata.settings['searchSources'] == null) {
appdata.settings['searchSources'] = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
}
if (appdata.implicitData['webdavAutoSync'] == null) {
var webdavConfig = appdata.settings['webdav'];
if (webdavConfig is List &&
webdavConfig.length == 3 &&
webdavConfig.whereType<String>().length == 3) {
appdata.implicitData['webdavAutoSync'] = true;
} else {
appdata.implicitData['webdavAutoSync'] = false;
}
appdata.writeImplicitData();
}
}
Future<void> _checkAppUpdates() async {
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) {
return;
}
appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await checkUpdateUi(false, true);
}
}
void checkUpdates() {
_checkAppUpdates();
FollowUpdatesService.initChecker();
}

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 {
@@ -34,13 +39,16 @@ void main(List<String> args) {
await windowManager.setBackgroundColor(Colors.transparent);
}
await windowManager.setMinimumSize(const Size(500, 600));
if (!App.isLinux) {
// https://github.com/leanflutter/window_manager/issues/460
var placement = await WindowPlacement.loadFromFile();
var placement = await WindowPlacement.loadFromFile();
if (App.isLinux) {
await windowManager.show();
await placement.applyToWindow();
} else {
await placement.applyToWindow();
await windowManager.show();
WindowPlacement.loop();
}
WindowPlacement.loop();
});
}
}, (error, stack) {
@@ -62,6 +70,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
App.registerForceRebuild(forceRebuild);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addObserver(this);
checkUpdates();
super.initState();
}
@@ -132,6 +141,40 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
};
}
ThemeData getTheme(
Color primary,
Color? secondary,
Color? tertiary,
Brightness brightness,
) {
String? font;
List<String>? fallback;
if (App.isLinux || App.isWindows) {
font = 'Noto Sans CJK';
fallback = [
'Segoe UI',
'Noto Sans SC',
'Noto Sans TC',
'Noto Sans',
'Microsoft YaHei',
'PingFang SC',
'Arial',
'sans-serif'
];
}
return ThemeData(
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
brightness: brightness,
tones: FlexTones.vividBackground(brightness),
),
fontFamily: font,
fontFamilyFallback: fallback,
);
}
@override
Widget build(BuildContext context) {
Widget home;
@@ -158,29 +201,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
return MaterialApp(
home: home,
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
tones: FlexTones.vividBackground(Brightness.light),
),
),
theme: getTheme(primary, secondary, tertiary, Brightness.light),
navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData(
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
brightness: Brightness.dark,
tones: FlexTones.vividBackground(Brightness.dark),
),
),
darkTheme: getTheme(primary, secondary, tertiary, Brightness.dark),
themeMode: switch (appdata.settings['theme_mode']) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system
},
color: Colors.transparent,
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
@@ -213,6 +242,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 > 50;
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(
@@ -228,6 +278,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
);
}
return _SystemUiProvider(Material(
color: App.isLinux ? Colors.transparent : null,
child: widget,
));
}

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,11 @@ 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}");
Log.info(
"Network",
"${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n"
"data:\n${options.data}");
options.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15);
@@ -107,62 +109,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 +139,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 +158,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 +196,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,21 +205,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: switch (options.method) {
'GET' => rhttp.HttpMethod.get,
'POST' => rhttp.HttpMethod.post,
'PUT' => rhttp.HttpMethod.put,
'PATCH' => rhttp.HttpMethod.patch,
'DELETE' => rhttp.HttpMethod.delete,
'HEAD' => rhttp.HttpMethod.head,
'OPTIONS' => rhttp.HttpMethod.options,
'TRACE' => rhttp.HttpMethod.trace,
'CONNECT' => rhttp.HttpMethod.connect,
_ => throw ArgumentError('Unsupported method: ${options.method}'),
},
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(
@@ -293,9 +236,29 @@ class RHttpAdapter implements HttpClientAdapter {
return ResponseBody(
res.body,
res.statusCode,
statusMessage: null,
statusMessage: _getStatusMessage(res.statusCode),
isRedirect: false,
headers: headers,
);
}
static String _getStatusMessage(int statusCode) {
return switch (statusCode) {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
206 => "Partial Content",
301 => "Moved Permanently",
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.",
404 => "Invalid Status Code 404: Not found.",
429 =>
"Invalid Status Code 429: Too many requests. Please try again later.",
_ => "Invalid Status Code $statusCode",
};
}
}

View File

@@ -14,7 +14,7 @@ import 'cookie_jar.dart';
class CloudflareException implements DioException {
final String url;
const CloudflareException(this.url);
CloudflareException(this.url);
@override
String toString() {
@@ -55,6 +55,9 @@ class CloudflareException implements DioException {
@override
DioExceptionType get type => DioExceptionType.badResponse;
@override
DioExceptionReadableStringBuilder? stringBuilder;
}
class CloudflareInterceptor extends Interceptor {
@@ -118,7 +121,7 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
// windows version of package `flutter_inappwebview` cannot get some cookies
// Using DesktopWebview instead
if (App.isLinux || App.isWindows) {
if (App.isLinux) {
var webview = DesktopWebview(
initialUrl: url,
onTitleChange: (title, controller) async {
@@ -152,6 +155,7 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
);
webview.open();
} else {
bool success = false;
void check(InAppWebViewController controller) async {
var head = await controller.evaluateJavascript(
source: "document.head.innerHTML") as String;
@@ -176,7 +180,10 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
return;
}
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
App.rootPop();
if (!success) {
App.rootPop();
success = true;
}
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart';
@@ -200,6 +201,15 @@ class SingleInstanceCookieJar extends CookieJarSql {
SingleInstanceCookieJar._create(super.path);
static SingleInstanceCookieJar? instance;
static Future<SingleInstanceCookieJar> createInstance() async {
if (instance != null) {
return instance!;
}
var dataPath = (await getApplicationSupportDirectory()).path;
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
return instance!;
}
}
class CookieManagerSql extends Interceptor {

View File

@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
@@ -328,8 +330,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_images = {};
_totalCount = 0;
int cpCount = 0;
int totalCpCount = chapters?.length ?? comic!.chapters!.length;
for (var i in comic!.chapters!.keys) {
int totalCpCount =
chapters?.length ?? comic!.chapters!.allChapters.length;
for (var i in comic!.chapters!.allChapters.keys) {
if (chapters != null && !chapters!.contains(i)) {
continue;
}
@@ -422,7 +425,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
"comic": comic?.toJson(),
"chapters": chapters,
"path": path,
"cover": cover,
"cover": _cover,
"images": _images,
"downloadedCount": _downloadedCount,
"totalCount": _totalCount,
@@ -479,7 +482,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
chapters: comic!.chapters,
cover: File(_cover!.split("file://").last).name,
comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [],
downloadedChapters: chapters ?? comic?.chapters?.ids.toList() ?? [],
createdAt: DateTime.now(),
);
}
@@ -549,7 +552,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;
@@ -738,11 +741,12 @@ class ArchiveDownloadTask extends DownloadTask {
path = dir.path;
}
var resultFile = File(FilePath.join(path!, "archive.zip"));
var archiveFile =
File(FilePath.join(App.dataPath, "archive_downloading.zip"));
Log.info("Download", "Downloading $archiveUrl");
_downloader = FileDownloader(archiveUrl, resultFile.path);
_downloader = FileDownloader(archiveUrl, archiveFile.path);
bool isDownloaded = false;
@@ -771,22 +775,33 @@ class ArchiveDownloadTask extends DownloadTask {
}
try {
await extractArchive(path!);
await _extractArchive(archiveFile.path, path!);
} catch (e) {
_setError("Failed to extract archive: $e");
return;
}
await resultFile.deleteIgnoreError();
await archiveFile.deleteIgnoreError();
LocalManager().completeTask(this);
}
static Future<void> extractArchive(String path) async {
var resultFile = FilePath.join(path, "archive.zip");
await Isolate.run(() {
ZipFile.openAndExtract(resultFile, path);
});
static Future<void> _extractArchive(String archive, String outDir) async {
var out = Directory(outDir);
if (out is AndroidDirectory) {
// Saf directory can't be accessed by native code.
var cacheDir = FilePath.join(App.cachePath, "archive_downloading");
Directory(cacheDir).forceCreateSync();
await Isolate.run(() {
ZipFile.openAndExtract(archive, cacheDir);
});
await copyDirectoryIsolate(Directory(cacheDir), Directory(outDir));
await Directory(cacheDir).deleteIgnoreError(recursive: true);
} else {
await Isolate.run(() {
ZipFile.openAndExtract(archive, outDir);
});
}
}
@override

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* {
@@ -70,7 +71,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 +84,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);
@@ -139,21 +174,24 @@ class ImageDownloader {
var buffer = <int>[];
await for (var data in stream) {
buffer.addAll(data);
if (expectedBytes != null) {
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: expectedBytes,
);
}
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: expectedBytes,
);
}
if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
buffer = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
(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(
@@ -191,10 +229,78 @@ 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;
final int totalBytes;
final int? totalBytes;
final Uint8List? imageBytes;

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

@@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import 'package:shimmer_animation/shimmer_animation.dart';
import "package:venera/components/components.dart";
import "package:venera/foundation/app.dart";
import "package:venera/foundation/appdata.dart";
import "package:venera/foundation/comic_source/comic_source.dart";
import "package:venera/pages/search_result_page.dart";
import "package:venera/utils/translations.dart";
@@ -24,7 +25,18 @@ class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
@override
void initState() {
sources = ComicSource.all().where((e) => e.searchPageData != null).toList();
var all = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
var settings = appdata.settings['searchSources'] as List;
var sources = <String>[];
for (var source in settings) {
if (all.contains(source)) {
sources.add(source);
}
}
this.sources = sources.map((e) => ComicSource.find(e)!).toList();
_keyword = widget.keyword;
controller = SearchBarController(
currentText: widget.keyword,
@@ -46,7 +58,11 @@ class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
delegate: SliverChildBuilderDelegate(
(context, index) {
final source = sources[index];
return _SliverSearchResult(source: source, keyword: _keyword);
return _SliverSearchResult(
key: ValueKey(source.key),
source: source,
keyword: _keyword,
);
},
childCount: sources.length,
),
@@ -56,7 +72,11 @@ class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
}
class _SliverSearchResult extends StatefulWidget {
const _SliverSearchResult({required this.source, required this.keyword});
const _SliverSearchResult({
required this.source,
required this.keyword,
super.key,
});
final ComicSource source;
@@ -70,7 +90,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
with AutomaticKeepAliveClientMixin {
bool isLoading = true;
static const _kComicHeight = 132.0;
static const _kComicHeight = 162.0;
get _comicWidth => _kComicHeight * 0.7;
@@ -78,6 +98,8 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
List<Comic>? comics;
String? error;
void load() async {
final data = widget.source.searchPageData!;
var options =
@@ -89,6 +111,11 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
comics = res.data;
isLoading = false;
});
} else {
setState(() {
error = res.errorMessage ?? "Unknown error".tl;
isLoading = false;
});
}
} else if (data.loadNext != null) {
var res = await data.loadNext!(widget.keyword, null, options);
@@ -97,6 +124,11 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
comics = res.data;
isLoading = false;
});
} else {
setState(() {
error = res.errorMessage ?? "Unknown error".tl;
isLoading = false;
});
}
}
}
@@ -120,13 +152,16 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
}
Widget buildComic(Comic c) {
return SimpleComicTile(comic: c)
return SimpleComicTile(comic: c, withTitle: true)
.paddingLeft(_kLeftPadding)
.paddingBottom(2);
}
@override
Widget build(BuildContext context) {
if (error != null && error!.startsWith("CloudflareException")) {
error = "Cloudflare verification required".tl;
}
super.build(context);
return InkWell(
onTap: () {
@@ -169,7 +204,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
}),
),
)
else if (comics == null || comics!.isEmpty)
else if (error != null || comics == null || comics!.isEmpty)
SizedBox(
height: _kComicHeight,
child: Column(
@@ -178,7 +213,13 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Text("No search results found".tl),
Expanded(
child: Text(
error ?? "No search results found".tl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
],
),
const Spacer(),

View File

@@ -4,12 +4,10 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/pages/ranking_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'category_comics_page.dart';
import 'comic_source_page.dart';
class CategoriesPage extends StatefulWidget {
@@ -32,7 +30,7 @@ class _CategoriesPageState extends State<CategoriesPage> {
.toList();
categories =
categories.where((element) => allCategories.contains(element)).toList();
if (!categories.isEqualsTo(this.categories)) {
if (!categories.isEqualTo(this.categories)) {
setState(() {
this.categories = categories;
});
@@ -147,43 +145,6 @@ class _CategoryPage extends StatelessWidget {
return "";
}
void handleClick(
String tag,
String? param,
String type,
String namespace,
String categoryKey,
) {
if (type == 'search') {
App.mainNavigatorKey?.currentContext?.to(
() => SearchResultPage(
text: tag,
options: const [],
sourceKey: findComicSourceKey(),
),
);
} else if (type == "search_with_namespace") {
if (tag.contains(" ")) {
tag = '"$tag"';
}
App.mainNavigatorKey?.currentContext?.to(
() => SearchResultPage(
text: "$namespace:$tag",
options: const [],
sourceKey: findComicSourceKey(),
),
);
} else if (type == "category") {
App.mainNavigatorKey!.currentContext!.to(
() => CategoryComicsPage(
category: tag,
categoryKey: categoryKey,
param: param,
),
);
}
}
@override
Widget build(BuildContext context) {
var children = <Widget>[];
@@ -194,11 +155,11 @@ class _CategoryPage extends StatelessWidget {
child: Wrap(
children: [
if (data.enableRankingPage)
buildTag("Ranking".tl, (p0, p1) {
buildTag("Ranking".tl, () {
context.to(() => RankingPage(categoryKey: data.key));
}),
for (var buttonData in data.buttons)
buildTag(buttonData.label.tl, (p0, p1) => buttonData.onTap())
buildTag(buttonData.label.tl, buttonData.onTap)
],
),
));
@@ -212,36 +173,14 @@ class _CategoryPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildTitleWithRefresh(part.title, () => updater(() {})),
buildTagsWithParams(
part.categories,
part.categoryParams,
part.title,
(key, param) => handleClick(
key,
param,
part.categoryType,
part.title,
category,
),
)
buildTags(part.categories)
],
);
}));
} else {
children.add(buildTitle(part.title));
children.add(
buildTagsWithParams(
part.categories,
part.categoryParams,
part.title,
(tag, param) => handleClick(
tag,
param,
part.categoryType,
part.title,
data.key,
),
),
buildTags(part.categories),
);
}
}
@@ -280,30 +219,28 @@ class _CategoryPage extends StatelessWidget {
);
}
Widget buildTagsWithParams(
List<String> tags,
List<String>? params,
String? namespace,
ClickTagCallback onClick,
Widget buildTags(
List<CategoryItem> categories,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
child: Wrap(
children: List<Widget>.generate(
tags.length,
(index) => buildTag(
tags[index],
onClick,
namespace,
params?.elementAtOrNull(index),
),
categories.length,
(index) => buildCategory(categories[index]),
),
),
);
}
Widget buildTag(String tag, ClickTagCallback onClick,
[String? namespace, String? param]) {
Widget buildCategory(CategoryItem c) {
return buildTag(c.label, () {
var context = App.mainNavigatorKey!.currentContext!;
c.target.jump(context);
});
}
Widget buildTag(String label, VoidCallback onClick) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Builder(
@@ -313,10 +250,10 @@ class _CategoryPage extends StatelessWidget {
color: context.colorScheme.primaryContainer.toOpacity(0.72),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () => onClick(tag, param),
onTap: onClick,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(tag),
child: Text(label),
),
),
);

View File

@@ -9,6 +9,7 @@ class CategoryComicsPage extends StatefulWidget {
required this.category,
this.param,
required this.categoryKey,
this.options,
super.key,
});
@@ -18,29 +19,44 @@ class CategoryComicsPage extends StatefulWidget {
final String categoryKey;
final List<String>? options;
@override
State<CategoryComicsPage> createState() => _CategoryComicsPageState();
}
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()) {
if (source.categoryData?.key == widget.categoryKey) {
if (source.categoryComicsData == null) {
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();
optionsValue = options.map((e) => e.options.keys.first).toList();
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;
}
@@ -48,8 +64,43 @@ 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) {
optionsValue = widget.options!;
} else {
optionsValue = [];
}
findData();
super.initState();
}
@@ -57,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],
@@ -92,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

@@ -0,0 +1,421 @@
part of 'comic_page.dart';
abstract mixin class _ComicPageActions {
void update();
ComicDetails get comic;
ComicSource get comicSource => ComicSource.find(comic.sourceKey)!;
History? get history;
bool isLiking = false;
bool isLiked = false;
void likeOrUnlike() async {
if (isLiking) return;
isLiking = true;
update();
var res = await comicSource.likeOrUnlikeComic!(comic.id, isLiked);
if (res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
} else {
isLiked = !isLiked;
}
isLiking = false;
update();
}
/// whether the comic is added to local favorite
bool isAddToLocalFav = false;
/// whether the comic is favorite on the server
bool isFavorite = false;
FavoriteItem _toFavoriteItem() {
var tags = <String>[];
for (var e in comic.tags.entries) {
tags.addAll(e.value.map((tag) => '${e.key}:$tag'));
}
return FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
);
}
void openFavPanel() {
showSideBar(
App.rootContext,
_FavoritePanel(
cid: comic.id,
type: comic.comicType,
isFavorite: isFavorite,
onFavorite: (local, network) {
isFavorite = network ?? isFavorite;
isAddToLocalFav = local ?? isAddToLocalFav;
update();
},
favoriteItem: _toFavoriteItem(),
updateTime: comic.findUpdateTime(),
),
);
}
void quickFavorite() {
var folder = appdata.settings['quickFavorite'];
if (folder is! String) {
return;
}
LocalFavoritesManager().addComic(
folder,
_toFavoriteItem(),
null,
comic.findUpdateTime(),
);
isAddToLocalFav = true;
update();
App.rootContext.showMessage(message: "Added".tl);
}
void share() {
var text = comic.title;
if (comic.url != null) {
text += '\n${comic.url}';
}
Share.shareText(text);
}
/// read the comic
///
/// [ep] the episode number, start from 1
///
/// [page] the page number, start from 1
///
/// [group] the chapter group number, start from 1
void read([int? ep, int? page, int? group]) {
App.rootContext
.to(
() => Reader(
type: comic.comicType,
cid: comic.id,
name: comic.title,
chapters: comic.chapters,
initialChapter: ep,
initialPage: page,
initialChapterGroup: group,
history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '',
tags: comic.plainTags,
),
)
.then((_) {
onReadEnd();
});
}
void continueRead() {
var ep = history?.ep ?? 1;
var page = history?.page ?? 1;
var group = history?.group ?? 1;
read(ep, page, group);
}
void onReadEnd();
void download() async {
if (LocalManager().isDownloading(comic.id, comic.comicType)) {
App.rootContext.showMessage(message: "The comic is downloading".tl);
return;
}
if (comic.chapters == null &&
LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
App.rootContext.showMessage(message: "The comic is downloaded".tl);
return;
}
if (comicSource.archiveDownloader != null) {
bool useNormalDownload = false;
List<ArchiveInfo>? archives;
int selected = -1;
bool isLoading = false;
bool isGettingLink = false;
await showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
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,
),
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),
)
],
)
],
),
actions: [
Button.filled(
isLoading: isGettingLink,
onPressed: () async {
if (selected == -1) {
useNormalDownload = true;
context.pop();
return;
}
setState(() {
isGettingLink = true;
});
var res =
await comicSource.archiveDownloader!.getDownloadUrl(
comic.id,
archives![selected].id,
);
if (res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
setState(() {
isGettingLink = false;
});
} else if (context.mounted) {
LocalManager()
.addTask(ArchiveDownloadTask(res.data, comic));
App.rootContext
.showMessage(message: "Download started".tl);
context.pop();
}
},
child: Text("Confirm".tl),
),
],
);
},
);
},
);
if (!useNormalDownload) {
return;
}
}
if (comic.chapters == null) {
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: comic.id,
comic: comic,
));
} else {
List<int>? selected;
var downloaded = <int>[];
var localComic = LocalManager().find(comic.id, comic.comicType);
if (localComic != null) {
for (int i = 0; i < comic.chapters!.length; i++) {
if (localComic.downloadedChapters
.contains(comic.chapters!.ids.elementAt(i))) {
downloaded.add(i);
}
}
}
await showSideBar(
App.rootContext,
_SelectDownloadChapter(
comic.chapters!.titles.toList(),
(v) => selected = v,
downloaded,
),
);
if (selected == null) return;
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: comic.id,
comic: comic,
chapters: selected!.map((i) {
return comic.chapters!.ids.elementAt(i);
}).toList(),
));
}
App.rootContext.showMessage(message: "Download started".tl);
update();
}
void onTapTag(String tag, String namespace) {
var target = comicSource.handleClickTagEvent?.call(namespace, tag);
var context = App.mainNavigatorKey!.currentContext!;
target?.jump(context);
}
void showMoreActions() {
var context = App.rootContext;
showMenuX(
context,
Offset(
context.width - 16,
context.padding.top,
),
[
MenuEntry(
icon: Icons.copy,
text: "Copy Title".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.title));
context.showMessage(message: "Copied".tl);
},
),
MenuEntry(
icon: Icons.copy_rounded,
text: "Copy ID".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.id));
context.showMessage(message: "Copied".tl);
},
),
if (comic.url != null)
MenuEntry(
icon: Icons.link,
text: "Copy URL".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.url!));
context.showMessage(message: "Copied".tl);
},
),
if (comic.url != null)
MenuEntry(
icon: Icons.open_in_browser,
text: "Open in Browser".tl,
onClick: () {
launchUrlString(comic.url!);
},
),
]);
}
void showComments() {
showSideBar(
App.rootContext,
CommentsPage(
data: comic,
source: comicSource,
),
);
}
void starRating() {
if (!comicSource.isLogged) {
return;
}
var rating = 0.0;
var isLoading = false;
showDialog(
context: App.rootContext,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => SimpleDialog(
title: const Text("Rating"),
alignment: Alignment.center,
children: [
SizedBox(
height: 100,
child: Center(
child: SizedBox(
width: 210,
child: Column(
children: [
const SizedBox(
height: 10,
),
RatingWidget(
padding: 2,
onRatingUpdate: (value) => rating = value,
value: 1,
selectable: true,
size: 40,
),
const Spacer(),
Button.filled(
isLoading: isLoading,
onPressed: () {
setState(() {
isLoading = true;
});
comicSource.starRatingFunc!(comic.id, rating.round())
.then((value) {
if (value.success) {
App.rootContext
.showMessage(message: "Success".tl);
Navigator.of(dialogContext).pop();
} else {
App.rootContext
.showMessage(message: value.errorMessage!);
setState(() {
isLoading = false;
});
}
});
},
child: Text("Submit".tl),
)
],
),
),
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,361 @@
part of 'comic_page.dart';
class _ComicChapters extends StatelessWidget {
const _ComicChapters({this.history, required this.groupedMode});
final History? history;
final bool groupedMode;
@override
Widget build(BuildContext context) {
return groupedMode
? _GroupedComicChapters(history)
: _NormalComicChapters(history);
}
}
class _NormalComicChapters extends StatefulWidget {
const _NormalComicChapters(this.history);
final History? history;
@override
State<_NormalComicChapters> createState() => _NormalComicChaptersState();
}
class _NormalComicChaptersState extends State<_NormalComicChapters> {
late _ComicPageState state;
late bool reverse;
bool showAll = false;
late History? history;
late ComicChapters chapters;
@override
void initState() {
super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history;
}
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.chapters!;
super.didChangeDependencies();
}
@override
void didUpdateWidget(covariant _NormalComicChapters oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() {
history = widget.history;
});
}
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constrains) {
int length = chapters.length;
bool canShowAll = showAll;
if (!showAll) {
var width = constrains.crossAxisExtent - 16;
var crossItems = width ~/ 200;
if (width % 200 != 0) {
crossItems += 1;
}
length = math.min(length, crossItems * 8);
if (length == chapters.length) {
canShowAll = true;
}
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: length,
(context, i) {
if (reverse) {
i = chapters.length - i - 1;
}
var key = chapters.ids.elementAt(i);
var value = chapters[key]!;
bool visited = (history?.readEpisode ?? {}).contains(i + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Material(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () => state.read(i + 1),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: visited
? context.colorScheme.outline
: null,
),
),
),
),
),
),
);
},
),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 250,
itemHeight: 48,
),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
if (!canShowAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: TextButton.icon(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
showAll = true;
});
},
label: Text("${"Show all".tl} (${chapters.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
},
);
}
}
class _GroupedComicChapters extends StatefulWidget {
const _GroupedComicChapters(this.history);
final History? history;
@override
State<_GroupedComicChapters> createState() => _GroupedComicChaptersState();
}
class _GroupedComicChaptersState extends State<_GroupedComicChapters>
with SingleTickerProviderStateMixin {
late _ComicPageState state;
late bool reverse;
bool showAll = false;
late History? history;
late ComicChapters chapters;
late TabController tabController;
late int index;
@override
void initState() {
super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history;
if (history?.group != null) {
index = history!.group! - 1;
} else {
index = 0;
}
}
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.chapters!;
tabController = TabController(
initialIndex: index,
length: chapters.ids.length,
vsync: this,
);
tabController.addListener(onTabChange);
super.didChangeDependencies();
}
void onTabChange() {
if (index != tabController.index) {
setState(() {
index = tabController.index;
});
}
}
@override
void didUpdateWidget(covariant _GroupedComicChapters oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() {
history = widget.history;
});
}
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constrains) {
var group = chapters.getGroupByIndex(index);
int length = group.length;
bool canShowAll = showAll;
if (!showAll) {
var width = constrains.crossAxisExtent - 16;
var crossItems = width ~/ 200;
if (width % 200 != 0) {
crossItems += 1;
}
length = math.min(length, crossItems * 8);
if (length == group.length) {
canShowAll = true;
}
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverToBoxAdapter(
child: AppTabBar(
withUnderLine: false,
controller: tabController,
tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
),
),
SliverPadding(padding: const EdgeInsets.only(top: 8)),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: length,
(context, i) {
if (reverse) {
i = group.length - i - 1;
}
var key = group.keys.elementAt(i);
var value = group[key]!;
var chapterIndex = 0;
for (var j = 0; j < chapters.groupCount; j++) {
if (j == index) {
chapterIndex += i;
break;
}
chapterIndex += chapters.getGroupByIndex(j).length;
}
String rawIndex = (chapterIndex + 1).toString();
String groupedIndex = "${index + 1}-${i + 1}";
bool visited = false;
if (history != null) {
visited = history!.readEpisode.contains(groupedIndex) ||
history!.readEpisode.contains(rawIndex);
}
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Material(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () => state.read(chapterIndex + 1),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: visited
? context.colorScheme.outline
: null,
),
),
),
),
),
),
);
},
),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 250,
itemHeight: 48,
),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
if (!canShowAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: TextButton.icon(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
showAll = true;
});
},
label: Text("${"Show all".tl} (${group.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
},
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,18 @@
import 'dart:collection';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
part of 'comic_page.dart';
class CommentsPage extends StatefulWidget {
const CommentsPage(
{super.key, required this.data, required this.source, this.replyId});
const CommentsPage({
super.key,
required this.data,
required this.source,
this.replyComment,
});
final ComicDetails data;
final ComicSource source;
final String? replyId;
final Comment? replyComment;
@override
State<CommentsPage> createState() => _CommentsPageState();
@@ -36,13 +29,13 @@ class _CommentsPageState extends State<CommentsPage> {
void firstLoad() async {
var res = await widget.source.commentsLoader!(
widget.data.comicId, widget.data.subId, 1, widget.replyId);
widget.data.comicId, widget.data.subId, 1, widget.replyComment?.id);
if (res.error) {
setState(() {
_error = res.errorMessage;
_loading = false;
});
} else {
} else if (mounted) {
setState(() {
_comments = res.data;
_loading = false;
@@ -53,7 +46,11 @@ class _CommentsPageState extends State<CommentsPage> {
void loadMore() async {
var res = await widget.source.commentsLoader!(
widget.data.comicId, widget.data.subId, _page + 1, widget.replyId);
widget.data.comicId,
widget.data.subId,
_page + 1,
widget.replyComment?.id,
);
if (res.error) {
context.showMessage(message: res.errorMessage ?? "Unknown Error");
} else {
@@ -102,25 +99,67 @@ class _CommentsPageState extends State<CommentsPage> {
return Column(
children: [
Expanded(
child: ListView.builder(
primary: false,
padding: EdgeInsets.zero,
itemCount: _comments!.length + 1,
itemBuilder: (context, index) {
if (index == _comments!.length) {
if (_page < (maxPage ?? _page + 1)) {
loadMore();
return const ListLoadingIndicator();
} else {
return const SizedBox();
}
}
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: [
_CommentTile(
comment: widget.replyComment!,
source: widget.source,
comic: widget.data,
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--;
return _CommentTile(
comment: _comments![index],
source: widget.source,
comic: widget.data,
showAvatar: showAvatar,
if (index == _comments!.length) {
if (_page < (maxPage ?? _page + 1)) {
loadMore();
return const ListLoadingIndicator();
} else {
return const SizedBox();
}
}
return _CommentTile(
comment: _comments![index],
source: widget.source,
comic: widget.data,
showAvatar: showAvatar,
);
},
);
},
),
@@ -141,6 +180,12 @@ class _CommentsPageState extends State<CommentsPage> {
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,
@@ -160,7 +205,7 @@ class _CommentsPageState extends State<CommentsPage> {
),
if (sending)
const Padding(
padding: EdgeInsets.all(8.5),
padding: EdgeInsets.all(8),
child: SizedBox(
width: 24,
height: 24,
@@ -182,7 +227,7 @@ class _CommentsPageState extends State<CommentsPage> {
widget.data.comicId,
widget.data.subId,
controller.text,
widget.replyId);
widget.replyComment?.id);
if (!b.error) {
controller.text = "";
setState(() {
@@ -205,7 +250,7 @@ class _CommentsPageState extends State<CommentsPage> {
),
)
],
).paddingVertical(2).paddingLeft(16).paddingRight(4),
).paddingLeft(16).paddingRight(4),
),
);
}
@@ -217,6 +262,7 @@ class _CommentTile extends StatefulWidget {
required this.source,
required this.comic,
required this.showAvatar,
this.showActions = true,
});
final Comment comment;
@@ -227,6 +273,8 @@ class _CommentTile extends StatefulWidget {
final bool showAvatar;
final bool showActions;
@override
State<_CommentTile> createState() => _CommentTileState();
}
@@ -243,24 +291,17 @@ class _CommentTileState extends State<_CommentTile> {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showAvatar)
Container(
width: 40,
height: 40,
width: 36,
height: 36,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.secondaryContainer),
child: widget.comment.avatar == null
? null
@@ -270,7 +311,7 @@ class _CommentTileState extends State<_CommentTile> {
sourceKey: widget.source.key,
),
),
).paddingRight(12),
).paddingRight(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -288,11 +329,14 @@ class _CommentTileState extends State<_CommentTile> {
),
)
],
).paddingAll(16),
),
);
}
Widget buildActions() {
if (!widget.showActions) {
return const SizedBox();
}
if (widget.comment.score == null && widget.comment.replyCount == null) {
return const SizedBox();
}
@@ -331,7 +375,7 @@ class _CommentTileState extends State<_CommentTile> {
CommentsPage(
data: widget.comic,
source: widget.source,
replyId: widget.comment.id,
replyComment: widget.comment,
),
showBarrier: false,
);
@@ -613,10 +657,16 @@ class _CommentImage {
}
class RichCommentContent extends StatefulWidget {
const RichCommentContent({super.key, required this.text});
const RichCommentContent({
super.key,
required this.text,
this.showImages = true,
});
final String text;
final bool showImages;
@override
State<RichCommentContent> createState() => _RichCommentContentState();
}
@@ -676,7 +726,17 @@ class _RichCommentContentState extends State<RichCommentContent> {
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', '');
}
}
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span', 'strong'];
const acceptedTags = [
'img',
'a',
'b',
'i',
'u',
's',
'br',
'span',
'strong'
];
if (acceptedTags.contains(tagName)) {
writeBuffer();
if (tagName == 'img') {
@@ -760,7 +820,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
children: textSpan,
),
);
if (images.isNotEmpty) {
if (images.isNotEmpty && widget.showImages) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [

View File

@@ -0,0 +1,153 @@
part of 'comic_page.dart';
class _CommentsPart extends StatefulWidget {
const _CommentsPart({
required this.comments,
required this.showMore,
});
final List<Comment> comments;
final void Function() showMore;
@override
State<_CommentsPart> createState() => _CommentsPartState();
}
class _CommentsPartState extends State<_CommentsPart> {
final scrollController = ScrollController();
late List<Comment> comments;
@override
void initState() {
comments = widget.comments;
super.initState();
}
@override
Widget build(BuildContext context) {
return MultiSliver(
children: [
SliverLazyToBoxAdapter(
child: ListTile(
title: Text("Comments".tl),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
scrollController.animateTo(
scrollController.position.pixels - 340,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
scrollController.animateTo(
scrollController.position.pixels + 340,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
),
],
),
),
),
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 184,
child: MediaQuery.removePadding(
removeTop: true,
context: context,
child: ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
itemCount: comments.length,
itemBuilder: (context, index) {
return _CommentWidget(comment: comments[index]);
},
),
),
),
const SizedBox(height: 8),
_ActionButton(
icon: const Icon(Icons.comment),
text: "View more".tl,
onPressed: widget.showMore,
iconColor: context.useTextColor(Colors.green),
).fixHeight(48).paddingRight(8).toAlign(Alignment.centerRight),
const SizedBox(height: 8),
],
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}
class _CommentWidget extends StatelessWidget {
const _CommentWidget({required this.comment});
final Comment comment;
@override
Widget build(BuildContext context) {
return Container(
height: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 0, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
width: 324,
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
children: [
if (comment.avatar != null)
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: context.colorScheme.surfaceContainer,
),
clipBehavior: Clip.antiAlias,
child: Image(
image: CachedImageProvider(comment.avatar!),
width: 36,
height: 36,
fit: BoxFit.cover,
),
).paddingRight(8),
Text(comment.userName, style: ts.bold),
],
),
const SizedBox(height: 4),
Expanded(
child: RichCommentContent(
text: comment.content,
showImages: false,
).fixWidth(324),
),
const SizedBox(height: 4),
if (comment.time != null)
Text(comment.time!, style: ts.s12).toAlign(Alignment.centerLeft),
],
),
);
}
}

View File

@@ -0,0 +1,432 @@
part of 'comic_page.dart';
class _FavoritePanel extends StatefulWidget {
const _FavoritePanel({
required this.cid,
required this.type,
required this.isFavorite,
required this.onFavorite,
required this.favoriteItem,
this.updateTime,
});
final String cid;
final ComicType type;
/// whether the comic is in the network favorite list
///
/// if null, the comic source does not support favorite or support multiple favorite lists
final bool? isFavorite;
final void Function(bool?, bool?) onFavorite;
final FavoriteItem favoriteItem;
final String? updateTime;
@override
State<_FavoritePanel> createState() => _FavoritePanelState();
}
class _FavoritePanelState extends State<_FavoritePanel>
with SingleTickerProviderStateMixin {
late ComicSource comicSource;
late TabController tabController;
late bool hasNetwork;
@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),
),
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({
required this.cid,
required this.comicSource,
required this.isFavorite,
required this.onFavorite,
});
final String cid;
final ComicSource comicSource;
final bool? isFavorite;
final void Function(bool) onFavorite;
@override
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
}
class _NetworkFavoritesState extends State<_NetworkFavorites> {
@override
Widget build(BuildContext context) {
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
}
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;
// for network favorites, only one selection is allowed
String? selected;
void loadFolders() async {
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
if (res.error) {
context.showMessage(message: res.errorMessage!);
} else {
folders = res.data;
if (res.subData is List) {
addedFolders = List<String>.from(res.subData).toSet();
}
setState(() {
isLoadingFolders = false;
});
}
}
Widget buildMultiFolder() {
if (widget.isFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
return Column(
children: [
Expanded(
child: Center(
child: Text("Added to favorites".tl),
),
),
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),
),
],
),
value: selected == id,
onChanged: (v) {
setState(() {
selected = id;
});
},
);
},
),
),
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),
),
],
);
}
}
}

View File

@@ -0,0 +1,169 @@
part of 'comic_page.dart';
class _ComicThumbnails extends StatefulWidget {
const _ComicThumbnails();
@override
State<_ComicThumbnails> createState() => _ComicThumbnailsState();
}
class _ComicThumbnailsState extends State<_ComicThumbnails> {
late _ComicPageState state;
late List<String> thumbnails;
bool isInitialLoading = true;
String? next;
String? error;
bool isLoading = false;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
loadNext();
thumbnails = List.from(state.comic.thumbnails ?? []);
super.didChangeDependencies();
}
void loadNext() async {
if (state.comicSource.loadComicThumbnail == null) return;
if (!isInitialLoading && next == null) {
return;
}
if (isLoading) return;
Future.microtask(() {
setState(() {
isLoading = true;
});
});
var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next);
if (res.success) {
thumbnails.addAll(res.data);
next = res.subData;
isInitialLoading = false;
} else {
error = res.errorMessage;
}
if (mounted) {
setState(() {
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return MultiSliver(
children: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Preview".tl),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: thumbnails.length,
(context, index) {
if (index == thumbnails.length - 1 && error == null) {
loadNext();
}
var url = thumbnails[index];
ImagePart? part;
if (url.contains('@')) {
var params = url.split('@')[1].split('&');
url = url.split('@')[0];
double? x1, y1, x2, y2;
try {
for (var p in params) {
if (p.startsWith('x')) {
var r = p.split('=')[1];
x1 = double.parse(r.split('-')[0]);
x2 = double.parse(r.split('-')[1]);
}
if (p.startsWith('y')) {
var r = p.split('=')[1];
y1 = double.parse(r.split('-')[0]);
y2 = double.parse(r.split('-')[1]);
}
}
} catch (_) {
// ignore
}
part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2);
}
return Padding(
padding: context.width < changePoint
? const EdgeInsets.all(4)
: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InkWell(
onTap: () => state.read(null, index + 1),
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
url,
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
part: part,
),
),
),
),
const SizedBox(
height: 4,
),
Text((index + 1).toString()),
],
),
);
},
),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.68,
),
),
if (error != null)
SliverToBoxAdapter(
child: Column(
children: [
Text(error!),
Button.outlined(
onPressed: loadNext,
child: Text("Retry".tl),
)
],
),
)
else if (isLoading)
const SliverListLoadingIndicator(),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

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;
@@ -40,19 +91,18 @@ class ComicSourcePage extends StatelessWidget {
}
}
if (shouldUpdate.isNotEmpty) {
var updates = <String, String>{};
for (var key in shouldUpdate) {
ComicSource.availableUpdates[key] = versions[key]!;
updates[key] = versions[key]!;
}
ComicSource.notifyListeners();
ComicSourceManager().updateAvailableUpdates(updates);
}
return shouldUpdate.length;
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: const _Body(),
);
return Scaffold(body: const _Body());
}
}
@@ -73,23 +123,20 @@ class _BodyState extends State<_Body> {
@override
void initState() {
super.initState();
ComicSource.addListener(updateUI);
ComicSourceManager().addListener(updateUI);
}
@override
void dispose() {
super.dispose();
ComicSource.removeListener(updateUI);
ComicSourceManager().removeListener(updateUI);
}
@override
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(
@@ -108,14 +155,12 @@ 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);
file.delete();
ComicSource.remove(source.key);
ComicSourceManager().remove(source.key);
_validatePages();
App.forceRebuild();
},
@@ -132,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 ComicSource.reload();
App.forceRebuild();
},
child: const Text("continue")),
onPressed: () async {
await ComicSourceManager().reload();
App.forceRebuild();
},
child: const Text("continue"),
),
],
),
);
@@ -150,51 +197,17 @@ class _BodyState extends State<_Body> {
}
context.to(
() => _EditFilePage(source.filePath, () async {
await ComicSource.reload();
await ComicSourceManager().reload();
setState(() {});
}),
);
}
static Future<void> update(ComicSource source) async {
if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config");
return;
}
ComicSource.remove(source.key);
bool cancel = false;
var 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 (ComicSource.availableUpdates.containsKey(source.key)) {
ComicSource.availableUpdates.remove(source.key);
}
} catch (e) {
if (cancel) return;
App.rootContext.showMessage(message: e.toString());
}
await ComicSource.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,
@@ -208,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),
],
),
@@ -272,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 {
@@ -283,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);
@@ -300,7 +321,7 @@ class _BodyState extends State<_Body> {
Future<void> addSource(String js, String fileName) async {
var comicSource = await ComicSourceParser().createAndParse(js, fileName);
ComicSource.add(comicSource);
ComicSourceManager().add(comicSource);
_addAllPagesWithComicSource(comicSource);
appdata.saveData();
App.forceRebuild();
@@ -317,95 +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,
itemBuilder: (context, 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 currentKey = ComicSource.all().map((e) => e.key).toList();
return ListTile(
title: Text(json![index]["name"]),
subtitle: Text(json![index]["version"]),
trailing: action,
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,
),
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),
),
onChanged: (value) {
changed = true;
},
).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,
);
},
);
}
}
@@ -456,6 +560,7 @@ void _addAllPagesWithComicSource(ComicSource source) {
var explorePages = appdata.settings['explore_pages'];
var categoryPages = appdata.settings['categories'];
var networkFavorites = appdata.settings['favorites'];
var searchPages = appdata.settings['searchSources'];
if (source.explorePages.isNotEmpty) {
for (var page in source.explorePages) {
@@ -472,10 +577,14 @@ void _addAllPagesWithComicSource(ComicSource source) {
!networkFavorites.contains(source.favoriteData!.key)) {
networkFavorites.add(source.favoriteData!.key);
}
if (source.searchPageData != null && !searchPages.contains(source.key)) {
searchPages.add(source.key);
}
appdata.settings['explore_pages'] = explorePages.toSet().toList();
appdata.settings['categories'] = categoryPages.toSet().toList();
appdata.settings['favorites'] = networkFavorites.toSet().toList();
appdata.settings['searchSources'] = searchPages.toSet().toList();
appdata.saveData();
}
@@ -510,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,
@@ -551,20 +655,74 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
} else if (count == 0) {
context.showMessage(message: "No updates".tl);
} else {
context.showMessage(message: "@c updates".tlParams({"c": count}));
showUpdateDialog();
}
setState(() {
isLoading = false;
});
}
void showUpdateDialog() async {
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,
builder: (context) {
return ContentDialog(
title: "Updates".tl,
content: Text(text).paddingHorizontal(16),
actions: [
FilledButton(
onPressed: () {
doUpdate = true;
context.pop();
},
child: Text("Update".tl),
),
],
);
},
);
if (doUpdate) {
var loadingController = showLoadingDialog(
context,
message: "Updating".tl,
withProgress: true,
);
int current = 0;
int total = ComicSourceManager().availableUpdates.length;
try {
var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
for (var key in shouldUpdate) {
var source = ComicSource.find(key)!;
await ComicSourcePage.update(source, false);
current++;
loadingController.setProgress(current / total);
}
} catch (e) {
context.showMessage(message: e.toString());
}
loadingController.close();
}
}
@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);
);
}
}
@@ -642,7 +800,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
@override
Widget build(BuildContext context) {
var newVersion = ComicSource.availableUpdates[source.key];
var newVersion = ComicSourceManager().availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
@@ -653,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(
@@ -689,7 +844,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
style: const TextStyle(fontSize: 13),
),
),
).paddingLeft(4)
).paddingLeft(4),
],
),
trailing: Row(
@@ -734,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())),
],
);
}
@@ -768,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(
@@ -777,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] =
@@ -806,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: () {
@@ -848,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(() {});
@@ -897,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),
);
@@ -910,7 +1060,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
source.data["account"] = null;
source.account?.logout();
source.saveData();
ComicSource.notifyListeners();
ComicSourceManager().notifyStateChange();
setState(() {});
},
trailing: const Icon(Icons.logout),
@@ -940,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),
@@ -1011,7 +1159,7 @@ class _LoginPageState extends State<_LoginPage> {
if (widget.config.loginWebsite != null)
TextButton(
onPressed: () {
if (App.isWindows || App.isLinux) {
if (App.isLinux) {
loginWithWebview2();
} else {
loginWithWebview();
@@ -1070,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';
@@ -1127,7 +1276,7 @@ class _LoginPageState extends State<_LoginPage> {
}
}
// for windows and linux
// for linux
void loginWithWebview2() async {
if (!await DesktopWebview.isAvailable()) {
context.showMessage(message: "Webview is not available".tl);

View File

@@ -15,6 +15,15 @@ class DownloadingPage extends StatefulWidget {
}
class _DownloadingPageState extends State<DownloadingPage> {
DownloadTask? firstTask;
@override
void didChangeDependencies() {
super.didChangeDependencies();
firstTask = LocalManager().downloadingTasks.firstOrNull;
firstTask?.addListener(update);
}
@override
void initState() {
LocalManager().addListener(update);
@@ -24,10 +33,17 @@ class _DownloadingPageState extends State<DownloadingPage> {
@override
void dispose() {
LocalManager().removeListener(update);
firstTask?.removeListener(update);
super.dispose();
}
void update() {
var currentFirstTask = LocalManager().downloadingTasks.firstOrNull;
if (currentFirstTask != firstTask) {
firstTask?.removeListener(update);
firstTask = currentFirstTask;
firstTask?.addListener(update);
}
if(mounted) {
setState(() {});
}

View File

@@ -3,16 +3,13 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'category_comics_page.dart';
class ExplorePage extends StatefulWidget {
const ExplorePage({super.key});
@@ -37,7 +34,7 @@ class _ExplorePageState extends State<ExplorePage>
.expand((e) => e.map((e) => e.title))
.toList();
explorePages = explorePages.where((e) => all.contains(e)).toList();
if (!pages.isEqualsTo(explorePages)) {
if (!pages.isEqualTo(explorePages)) {
setState(() {
pages = explorePages;
controller = TabController(
@@ -52,9 +49,7 @@ class _ExplorePageState extends State<ExplorePage>
if (index == 2) {
int page = controller.index;
String currentPageId = pages[page];
StateController.find<SimpleController>(tag: currentPageId)
.control!()['toTop']
?.call();
GlobalState.find<_SingleExplorePageState>(currentPageId).toTop();
}
}
@@ -98,7 +93,7 @@ class _ExplorePageState extends State<ExplorePage>
void refresh() {
int page = controller.index;
String currentPageId = pages[page];
StateController.find<SimpleController>(tag: currentPageId).refresh();
GlobalState.find<_SingleExplorePageState>(currentPageId).refresh();
}
Widget buildFAB() => Material(
@@ -244,7 +239,7 @@ class _SingleExplorePage extends StatefulWidget {
State<_SingleExplorePage> createState() => _SingleExplorePageState();
}
class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
class _SingleExplorePageState extends AutomaticGlobalState<_SingleExplorePage>
with AutomaticKeepAliveClientMixin<_SingleExplorePage> {
late final ExplorePageData data;
@@ -328,7 +323,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
}
@override
Object? get tag => widget.title;
Object? get key => widget.title;
@override
void refresh() {
@@ -347,9 +342,6 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
);
}
}
@override
Map<String, dynamic> get control => {"toTop": toTop};
}
class _MixedExplorePage extends StatefulWidget {
@@ -450,30 +442,7 @@ Iterable<Widget> _buildExplorePagePart(
TextButton(
onPressed: () {
var context = App.mainNavigatorKey!.currentContext!;
if (part.viewMore!.startsWith("search:")) {
context.to(
() => SearchResultPage(
text: part.viewMore!.replaceFirst("search:", ""),
options: const [],
sourceKey: sourceKey,
),
);
} else if (part.viewMore!.startsWith("category:")) {
var cp = part.viewMore!.replaceFirst("category:", "");
var c = cp.split('@').first;
String? p = cp.split('@').last;
if (p == c) {
p = null;
}
context.to(
() => CategoryComicsPage(
category: c,
categoryKey:
ComicSource.find(sourceKey)!.categoryData!.key,
param: p,
),
);
}
part.viewMore!.jump(context);
},
child: Text("View more".tl),
)

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

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
@@ -14,16 +15,19 @@ import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/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;
@@ -63,6 +67,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});
@@ -31,25 +37,132 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
int? lastSelectedIndex;
bool get isAllFolder => widget.folder == _localAllFolderLabel;
LocalFavoritesManager get manager => LocalFavoritesManager();
bool isLoading = false;
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 (keyword.isEmpty) {
setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder);
});
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 {
setState(() {
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword);
});
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(() {});
}
bool matchKeyword(String keyword, FavoriteItem comic) {
var list = keyword.split(" ");
for (var k in list) {
if (k.isEmpty) continue;
if (comic.title.contains(k)) {
continue;
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) {
continue;
} else if (comic.tags.any((tag) {
if (tag == k) {
return true;
} else if (tag.contains(':') && tag.split(':')[1] == k) {
return true;
} else if (App.locale.languageCode != 'en' &&
tag.translateTagsToCN == k) {
return true;
}
return false;
})) {
continue;
} else if (comic.author == k) {
continue;
}
return false;
}
return true;
}
// 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() {
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 +175,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 +243,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 +270,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(
@@ -191,14 +326,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
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 +358,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return null;
},
);
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.reorder,
text: "Reorder".tl,
onClick: () {
@@ -241,8 +380,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}
},
);
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.upload_file,
text: "Export".tl,
onClick: () {
@@ -253,8 +393,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 +406,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
});
}
});
}),
MenuEntry(
},
),
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Folder".tl,
color: context.colorScheme.error,
@@ -284,9 +426,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
favPage.folderList?.updateFolders();
},
);
}),
],
),
},
),
],
),
],
)
else if (multiSelectMode)
@@ -310,10 +453,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
"Selected @c comics".tlParams({"c": selectedComics.length})),
actions: [
MenuButton(entries: [
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,
@@ -330,27 +475,55 @@ 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,
onClick: downloadSelected,
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.copy,
text: "Copy Title".tl,
onClick: () {
Clipboard.setData(
ClipboardData(
text: selectedComics.keys.first.title,
),
);
context.showMessage(
message: "Copied".tl,
);
},
),
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,
));
},
),
]),
],
)
@@ -365,9 +538,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
searchMode = false;
keyword = "";
updateComics();
setState(() {
searchMode = false;
});
});
},
),
@@ -376,138 +549,147 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
autofocus: true,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
border: UnderlineInputBorder(),
),
onChanged: (v) {
keyword = v;
updateComics();
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 : 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) {
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;
}
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 = Scrollbar(
body = AppScrollBar(
topPadding: 48,
controller: scrollController,
thickness: App.isDesktop ? 8 : 12,
radius: const Radius.circular(8),
interactive: true,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: body,
@@ -625,32 +807,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();
@@ -686,13 +862,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();
}
}
@@ -712,7 +883,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) {
@@ -733,7 +904,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();
}
@@ -768,27 +942,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>(

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

@@ -110,6 +110,15 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
child: Text(widget.data.title),
),
actions: [
Tooltip(
message: "Refresh".tl,
child: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
comicListKey.currentState!.refresh();
},
),
),
MenuButton(entries: [
MenuEntry(
icon: Icons.sync,
@@ -476,55 +485,47 @@ class _CreateFolderDialogState extends State<_CreateFolderDialog> {
@override
Widget build(BuildContext context) {
return SimpleDialog(
title: Text("Create a folder".tl),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "name".tl,
),
),
),
const SizedBox(
width: 200,
height: 10,
),
if (loading)
Center(
child: const CircularProgressIndicator(
strokeWidth: 2,
).fixWidth(24).fixHeight(24),
)
else
SizedBox(
height: 35,
child: Center(
child: TextButton(
onPressed: () {
setState(() {
loading = true;
});
widget.data.addFolder!(controller.text).then((b) {
if (b.error) {
context.showMessage(message: b.errorMessage!);
setState(() {
loading = false;
});
} else {
context.pop();
context.showMessage(message: "Created successfully".tl);
widget.updateState();
}
});
},
child: Text("Submit".tl),
return ContentDialog(
title: "Create a folder".tl,
content: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "name".tl,
),
),
)
),
const SizedBox(
height: 16
),
],
),
actions: [
Button.filled(
isLoading: loading,
onPressed: () {
setState(() {
loading = true;
});
widget.data.addFolder!(controller.text).then((b) {
if (b.error) {
context.showMessage(message: b.errorMessage!);
setState(() {
loading = false;
});
} else {
context.pop();
context.showMessage(message: "Created successfully".tl);
widget.updateState();
}
});
},
child: Text("Submit".tl),
)
],
);
}

View File

@@ -20,22 +20,37 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
var networkFolders = <String>[];
void findNetworkFolders() {
networkFolders.clear();
var all = ComicSource.all()
.where((e) => e.favoriteData != null)
.map((e) => e.favoriteData!.key)
.toList();
var settings = appdata.settings['favorites'] as List;
for (var p in settings) {
if (all.contains(p) && !networkFolders.contains(p)) {
networkFolders.add(p);
}
}
}
@override
void initState() {
favPage = widget.favPage ??
context.findAncestorStateOfType<_FavoritesPageState>()!;
favPage.folderList = this;
folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all()
.where((e) => e.favoriteData != null && e.isLogged)
.map((e) => e.favoriteData!.key)
.toList();
findNetworkFolders();
appdata.settings.addListener(updateFolders);
LocalFavoritesManager().addListener(updateFolders);
super.initState();
}
@override
void dispose() {
super.dispose();
appdata.settings.removeListener(updateFolders);
LocalFavoritesManager().removeListener(updateFolders);
}
@override
@@ -73,56 +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) {
@@ -130,29 +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: [
const SizedBox(width: 16),
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
],
),
);
return buildNetworkTitle();
}
index--;
return buildNetworkFolder(networkFolders[index]);
@@ -164,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) {
@@ -190,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()),
),
],
),
),
);
}
@@ -241,10 +297,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
if (!mounted) return;
setState(() {
folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all()
.where((e) => e.favoriteData != null)
.map((e) => e.favoriteData!.key)
.toList();
findNetworkFolders();
});
}
}

View File

@@ -0,0 +1,596 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.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});
@override
State<FollowUpdatesWidget> createState() => _FollowUpdatesWidgetState();
}
class _FollowUpdatesWidgetState
extends AutomaticGlobalState<FollowUpdatesWidget> {
int _count = 0;
String? get folder => appdata.settings["followUpdatesFolder"];
void getCount() {
if (folder == null) {
_count = 0;
return;
}
if (!LocalFavoritesManager().folderNames.contains(folder)) {
_count = 0;
appdata.settings["followUpdatesFolder"] = null;
Future.microtask(() {
appdata.saveData();
});
} else {
_count = LocalFavoritesManager().countUpdates(folder!);
}
}
void updateCount() {
setState(() {
getCount();
});
}
@override
void initState() {
super.initState();
getCount();
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
context.to(() => FollowUpdatesPage());
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 56,
child: Row(
children: [
Center(
child: Text('Follow Updates'.tl, style: ts.s18),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
),
).paddingHorizontal(16),
if (_count > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
margin: const EdgeInsets.only(bottom: 16, left: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.primaryContainer,
),
child: Text(
'@c updates'.tlParams({
'c': _count,
}),
style: ts.s16,
),
),
],
),
),
),
);
}
@override
Object? get key => 'FollowUpdatesWidget';
}
class FollowUpdatesPage extends StatefulWidget {
const FollowUpdatesPage({super.key});
@override
State<FollowUpdatesPage> createState() => _FollowUpdatesPageState();
}
class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
String? get folder => appdata.settings["followUpdatesFolder"];
var updatedComics = <FavoriteItemWithUpdateInfo>[];
var allComics = <FavoriteItemWithUpdateInfo>[];
/// Sort comics by update time in descending order with nulls at the end.
void sortComics() {
allComics.sort((a, b) {
if (a.updateTime == null && b.updateTime == null) {
return 0;
} else if (a.updateTime == null) {
return -1;
} else if (b.updateTime == null) {
return 1;
}
try {
var aNums = a.updateTime!.split('-').map(int.parse).toList();
var bNums = b.updateTime!.split('-').map(int.parse).toList();
for (int i = 0; i < aNums.length; i++) {
if (aNums[i] != bNums[i]) {
return bNums[i] - aNums[i];
}
}
return 0;
} catch (_) {
return 0;
}
});
}
@override
void initState() {
super.initState();
if (folder != null) {
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder!);
sortComics();
updatedComics = allComics.where((c) => c.hasNewUpdate).toList();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text('Follow Updates'.tl)),
if (folder == null)
buildNotConfigured(context)
else
buildConfigured(context),
SliverPadding(padding: const EdgeInsets.only(top: 8)),
buildUpdatedComics(),
buildAllComics(),
],
),
);
}
Widget buildNotConfigured(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.info_outline),
title: Text("Not Configured".tl),
),
Text(
"Choose a folder to follow updates.".tl,
style: ts.s16,
).paddingHorizontal(16),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: showSelector,
child: Text("Choose Folder".tl),
).paddingHorizontal(16).toAlign(Alignment.centerRight),
const SizedBox(height: 16),
],
),
),
);
}
Widget buildConfigured(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.stars_outlined),
title: Text(folder!),
),
Text(
"Automatic update checking enabled.".tl,
style: ts.s14,
).paddingHorizontal(16),
Text(
"The app will check for updates at most once a day.".tl,
style: ts.s14,
).paddingHorizontal(16),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: showSelector,
child: Text("Change Folder".tl),
),
FilledButton.tonal(
onPressed: checkNow,
child: Text("Check Now".tl),
),
const SizedBox(width: 16),
],
),
const SizedBox(height: 16),
],
),
),
);
}
Widget buildUpdatedComics() {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(Icons.update),
const SizedBox(width: 8),
Text(
"Updates".tl,
style: ts.s18,
),
const Spacer(),
if (updatedComics.isNotEmpty)
IconButton(
icon: Icon(Icons.clear_all),
onPressed: () {
showConfirmDialog(
context: App.rootContext,
title: "Mark all as read".tl,
content: "Do you want to mark all as read?".tl,
onConfirm: () {
for (var comic in updatedComics) {
LocalFavoritesManager().markAsRead(
comic.id,
comic.type,
);
}
updateFollowUpdatesUI();
},
);
},
),
],
),
),
),
if (updatedComics.isNotEmpty)
SliverToBoxAdapter(
child: Text(
"The comic will be marked as no updates as soon as you read it."
.tl)
.paddingHorizontal(16)
.paddingVertical(4),
),
if (updatedComics.isNotEmpty)
SliverGridComics(comics: updatedComics)
else
SliverToBoxAdapter(
child: Row(
children: [
Container(
margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"No updates found".tl,
style: ts.s16,
),
],
),
)
],
),
),
],
);
}
Widget buildAllComics() {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(Icons.list),
const SizedBox(width: 8),
Text(
"All Comics".tl,
style: ts.s18,
),
],
),
),
),
SliverGridComics(comics: allComics),
],
);
}
void showSelector() {
var folders = LocalFavoritesManager().folderNames;
if (folders.isEmpty) {
context.showMessage(message: "No folders available".tl);
return;
}
String? selectedFolder;
showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Choose Folder".tl,
content: Column(
children: [
ListTile(
title: Text("Folder".tl),
trailing: Select(
minWidth: 120,
current: selectedFolder,
values: folders,
onTap: (i) {
setState(() {
selectedFolder = folders[i];
});
},
),
),
],
),
actions: [
if (appdata.settings["followUpdatesFolder"] != null)
TextButton(
onPressed: () {
disable();
context.pop();
},
child: Text("Disable".tl),
),
FilledButton(
onPressed: selectedFolder == null
? null
: () {
context.pop();
setFolder(selectedFolder!);
},
child: Text("Confirm".tl),
),
],
);
});
},
);
}
void disable() {
appdata.settings["followUpdatesFolder"] = null;
appdata.saveData();
updateFollowUpdatesUI();
}
void setFolder(String folder) async {
FollowUpdatesService._cancelChecking?.call();
LocalFavoritesManager().prepareTableForFollowUpdates(folder);
var count = LocalFavoritesManager().count(folder);
if (count > 0) {
bool isCanceled = false;
void onCancel() {
isCanceled = true;
}
var loadingController = showLoadingDialog(
App.rootContext,
withProgress: true,
cancelButtonText: "Cancel".tl,
onCancel: onCancel,
message: "Updating comics...".tl,
);
await for (var progress in updateFolder(folder, true)) {
if (isCanceled) {
return;
}
loadingController.setProgress(progress.current / progress.total);
}
loadingController.close();
}
setState(() {
appdata.settings["followUpdatesFolder"] = folder;
updatedComics = [];
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
sortComics();
});
appdata.saveData();
}
void checkNow() async {
FollowUpdatesService._cancelChecking?.call();
bool isCanceled = false;
void onCancel() {
isCanceled = true;
}
var loadingController = showLoadingDialog(
App.rootContext,
withProgress: true,
cancelButtonText: "Cancel".tl,
onCancel: onCancel,
message: "Updating comics...".tl,
);
int updated = 0;
await for (var progress in updateFolder(folder!, true)) {
if (isCanceled) {
return;
}
loadingController.setProgress(progress.current / progress.total);
updated = progress.updated;
}
loadingController.close();
if (updated > 0) {
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
updateComics();
}
}
void updateComics() {
if (folder == null) {
setState(() {
allComics = [];
updatedComics = [];
});
return;
}
setState(() {
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder!);
sortComics();
updatedComics = allComics.where((c) => c.hasNewUpdate).toList();
});
}
@override
Object? get key => 'FollowUpdatesPage';
}
/// Background service for checking updates
abstract class FollowUpdatesService {
static bool _isChecking = false;
static void Function()? _cancelChecking;
static bool _isInitialized = false;
static void _check() async {
if (_isChecking) {
return;
}
var folder = appdata.settings["followUpdatesFolder"];
if (folder == null) {
return;
}
bool isCanceled = false;
_cancelChecking = () {
isCanceled = true;
};
_isChecking = true;
while (DataSync().isDownloading) {
await Future.delayed(const Duration(milliseconds: 100));
}
int updated = 0;
try {
await for (var progress in updateFolder(folder, false)) {
if (isCanceled) {
return;
}
updated = progress.updated;
}
} finally {
_cancelChecking = null;
_isChecking = false;
if (updated > 0) {
updateFollowUpdatesUI();
}
}
}
/// Initialize the checker.
static void initChecker() {
if (_isInitialized) return;
_isInitialized = true;
_check();
DataSync().addListener(updateFollowUpdatesUI);
// A short interval will not affect the performance since every comic has a check time.
Timer.periodic(const Duration(minutes: 10), (timer) {
_check();
});
}
}
/// Update the UI of follow updates.
void updateFollowUpdatesUI() {
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics();
}

View File

@@ -29,86 +29,219 @@ class _HistoryPageState extends State<HistoryPage> {
void onUpdate() {
setState(() {
comics = HistoryManager().getAll();
if (multiSelectMode) {
selectedComics.removeWhere((comic, _) => !comics.contains(comic));
if (selectedComics.isEmpty) {
multiSelectMode = false;
}
}
});
}
var comics = HistoryManager().getAll();
var controller = FlyoutController();
bool multiSelectMode = false;
Map<History, bool> selectedComics = {};
void selectAll() {
setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
});
}
void deSelect() {
setState(() {
selectedComics.clear();
});
}
void invertSelection() {
setState(() {
comics.asMap().forEach((k, v) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
});
selectedComics.removeWhere((k, v) => !v);
});
}
void _removeHistory(History comic) {
if (comic.sourceKey.startsWith("Unknown")) {
HistoryManager().remove(
comic.id,
ComicType(int.parse(comic.sourceKey.split(':')[1])),
);
} else if (comic.sourceKey == 'local') {
HistoryManager().remove(
comic.id,
ComicType.local,
);
} else {
HistoryManager().remove(
comic.id,
ComicType(comic.sourceKey.hashCode),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text('History'.tl),
actions: [
Tooltip(
message: 'Clear History'.tl,
child: Flyout(
controller: controller,
flyoutBuilder: (context) {
return FlyoutContent(
title: 'Clear History'.tl,
content: Text(
'Are you sure you want to clear your history?'.tl),
actions: [
Button.filled(
color: context.colorScheme.error,
onPressed: () {
HistoryManager().clearHistory();
context.pop();
},
child: Text('Clear'.tl),
),
],
);
List<Widget> selectActions = [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: "Select All".tl,
onPressed: selectAll
),
IconButton(
icon: const Icon(Icons.deselect),
tooltip: "Deselect".tl,
onPressed: deSelect
),
IconButton(
icon: const Icon(Icons.flip),
tooltip: "Invert Selection".tl,
onPressed: invertSelection
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Delete".tl,
onPressed: selectedComics.isEmpty
? null
: () {
final comicsToDelete = List<History>.from(selectedComics.keys);
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
for (final comic in comicsToDelete) {
_removeHistory(comic);
}
},
),
];
List<Widget> normalActions = [
IconButton(
icon: const Icon(Icons.checklist),
tooltip: multiSelectMode ? "Exit Multi-Select".tl : "Multi-Select".tl,
onPressed: () {
setState(() {
multiSelectMode = !multiSelectMode;
});
},
),
Tooltip(
message: 'Clear History'.tl,
child: Flyout(
controller: controller,
flyoutBuilder: (context) {
return FlyoutContent(
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: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
controller.show();
},
),
child: Text('Clear Unfavorited'.tl),
),
)
],
),
SliverGridComics(
comics: comics,
badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name;
},
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.remove,
text: 'Remove'.tl,
const SizedBox(width: 4),
Button.filled(
color: context.colorScheme.error,
onClick: () {
if (c.sourceKey.startsWith("Unknown")) {
HistoryManager().remove(
c.id,
ComicType(int.parse(c.sourceKey.split(':')[1])),
);
} else if (c.sourceKey == 'local') {
HistoryManager().remove(
c.id,
ComicType.local,
);
onPressed: () {
HistoryManager().clearHistory();
context.pop();
},
child: Text('Clear'.tl),
),
],
);
},
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
controller.show();
},
),
),
)
];
return PopScope(
canPop: !multiSelectMode,
onPopInvokedWithResult: (didPop, result) {
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
}
},
child: Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
leading: Tooltip(
message: multiSelectMode ? "Cancel".tl : "Back".tl,
child: IconButton(
onPressed: () {
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
} else {
HistoryManager().remove(
c.id,
ComicType(c.sourceKey.hashCode),
);
context.pop();
}
},
icon: multiSelectMode
? const Icon(Icons.close)
: const Icon(Icons.arrow_back),
),
];
},
),
],
),
title: multiSelectMode
? Text(selectedComics.length.toString())
: Text('History'.tl),
actions: multiSelectMode ? selectActions : normalActions,
),
SliverGridComics(
comics: comics,
selections: selectedComics,
onLongPressed: null,
onTap: multiSelectMode
? (c) {
setState(() {
if (selectedComics.containsKey(c as History)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
if (selectedComics.isEmpty) {
multiSelectMode = false;
}
});
}
: null,
badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name;
},
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.remove,
text: 'Remove'.tl,
color: context.colorScheme.error,
onClick: () {
_removeHistory(c as History);
},
),
];
},
),
],
),
),
);
}

View File

@@ -9,9 +9,10 @@ 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/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/image_favorites_page/image_favorites_page.dart';
import 'package:venera/pages/search_page.dart';
@@ -34,6 +35,7 @@ class HomePage extends StatelessWidget {
const _SyncDataWidget(),
const _History(),
const _Local(),
const FollowUpdatesWidget(),
const _ComicSourceWidget(),
const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
@@ -50,7 +52,7 @@ class _SearchBar extends StatelessWidget {
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
height: 52,
height: App.isMobile ? 52 : 46,
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Material(
@@ -160,16 +162,50 @@ class _SyncDataWidgetState extends State<_SyncDataWidget>
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (DataSync().lastError != null)
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
showDialogMessage(
App.rootContext,
"Error".tl,
DataSync().lastError!,
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Colors.red,
size: 18,
),
const SizedBox(width: 4),
Text('Error'.tl, style: ts.s12),
],
),
),
).paddingRight(4),
IconButton(
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}),
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
},
),
IconButton(
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}),
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
},
),
],
),
),
@@ -195,10 +231,12 @@ class _HistoryState extends State<_History> {
late int count;
void onHistoryChange() {
setState(() {
history = HistoryManager().getRecent();
count = HistoryManager().count();
});
if (mounted) {
setState(() {
history = HistoryManager().getRecent();
count = HistoryManager().count();
});
}
}
@override
@@ -259,7 +297,7 @@ class _HistoryState extends State<_History> {
).paddingHorizontal(16),
if (history.isNotEmpty)
SizedBox(
height: 128,
height: 136,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: history.length,
@@ -362,13 +400,14 @@ class _LocalState extends State<_Local> {
).paddingHorizontal(16),
if (local.isNotEmpty)
SizedBox(
height: 128,
height: 136,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: local.length,
itemBuilder: (context, index) {
return SimpleComicTile(comic: local[index])
.paddingHorizontal(8);
.paddingHorizontal(8)
.paddingVertical(2);
},
),
).paddingHorizontal(8),
@@ -534,7 +573,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
],
),
onPressed: () {
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
},
).fixWidth(90).paddingRight(8),
Button.filled(
@@ -591,16 +631,29 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
@override
void initState() {
comicSources = ComicSource.all().map((e) => e.name).toList();
ComicSource.addListener(onComicSourceChange);
ComicSourceManager().addListener(onComicSourceChange);
super.initState();
}
@override
void dispose() {
ComicSource.removeListener(onComicSourceChange);
ComicSourceManager().removeListener(onComicSourceChange);
super.dispose();
}
int get _availableUpdates {
int c = 0;
ComicSourceManager().availableUpdates.forEach((key, version) {
var source = ComicSource.find(key);
if (source != null) {
if (compareSemVer(version, source.version)) {
c++;
}
}
});
return c;
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
@@ -664,7 +717,7 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}).toList(),
).paddingHorizontal(16).paddingBottom(16),
),
if (ComicSource.availableUpdates.isNotEmpty)
if (_availableUpdates > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
@@ -680,14 +733,24 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.update, color: context.colorScheme.primary, size: 20,),
Icon(
Icons.update,
color: context.colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text("@c updates".tlParams({
'c': ComicSource.availableUpdates.length,
}), style: ts.withColor(context.colorScheme.primary),),
Text(
"@c updates".tlParams({
'c': _availableUpdates,
}),
style: ts.withColor(context.colorScheme.primary),
),
],
),
).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8),
)
.toAlign(Alignment.centerLeft)
.paddingHorizontal(16)
.paddingBottom(8),
],
),
),
@@ -821,6 +884,21 @@ class _ImageFavoritesState extends State<ImageFavorites> {
Center(
child: Text('Image Favorites'.tl, style: ts.s18),
),
if (hasData)
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
imageFavoritesCompute!.count.toString(),
style: ts.s12,
),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
@@ -864,7 +942,7 @@ class _ImageFavoritesState extends State<ImageFavorites> {
displayType = type;
});
await Future.delayed(const Duration(milliseconds: 20));
var scrollController = ScrollControllerProvider.of(context);
var scrollController = ScrollState.of(context).controller;
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),

View File

@@ -11,7 +11,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/image_favorites_page/type.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';

View File

@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/cbz.dart';
@@ -12,6 +13,8 @@ import 'package:venera/utils/epub.dart';
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});
@@ -141,19 +144,27 @@ 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,
text: "View Detail".tl,
onClick: () {
context.to(() => ComicPage(
id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey,
));
id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey,
));
},
),
if (selectedComics.length == 1)
...exportActions(selectedComics.keys.first),
if (selectedComics.isNotEmpty)
...exportActions(selectedComics.keys.toList()),
]);
}
@@ -303,11 +314,21 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
}
});
} else {
(c as LocalComic).read();
// prevent dirty data
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,
@@ -322,7 +343,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
});
},
),
...exportActions(c as LocalComic),
...exportActions([c as LocalComic]),
];
},
),
@@ -356,28 +377,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),
@@ -390,79 +432,204 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return isDeleted;
}
List<MenuEntry> exportActions(LocalComic c) {
List<MenuEntry> exportActions(List<LocalComic> comics) {
return [
MenuEntry(
icon: Icons.outbox_outlined,
text: "Export as cbz".tl,
onClick: () async {
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
var file = await CBZ.export(c);
await saveFile(filename: file.name, file: file);
await file.delete();
} catch (e, s) {
context.showMessage(message: e.toString());
Log.error("CBZ Export", e, s);
}
controller.close();
}),
icon: Icons.outbox_outlined,
text: "Export as cbz".tl,
onClick: () {
exportComics(comics, CBZ.export, ".cbz");
},
),
MenuEntry(
icon: Icons.picture_as_pdf_outlined,
text: "Export as pdf".tl,
onClick: () async {
var cache = FilePath.join(App.cachePath, 'temp.pdf');
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
await createPdfFromComicIsolate(
comic: c,
savePath: cache,
);
await saveFile(
file: File(cache),
filename: "${c.title}.pdf",
);
} catch (e, s) {
Log.error("PDF Export", e, s);
context.showMessage(message: e.toString());
} finally {
controller.close();
File(cache).deleteIgnoreError();
}
exportComics(comics, createPdfFromComicIsolate, ".pdf");
},
),
MenuEntry(
icon: Icons.import_contacts_outlined,
text: "Export as epub".tl,
onClick: () async {
var controller = showLoadingDialog(
context,
allowCancel: false,
);
File? file;
try {
file = await createEpubWithLocalComic(
c,
);
await saveFile(
file: file,
filename: "${c.title}.epub",
);
} catch (e, s) {
Log.error("EPUB Export", e, s);
context.showMessage(message: e.toString());
} finally {
controller.close();
file?.deleteIgnoreError();
}
exportComics(comics, createEpubWithLocalComic, ".epub");
},
)
];
}
/// Export given comics to a file
void exportComics(
List<LocalComic> comics, ExportComicFunc export, String ext) async {
var current = 0;
var cacheDir = FilePath.join(App.cachePath, 'comics_export');
var outFile = FilePath.join(App.cachePath, 'comics_export.zip');
bool canceled = false;
if (Directory(cacheDir).existsSync()) {
Directory(cacheDir).deleteSync(recursive: true);
}
Directory(cacheDir).createSync();
var loadingController = showLoadingDialog(
context,
allowCancel: true,
message: "${"Exporting".tl} $current/${comics.length}",
withProgress: comics.length > 1,
onCancel: () {
canceled = true;
},
);
try {
var fileName = "";
// For each comic, export it to a file
for (var comic in comics) {
fileName = FilePath.join(
cacheDir,
sanitizeFileName(comic.title, maxLength: 100) + ext,
);
await export(comic, fileName);
current++;
if (comics.length > 1) {
loadingController
.setMessage("${"Exporting".tl} $current/${comics.length}");
loadingController.setProgress(current / comics.length);
}
if (canceled) {
return;
}
}
// For single comic, just save the file
if (comics.length == 1) {
await saveFile(
file: File(fileName),
filename: File(fileName).name,
);
Directory(cacheDir).deleteSync(recursive: true);
loadingController.close();
return;
}
// For multiple comics, compress the folder
loadingController.setProgress(null);
loadingController.setMessage("Compressing".tl);
await ZipFile.compressFolderAsync(cacheDir, outFile);
if (canceled) {
File(outFile).deleteIgnoreError();
return;
}
} catch (e, s) {
Log.error("Export Comics", e, s);
context.showMessage(message: e.toString());
loadingController.close();
return;
} finally {
Directory(cacheDir).deleteIgnoreError(recursive: true);
}
await saveFile(
file: File(outFile),
filename: "comics_export.zip",
);
loadingController.close();
File(outFile).deleteIgnoreError();
}
}
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

@@ -7,7 +7,6 @@ import 'package:venera/utils/translations.dart';
import '../components/components.dart';
import '../foundation/app.dart';
import 'comic_source_page.dart';
import 'explore_page.dart';
import 'favorites/favorites_page.dart';
import 'home_page.dart';
@@ -36,27 +35,12 @@ class _MainPageState extends State<MainPage> {
_navigatorKey!.currentContext!.pop();
}
void checkUpdates() async {
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) {
return;
}
appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
}
}
@override
void initState() {
checkUpdates();
_observer = NaviObserver();
_navigatorKey = GlobalKey();
App.mainNavigatorKey = _navigatorKey;
index = int.tryParse(appdata.settings['initialPage'].toString()) ?? 0;
super.initState();
}
@@ -78,6 +62,7 @@ class _MainPageState extends State<MainPage> {
@override
Widget build(BuildContext context) {
return NaviPane(
initialPage: index,
observer: _observer,
navigatorKey: _navigatorKey!,
paneItems: [

View File

@@ -0,0 +1,242 @@
part of 'reader.dart';
class _ChaptersView extends StatefulWidget {
const _ChaptersView(this.reader);
final _ReaderState reader;
@override
State<_ChaptersView> createState() => _ChaptersViewState();
}
class _ChaptersViewState extends State<_ChaptersView> {
bool desc = false;
late final ScrollController _scrollController;
var downloaded = <String>[];
@override
void initState() {
super.initState();
int epIndex = widget.reader.chapter - 2;
_scrollController = ScrollController(
initialScrollOffset: (epIndex * 48.0 + 52).clamp(0, double.infinity),
);
var local = LocalManager().find(widget.reader.cid, widget.reader.type);
if (local != null) {
downloaded = local.downloadedChapters;
}
}
@override
Widget build(BuildContext context) {
var chapters = widget.reader.widget.chapters!;
var current = widget.reader.chapter - 1;
return Scaffold(
body: SmoothCustomScrollView(
controller: _scrollController,
slivers: [
SliverAppbar(
style: AppbarStyle.shadow,
title: Text("Chapters".tl),
actions: [
Tooltip(
message: "Click to change the order".tl,
child: TextButton.icon(
icon: Icon(
!desc ? Icons.arrow_upward : Icons.arrow_downward,
size: 18,
),
label: Text(!desc ? "Ascending".tl : "Descending".tl),
onPressed: () {
setState(() {
desc = !desc;
});
},
),
),
],
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (desc) {
index = chapters.length - 1 - index;
}
var chapter = chapters.titles.elementAt(index);
return _ChapterListTile(
onTap: () {
widget.reader.toChapter(index + 1);
Navigator.of(context).pop();
},
title: chapter,
isActive: current == index,
isDownloaded:
downloaded.contains(chapters.ids.elementAt(index)),
);
},
childCount: chapters.length,
),
),
],
),
);
}
}
class _GroupedChaptersView extends StatefulWidget {
const _GroupedChaptersView(this.reader);
final _ReaderState reader;
@override
State<_GroupedChaptersView> createState() => _GroupedChaptersViewState();
}
class _GroupedChaptersViewState extends State<_GroupedChaptersView>
with SingleTickerProviderStateMixin {
ComicChapters get chapters => widget.reader.widget.chapters!;
late final TabController tabController;
late final ScrollController _scrollController;
late final String initialGroupName;
var downloaded = <String>[];
@override
void initState() {
super.initState();
int index = 0;
int epIndex = widget.reader.chapter - 1;
while (epIndex >= 0) {
epIndex -= chapters.getGroupByIndex(index).length;
index++;
}
tabController = TabController(
length: chapters.groups.length,
vsync: this,
initialIndex: index - 1,
);
initialGroupName = chapters.groups.elementAt(index - 1);
var epIndexAtGroup = widget.reader.chapter - 1;
for (var i = 0; i < index - 1; i++) {
epIndexAtGroup -= chapters.getGroupByIndex(i).length;
}
_scrollController = ScrollController(
initialScrollOffset: (epIndexAtGroup * 48.0).clamp(0, double.infinity),
);
var local = LocalManager().find(widget.reader.cid, widget.reader.type);
if (local != null) {
downloaded = local.downloadedChapters;
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Appbar(title: Text("Chapters".tl)),
AppTabBar(
controller: tabController,
tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
),
Expanded(
child: TabViewBody(
controller: tabController,
children: chapters.groups.map(buildGroup).toList(),
),
),
],
);
}
Widget buildGroup(String groupName) {
var group = chapters.getGroup(groupName);
return SmoothCustomScrollView(
controller: initialGroupName == groupName ? _scrollController : null,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
var name = group.values.elementAt(index);
var i = 0;
for (var g in chapters.groups) {
if (g == groupName) {
break;
}
i += chapters.getGroup(g).length;
}
i += index + 1;
return _ChapterListTile(
onTap: () {
widget.reader.toChapter(i);
context.pop();
},
title: name,
isActive: widget.reader.chapter == i,
isDownloaded: downloaded.contains(group.keys.elementAt(index)),
);
},
childCount: group.length,
),
),
],
);
}
}
class _ChapterListTile extends StatelessWidget {
const _ChapterListTile({
required this.title,
required this.isActive,
required this.isDownloaded,
required this.onTap,
});
final String title;
final bool isActive;
final bool isDownloaded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color:
isActive ? context.colorScheme.primary : Colors.transparent,
width: 4,
),
),
),
child: Row(
children: [
Text(
title,
style: isActive
? ts.withColor(context.colorScheme.primary).bold.s16
: ts.s16,
),
const Spacer(),
if (isDownloaded)
Icon(
Icons.download_done_rounded,
color: context.colorScheme.secondary,
),
],
),
),
);
}
}

View File

@@ -3,31 +3,32 @@ part of 'reader.dart';
class ComicImage extends StatefulWidget {
/// Modified from flutter Image
ComicImage({
required ImageProvider image,
super.key,
double scale = 1.0,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
}
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
required ImageProvider image,
super.key,
double scale = 1.0,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
this.onInit,
this.onDispose,
}) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
final ImageProvider image;
@@ -61,6 +62,10 @@ class ComicImage extends StatefulWidget {
final bool isAntiAlias;
final void Function(State<ComicImage> state)? onInit;
final void Function(State<ComicImage> state)? onDispose;
static void clear() => _ComicImageState.clear();
@override
@@ -88,6 +93,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this);
widget.onInit?.call(this);
}
@override
@@ -98,6 +104,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
_completerHandle?.dispose();
_scrollAwareContext.dispose();
_replaceImage(info: null);
widget.onDispose?.call(this);
super.dispose();
}
@@ -137,9 +144,18 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.reassemble();
}
bool containsPoint(Offset point) {
if (!mounted) {
return false;
}
var renderBox = context.findRenderObject() as RenderBox;
var localPoint = renderBox.globalToLocal(point);
return renderBox.paintBounds.contains(localPoint);
}
void _updateInvertColors() {
_invertColors = MediaQuery.maybeInvertColorsOf(context)
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
_invertColors = MediaQuery.maybeInvertColorsOf(context) ??
SemanticsBinding.instance.accessibilityFeatures.invertColors;
}
void _resolveImage() {
@@ -148,16 +164,19 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
imageProvider: widget.image,
);
final ImageStream newStream =
provider.resolve(createLocalImageConfiguration(
provider.resolve(createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
size: widget.width != null && widget.height != null
? Size(widget.width!, widget.height!)
: null,
));
_updateSourceStream(newStream);
}
ImageStreamListener? _imageStreamListener;
ImageStreamListener _getListener({bool recreateListener = false}) {
if(_imageStreamListener == null || recreateListener) {
if (_imageStreamListener == null || recreateListener) {
_lastException = null;
_imageStreamListener = ImageStreamListener(
_handleImageFrame,
@@ -191,7 +210,8 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
void _replaceImage({required ImageInfo? info}) {
final ImageInfo? oldImageInfo = _imageInfo;
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
SchedulerBinding.instance
.addPostFrameCallback((_) => oldImageInfo?.dispose());
_imageInfo = info;
}
@@ -208,7 +228,9 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
}
if (!widget.gaplessPlayback) {
setState(() { _replaceImage(info: null); });
setState(() {
_replaceImage(info: null);
});
}
setState(() {
@@ -247,7 +269,9 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
return;
}
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
if (keepStreamAlive &&
_completerHandle == null &&
_imageStream?.completer != null) {
_completerHandle = _imageStream!.completer!.keepAlive();
}
@@ -269,26 +293,41 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
children: [
Expanded(
child: Center(
child: Text(_lastException.toString(), maxLines: 3,),
child: Text(
_lastException.toString(),
maxLines: 3,
),
),
),
const SizedBox(height: 4,),
const SizedBox(
height: 4,
),
MouseRegion(
cursor: SystemMouseCursors.click,
child: Listener(
onPointerDown: (details){
onPointerDown: (details) {
GlobalState.find<_ReaderGestureDetectorState>().ignoreNextTap();
setState(() {
_loadingProgress = null;
_lastException = null;
});
_resolveImage();
},
child: const SizedBox(
child: SizedBox(
width: 84,
height: 36,
child: Center(
child: Text("Retry", style: TextStyle(color: Colors.blue),),
child: Text(
"Retry".tl,
style: TextStyle(color: Colors.blue),
),
),
),
),
),
const SizedBox(height: 16,),
const SizedBox(
height: 16,
),
],
),
),
@@ -300,34 +339,32 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
var width = widget.width;
var height = widget.height;
if(_imageInfo != null) {
if (_imageInfo != null) {
// Record the height and the width of the image
_cache[widget.image.hashCode] = Size(
_imageInfo!.image.width.toDouble(),
_imageInfo!.image.height.toDouble()
);
_cache[widget.image.hashCode] = Size(_imageInfo!.image.width.toDouble(),
_imageInfo!.image.height.toDouble());
}
Size? cacheSize = _cache[widget.image.hashCode];
if(cacheSize != null){
if(width == double.infinity) {
if (cacheSize != null) {
if (width == double.infinity) {
width = constrains.maxWidth;
height = width * cacheSize.height / cacheSize.width;
} else if(height == double.infinity) {
} else if (height == double.infinity) {
height = constrains.maxHeight;
width = height * cacheSize.width / cacheSize.height;
}
} else {
if(width == double.infinity) {
if (width == double.infinity) {
width = constrains.maxWidth;
height = 300;
} else if(height == double.infinity) {
} else if (height == double.infinity) {
height = constrains.maxHeight;
width = 300;
}
}
if(_imageInfo != null){
if (_imageInfo != null) {
// build image
Widget result = RawImage(
// Do not clone the image, because RawImage is a stateless wrapper.
@@ -379,12 +416,13 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
height: 24,
child: CircularProgressIndicator(
strokeWidth: 3,
backgroundColor: context.colorScheme.surfaceContainerLow,
backgroundColor: context.colorScheme.surfaceContainer,
value: (_loadingProgress != null &&
_loadingProgress!.expectedTotalBytes!=null &&
_loadingProgress!.expectedTotalBytes! != 0)
?_loadingProgress!.cumulativeBytesLoaded / _loadingProgress!.expectedTotalBytes!
:0,
_loadingProgress!.expectedTotalBytes != null &&
_loadingProgress!.expectedTotalBytes! != 0)
? _loadingProgress!.cumulativeBytesLoaded /
_loadingProgress!.expectedTotalBytes!
: 0,
),
),
),
@@ -398,8 +436,10 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.debugFillProperties(description);
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<ImageChunkEvent>(
'loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
description.add(DiagnosticsProperty<bool>(
'wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
}
}

View File

@@ -9,7 +9,7 @@ class _ReaderGestureDetector extends StatefulWidget {
State<_ReaderGestureDetector> createState() => _ReaderGestureDetectorState();
}
class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDetector> {
late TapGestureRecognizer _tapGestureRecognizer;
static const _kDoubleTapMaxTime = Duration(milliseconds: 200);
@@ -24,6 +24,14 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
int fingers = 0;
late _ReaderState reader;
bool ignoreNextTag = false;
void ignoreNextTap() {
ignoreNextTag = true;
}
@override
void initState() {
_tapGestureRecognizer = TapGestureRecognizer()
@@ -33,6 +41,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
};
super.initState();
context.readerScaffold._gestureDetectorState = this;
reader = context.reader;
}
@override
@@ -41,6 +50,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
fingers++;
if (ignoreNextTag) {
ignoreNextTag = false;
return;
}
_lastTapPointer = event.pointer;
_lastTapMoveDistance = Offset.zero;
_tapGestureRecognizer.addPointer(event);
@@ -118,11 +131,11 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
}
if (context.reader.mode.key.startsWith('gallery')) {
if (forward) {
if (!context.reader.toNextPage()) {
if (!context.reader.toNextPage() && !context.reader.isLastChapterOfGroup) {
context.reader.toNextChapter();
}
} else {
if (!context.reader.toPrevPage()) {
if (!context.reader.toPrevPage() && !context.reader.isFirstChapterOfGroup) {
context.reader.toPrevChapter();
}
}
@@ -139,12 +152,19 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
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 <
@@ -166,10 +186,13 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
}
void onTap(Offset location) {
if (context.readerScaffold.isOpen) {
if (reader._imageViewController!.handleOnTap(location)) {
return;
} 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;
@@ -186,31 +209,38 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
isBottom = true;
}
bool isCenter = false;
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:
case ReaderMode.continuousLeftToRight:
if (isLeft) {
context.reader.toPrevPage();
prev();
} else if (isRight) {
context.reader.toNextPage();
next();
} else {
isCenter = true;
}
case ReaderMode.galleryRightToLeft:
case ReaderMode.continuousRightToLeft:
if (isLeft) {
context.reader.toNextPage();
next();
} else if (isRight) {
context.reader.toPrevPage();
prev();
} else {
isCenter = true;
}
case ReaderMode.galleryTopToBottom:
case ReaderMode.continuousTopToBottom:
if (isTop) {
context.reader.toPrevPage();
prev();
} else if (isBottom) {
context.reader.toNextPage();
next();
} else {
isCenter = true;
}
@@ -260,6 +290,18 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
context.pop();
},
),
if (App.isDesktop && !reader.isLoading)
MenuEntry(
icon: Icons.copy,
text: "Copy Image".tl,
onClick: () => copyImage(location),
),
if (!reader.isLoading)
MenuEntry(
icon: Icons.download_outlined,
text: "Save Image".tl,
onClick: () => saveImage(location),
),
],
);
}
@@ -279,6 +321,30 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
void removeDragListener(_DragListener listener) {
_dragListeners.remove(listener);
}
@override
Object? get key => "reader_gesture";
void copyImage(Offset location) async {
var controller = reader._imageViewController;
var image = await controller!.getImageByOffset(location);
if (image != null) {
writeImageToClipboard(image);
} else {
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 {

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