245 Commits

Author SHA1 Message Date
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
119 changed files with 11736 additions and 2940 deletions

View File

@@ -8,9 +8,31 @@ body:
value: | value: |
Thank you for reporting a problem, please complete the title and fill in the following information. 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.** **Please do not report any issues related to config files.**
To report a bug related to the config file, please send it to the [config repository](https://github.com/venera-app/venera-configs). **请不要报告与配置文件相关的任何问题。**
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 - type: textarea
id: what-happened id: what-happened
attributes: attributes:

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: attributes:
value: | value: |
Welcome to make a feature request, please fill in the following information after completing the title. 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 - type: textarea
id: what-happened id: what-happened
attributes: 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: workflow_dispatch:
push: push:
branches: [ "master" ] branches: [ "master" ]
paths:
- 'fastlane/**'
pull_request: pull_request:
branches: [ "master" ] branches: [ "master" ]
paths:
- 'fastlane/**'
jobs: jobs:
go: 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

@@ -26,6 +26,9 @@ jobs:
echo "$CERTIFICATE" | base64 --decode > signing_certificate.p12 echo "$CERTIFICATE" | base64 --decode > signing_certificate.p12
security import signing_certificate.p12 -k ~/Library/Keychains/login.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign 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 # Step 2: Build the Flutter macOS app
- name: Build Flutter macOS App - name: Build Flutter macOS App
run: flutter build macos --release run: flutter build macos --release
@@ -97,10 +100,8 @@ jobs:
with: with:
distribution: 'oracle' distribution: 'oracle'
java-version: '17' java-version: '17'
- name: Setup Rust - name: Check rust-toolchain.toml
run: | run: rustup show
rustup update
rustup default stable
- run: flutter pub get - run: flutter pub get
- run: flutter build apk --release - run: flutter build apk --release
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -219,5 +220,6 @@ jobs:
outputs/*.exe outputs/*.exe
outputs/*.deb outputs/*.deb
outputs/*.zst outputs/*.zst
outputs/*.AppImage
env: env:
GITHUB_TOKEN: ${{ secrets.ACTION_GITHUB_TOKEN }} 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

@@ -3,7 +3,7 @@
[![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE) [![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) [![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) [![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)
A comic reader that support reading local and network comics. A comic reader that support reading local and network comics.

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,7 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android { android {
namespace = "com.github.wgh136.venera" namespace = "com.github.wgh136.venera"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion "25.1.8937393" ndkVersion "28.0.13004108"
splits{ splits{
abi { abi {
@@ -67,7 +67,6 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.github.wgh136.venera" applicationId = "com.github.wgh136.venera"
// You can update the following values to match your application needs. // 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. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
@@ -125,6 +124,6 @@ flutter {
} }
dependencies { dependencies {
implementation "androidx.activity:activity-ktx:1.9.2" implementation "androidx.activity:activity-ktx:1.10.1"
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'
} }

View File

@@ -47,6 +47,11 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="exhentai.org" android:pathPrefix="/g" /> <data android:scheme="https" android:host="exhentai.org" android:pathPrefix="/g" />
</intent-filter> </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> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
@@ -40,6 +41,41 @@ class MainActivity : FlutterFragmentActivity() {
private val nextLocalRequestCode = AtomicInteger() 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( private fun <I, O> startContractForResult(
contract: ActivityResultContract<I, O>, contract: ActivityResultContract<I, O>,
input: I, input: I,
@@ -134,6 +170,26 @@ class MainActivity : FlutterFragmentActivity() {
val mimeType = req.arguments<String>() val mimeType = req.arguments<String>()
openFile(res, mimeType!!) 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 { 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 distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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,7 +18,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.3.2' apply false id "com.android.application" version '8.9.0' apply false
id "org.jetbrains.kotlin.android" version "1.8.10" apply false id "org.jetbrains.kotlin.android" version "1.8.10" apply false
} }

View File

@@ -39,6 +39,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 * @param {ArrayBuffer} value
* @returns {string} * @returns {string}
@@ -176,7 +202,7 @@ let Convert = {
decryptAesCbc: (value, key, iv) => { decryptAesCbc: (value, key, iv) => {
return sendMessage({ return sendMessage({
method: "convert", method: "convert",
type: "aes-ecb", type: "aes-cbc",
value: value, value: value,
key: key, key: key,
iv: iv, iv: iv,
@@ -1296,13 +1322,15 @@ let UI = {
* Show an input dialog * Show an input dialog
* @param title {string} * @param title {string}
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message. * @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
* @param image {string?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null. * @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
*/ */
showInputDialog: (title, validator) => { showInputDialog: (title, validator, image) => {
return sendMessage({ return sendMessage({
method: 'UI', method: 'UI',
function: 'showInputDialog', function: 'showInputDialog',
title: title, title: title,
image: image,
validator: validator validator: validator
}) })
}, },
@@ -1358,3 +1386,29 @@ 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'
})
}

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 (Right to Left)": "连续(从右到左)",
"Continuous (Top to Bottom)": "连续(从上到下)", "Continuous (Top to Bottom)": "连续(从上到下)",
"Auto page turning interval": "自动翻页间隔", "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": "主题模式", "Theme Mode": "主题模式",
"System": "系统", "System": "系统",
"Light": "浅色", "Light": "浅色",
@@ -189,6 +190,7 @@
"Operation": "操作", "Operation": "操作",
"Upload": "上传", "Upload": "上传",
"Saved": "已保存", "Saved": "已保存",
"Saved Failed": "保存失败",
"Sync Data": "同步数据", "Sync Data": "同步数据",
"Syncing Data": "正在同步数据", "Syncing Data": "正在同步数据",
"Data Sync": "数据同步", "Data Sync": "数据同步",
@@ -232,8 +234,10 @@
"Please add some sources": "请添加一些源", "Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置", "Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面", "No Category Pages": "没有分类页面",
"Group @group": "第 @group 组",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 页", "Page @page": "第 @page 页",
"Remove local favorite and history": "删除本地收藏和历史记录",
"Also remove files on disk": "同时删除磁盘上的文件", "Also remove files on disk": "同时删除磁盘上的文件",
"Copy to app local path": "将漫画复制到本地存储目录中", "Copy to app local path": "将漫画复制到本地存储目录中",
"Delete all unavailable local favorite items": "删除所有无效的本地收藏", "Delete all unavailable local favorite items": "删除所有无效的本地收藏",
@@ -336,8 +340,7 @@
"Number of images preloaded": "预加载图片数量", "Number of images preloaded": "预加载图片数量",
"Ascending": "升序", "Ascending": "升序",
"Descending": "降序", "Descending": "降序",
"Last Reading: Chapter @ep Page @page": "上次阅读: 第 @ep 章 第 @page 页", "Last Reading": "上次阅读",
"Last Reading: Page @page": "上次阅读: 第 @page 页",
"Replies": "回复", "Replies": "回复",
"Follow Updates": "追更", "Follow Updates": "追更",
"Not Configured": "未配置", "Not Configured": "未配置",
@@ -353,7 +356,60 @@
"No updates found": "未找到更新", "No updates found": "未找到更新",
"All Comics": "全部漫画", "All Comics": "全部漫画",
"The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新", "The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新",
"Disable": "禁用" "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": "为每本漫画保存特定设置"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -363,7 +419,7 @@
"Settings": "設定", "Settings": "設定",
"Search": "搜尋", "Search": "搜尋",
"History": "歷史", "History": "歷史",
"Local": "本", "Local": "本",
"Import": "匯入", "Import": "匯入",
"Comic Source": "漫畫源", "Comic Source": "漫畫源",
"Accounts": "帳戶", "Accounts": "帳戶",
@@ -375,14 +431,14 @@
"help": "幫助", "help": "幫助",
"Select": "選擇", "Select": "選擇",
"Selected @a comics": "已選擇 @a 部漫畫", "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": "下載中", "Downloading": "下載中",
"Back": "後退", "Back": "後退",
"Delete": "刪除", "Delete": "刪除",
"Full Screen": "全螢幕", "Full Screen": "全螢幕",
"Auto Page Turning": "自動翻頁", "Auto Page Turning": "自動翻頁",
"Chapters": "章節", "Chapters": "章節",
"Save Image": "存圖片", "Save Image": "存圖片",
"Share": "分享", "Share": "分享",
"Details": "詳情", "Details": "詳情",
"Description": "描述", "Description": "描述",
@@ -390,19 +446,19 @@
"Add to favorites": "加入收藏", "Add to favorites": "加入收藏",
"Error": "錯誤", "Error": "錯誤",
"Retry": "重試", "Retry": "重試",
"Folders": "文件夾", "Folders": "資料夾",
"Delete Folder": "刪除文件夾", "Delete Folder": "刪除資料夾",
"Rename": "重新命名", "Rename": "重新命名",
"Reorder": "重新排序", "Reorder": "重新排序",
"Network": "網路", "Network": "網路",
"more": "更多", "more": "更多",
"Select a folder": "選擇一個文件夾", "Select a folder": "選擇一個資料夾",
"Folder": "文件夾", "Folder": "資料夾",
"Confirm": "確認", "Confirm": "確認",
"Remove comic from favorite?": "從收藏中移除漫畫?", "Remove comic from favorite?": "從收藏中移除漫畫?",
"Move": "移動", "Move": "移動",
"Move to folder": "移動到文件夾", "Move to folder": "移動到資料夾",
"Copy to folder": "複製到文件夾", "Copy to folder": "複製到資料夾",
"Delete Comic": "刪除漫畫", "Delete Comic": "刪除漫畫",
"Delete @c comics?": "刪除 @c 本漫畫?", "Delete @c comics?": "刪除 @c 本漫畫?",
"Add comic source": "添加漫畫源", "Add comic source": "添加漫畫源",
@@ -414,43 +470,43 @@
"Check updates": "檢查更新", "Check updates": "檢查更新",
"Edit": "編輯", "Edit": "編輯",
"Update": "更新", "Update": "更新",
"Log in": "登", "Log in": "登",
"Log out": "登出", "Log out": "登出",
"Re-login": "重新登", "Re-login": "重新登",
"Click if login expired": "點擊此處如果登已過期", "Click if login expired": "點擊此處如果登已過期",
"Login": "登", "Login": "登",
"Username": "用戶名", "Username": "使用者名稱",
"Password": "密碼", "Password": "密碼",
"Continue": "繼續", "Continue": "繼續",
"Create Account": "建帳戶", "Create Account": "建帳戶",
"Next": "前進", "Next": "前進",
"Login with webview": "過網頁登", "Login with webview": "過網頁登",
"Read": "閱讀", "Read": "閱讀",
"Download": "下載", "Download": "下載",
"Favorite": "收藏", "Favorite": "收藏",
"Comments": "評論", "Comments": "評論",
"Information": "信息", "Information": "資訊",
"Uploader": "上傳者", "Uploader": "上傳者",
"Upload Time": "上傳時間", "Upload Time": "上傳時間",
"Preview": "預覽", "Preview": "預覽",
"Comment": "評論", "Comment": "評論",
"Submit": "提交", "Submit": "提交",
"Add": "添加", "Add": "添加",
"New Folder": "新建文件夾", "New Folder": "建立資料夾",
"Reading": "閱讀中", "Reading": "閱讀中",
"Appearance": "外觀", "Appearance": "外觀",
"Local Favorites": "本收藏", "Local Favorites": "本收藏",
"APP": "應用", "APP": "應用",
"About": "關於", "About": "關於",
"Display mode of comic tile": "漫畫縮圖的顯示模式", "Display mode of comic tile": "漫畫縮圖的顯示模式",
"Detailed": "詳細", "Detailed": "詳細",
"Brief": "簡潔", "Brief": "簡潔",
"Size of comic tile": "漫畫縮圖的大小", "Size of comic tile": "漫畫縮圖的大小",
"Explore Pages": "探索頁面", "Explore Pages": "探索頁面",
"Category Pages": "分類頁面", "Category Pages": "分類頁面",
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態", "Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄", "Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
"Keyword blocking": "關鍵詞屏蔽", "Keyword blocking": "關鍵字封鎖",
"Tap to turn Pages": "點擊翻頁", "Tap to turn Pages": "點擊翻頁",
"Page animation": "頁面動畫", "Page animation": "頁面動畫",
"Reading mode": "閱讀模式", "Reading mode": "閱讀模式",
@@ -461,10 +517,11 @@
"Continuous (Right to Left)": "連續(從右到左)", "Continuous (Right to Left)": "連續(從右到左)",
"Continuous (Top to Bottom)": "連續(從上到下)", "Continuous (Top to Bottom)": "連續(從上到下)",
"Auto page turning interval": "自動翻頁間隔", "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": "主題模式", "Theme Mode": "主題模式",
"System": "系統", "System": "系統",
"Light": "色", "Light": "色",
"Dark": "深色", "Dark": "深色",
"Theme Color": "主題顏色", "Theme Color": "主題顏色",
"Red": "紅色", "Red": "紅色",
@@ -474,34 +531,34 @@
"Orange": "橙色", "Orange": "橙色",
"Blue": "藍色", "Blue": "藍色",
"App": "應用", "App": "應用",
"Data": "數據", "Data": "資料",
"Storage Path for local comics": "本漫畫的儲路徑", "Storage Path for local comics": "本漫畫的儲路徑",
"Set New Storage Path": "設新的儲路徑", "Set New Storage Path": "設新的儲路徑",
"Set": "設", "Set": "設",
"Cache Size": "緩存大小", "Cache Size": "快取大小",
"Clear Cache": "清除緩存", "Clear Cache": "清除快取",
"Clear": "清除", "Clear": "清除",
"Log": "日誌", "Log": "日誌",
"Open Log": "打開日誌", "Open Log": "打開日誌",
"Open": "打開", "Open": "打開",
"User": "用戶", "User": "使用者",
"Language": "語言", "Language": "語言",
"Proxy": "代理", "Proxy": "代理",
"Venera is a free and open-source app for comic reading.": "Venera是一個免費的開源漫畫閱讀應用。", "Venera is a free and open-source app for comic reading.": "Venera是一個免費的開源漫畫閱讀應用。",
"Check for updates": "檢查更新", "Check for updates": "檢查更新",
"Check": "檢查", "Check": "檢查",
"Network Favorite Pages": "網路收藏頁面", "Network Favorite Pages": "網路收藏頁面",
"Block": "屏蔽", "Block": "封鎖",
"Add new favorite to": "添加新收藏到", "Add new favorite to": "添加新收藏到",
"Move favorite after reading": "閱讀後移動收藏", "Move favorite after reading": "閱讀後移動收藏",
"Delete folder?" : "刪除文件夾?", "Delete folder?": "刪除資料夾?",
"Delete folder '@f' ?" : "刪除文件夾 '@f' ", "Delete folder '@f' ?": "刪除資料夾 '@f' ",
"Import from file": "從文件匯入", "Import from file": "從文件匯入",
"Failed to import": "匯入失敗", "Failed to import": "匯入失敗",
"Cache Limit": "緩存限制", "Cache Limit": "快取限制",
"Set Cache Limit": "設置緩存限制", "Set Cache Limit": "設定快取限制",
"Size in MB": "大小MB", "Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄", "Select a directory which contains the comic directories.": "選擇一個包含漫畫資料夾的目錄",
"Help": "幫助", "Help": "幫助",
"Export as cbz": "匯出為cbz", "Export as cbz": "匯出為cbz",
"Select an archive file (cbz, zip, 7z, cb7)": "選擇一個歸檔文件 (cbz, zip, 7z, cb7)", "Select an archive file (cbz, zip, 7z, cb7)": "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
@@ -515,15 +572,15 @@
"Date Desc": "日期降序", "Date Desc": "日期降序",
"Start": "開始", "Start": "開始",
"Reversed successfully": "反轉成功", "Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據", "Export App Data": "匯出應用資料",
"Import App Data": "匯入應用數據", "Import App Data": "匯入應用資料",
"Export": "匯出", "Export": "匯出",
"Download Threads": "下載線程數", "Download Threads": "下載執行緒數",
"Update Time": "更新時間", "Update Time": "更新時間",
"Copy ID": "複製ID", "Copy ID": "複製ID",
"Copy URL": "複製URL", "Copy URL": "複製URL",
"Create": "建", "Create": "建",
"Folder Name": "文件夾名稱", "Folder Name": "資料夾名稱",
"Ranking": "排行", "Ranking": "排行",
"Download Selected": "下載選中", "Download Selected": "下載選中",
"Download All": "下載全部", "Download All": "下載全部",
@@ -534,9 +591,9 @@
"Updates Available": "更新可用", "Updates Available": "更新可用",
"Unselected": "未選擇", "Unselected": "未選擇",
"Long press and drag to reorder.": "長按並拖動以重新排序。", "Long press and drag to reorder.": "長按並拖動以重新排序。",
"Limit image width": "限圖片寬度", "Limit image width": "限圖片寬度",
"When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式", "When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式",
"Open link": "打開鏈接", "Open link": "打開連結",
"Open comic": "打開漫畫", "Open comic": "打開漫畫",
"Move To First": "移動到最前", "Move To First": "移動到最前",
"Cancel": "取消", "Cancel": "取消",
@@ -544,15 +601,16 @@
"Pause": "暫停", "Pause": "暫停",
"Operation": "操作", "Operation": "操作",
"Upload": "上傳", "Upload": "上傳",
"Saved": "已存", "Saved": "已存",
"Sync Data": "同步數據", "Saved Failed": "儲存失敗",
"Syncing Data": "正在同步數據", "Sync Data": "同步資料",
"Data Sync": "數據同步", "Syncing Data": "正在同步資料",
"Data Sync": "資料同步",
"Quick Favorite": "快速收藏", "Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個文件夾", "Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
"Added": "已添加", "Added": "已添加",
"Turn page by volume keys": "使用音量鍵翻頁", "Turn page by volume keys": "使用音量鍵翻頁",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量信息", "Display time & battery info in reader": "在閱讀器中顯示時間和電量資訊",
"EhViewer downloads": "EhViewer下載", "EhViewer downloads": "EhViewer下載",
"Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料匯出的db檔案與存放下載內容的目錄", "Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料匯出的db檔案與存放下載內容的目錄",
"(EhViewer)Default": "(EhViewer)預設", "(EhViewer)Default": "(EhViewer)預設",
@@ -566,45 +624,47 @@
"Select in range": "區間選擇", "Select in range": "區間選擇",
"Finished": "已完成", "Finished": "已完成",
"Updating": "更新中", "Updating": "更新中",
"Update Comics Info": "更新漫畫信息", "Update Comics Info": "更新漫畫資訊",
"Create Folder": "新建文件夾", "Create Folder": "建立資料夾",
"Select an image on screen": "選擇幕上的圖片", "Select an image on screen": "選擇幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載列", "Added @count comics to download queue.": "已添加 @count 本漫畫到下載列",
"Authorization Required": "需要身份驗證", "Authorization Required": "需要身份驗證",
"Sync": "同步", "Sync": "同步",
"The folder is Linked to @source": "文件夾已關聯到 @source", "The folder is Linked to @source": "資料夾已關聯到 @source",
"Source Folder": "源文件夾", "Source Folder": "來源資料夾",
"Use a config file": "使用配置文件", "Use a config file": "使用設定檔",
"Comic Source list": "漫畫源列表", "Comic Source list": "漫畫源列表",
"View": "查看", "View": "查看",
"Copy": "複製", "Copy": "複製",
"Copied": "已複製", "Copied": "已複製",
"Search History": "搜歷史", "Search History": "搜歷史",
"Clear Search History": "清除搜歷史", "Clear Search History": "清除搜歷史",
"Search in": "搜於", "Search in": "搜於",
"Clear History": "清除歷史", "Clear History": "清除歷史",
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?", "Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
"No Explore Pages": "沒有探索頁面", "No Explore Pages": "沒有探索頁面",
"Please add some sources": "請添加一些源", "Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定", "Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面", "No Category Pages": "沒有分類頁面",
"Group @group": "第 @group 組",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁", "Page @page": "第 @page 頁",
"Also remove files on disk": "同時刪除磁盤上的文件", "Remove local favorite and history": "刪除本機收藏和歷史記錄",
"Copy to app local path": "將漫畫複製到本地儲存目錄中", "Also remove files on disk": "同時刪除磁碟上的文件",
"Delete all unavailable local favorite items": "刪除所有無效的本地收藏", "Copy to app local path": "將漫畫複製到本機儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
"Deleted @a favorite items.": "已刪除 @a 條無效收藏", "Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用", "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": "沒有新版本可用", "No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf", "Export as pdf": "匯出為pdf",
"Export as epub": "匯出為epub", "Export as epub": "匯出為epub",
"Aggregated Search": "聚合搜", "Aggregated Search": "聚合搜",
"No search results found": "未找到搜結果", "No search results found": "未找到搜結果",
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載列", "Added @c comics to download queue.": "已添加 @c 本漫畫到下載列",
"Download started": "下載已開始", "Download started": "下載已開始",
"Click favorite": "點擊收藏", "Click favorite": "點擊收藏",
"Local comic collection is not supported at present": "本收藏暫不支", "Local comic collection is not supported at present": "本收藏暫不支",
"The cover cannot be uncollected here": "封面不能在此取消收藏", "The cover cannot be uncollected here": "封面不能在此取消收藏",
"Uncollected the image": "取消收藏圖片", "Uncollected the image": "取消收藏圖片",
"Successfully collected": "收藏成功", "Successfully collected": "收藏成功",
@@ -613,7 +673,7 @@
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在圖片瀏覽頁面, 你可以根據你的閱讀模式橫向或者縱向滑動快速收藏圖片", "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 張圖片中, 計算你最喜歡的", "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": "括號後是圖片數量或圖片數比漫畫頁數", "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: ": "作者: ", "Author: ": "作者: ",
"Tags: ": "標籤: ", "Tags: ": "標籤: ",
"Comics(number): ": "漫畫(數量): ", "Comics(number): ": "漫畫(數量): ",
@@ -621,7 +681,7 @@
"Time Filter": "時間篩選", "Time Filter": "時間篩選",
"Image Favorites Greater Than": "圖片收藏數大於", "Image Favorites Greater Than": "圖片收藏數大於",
"Collection time": "收藏時間", "Collection time": "收藏時間",
"Not enable": "不用", "Not enable": "不用",
"Double Tap": "雙擊", "Double Tap": "雙擊",
"Swipe": "滑動", "Swipe": "滑動",
"favoritesCompareComicPages": "收藏數與漫畫頁數比較", "favoritesCompareComicPages": "收藏數與漫畫頁數比較",
@@ -632,7 +692,7 @@
"Favorite Num": "收藏數", "Favorite Num": "收藏數",
"Favorite Num Compare Comic Pages": "收藏數比漫畫頁數", "Favorite Num Compare Comic Pages": "收藏數比漫畫頁數",
"All": "全部", "All": "全部",
"Last Week": "上", "Last Week": "上",
"Last Month": "上月", "Last Month": "上月",
"Last Half Year": "半年", "Last Half Year": "半年",
"Last Year": "一年", "Last Year": "一年",
@@ -653,16 +713,16 @@
"No valid comics found": "未找到有效的漫畫", "No valid comics found": "未找到有效的漫畫",
"Enable DNS Overrides": "啟用DNS覆寫", "Enable DNS Overrides": "啟用DNS覆寫",
"DNS Overrides": "DNS覆寫", "DNS Overrides": "DNS覆寫",
"Custom Image Processing": "自定義圖片處理", "Custom Image Processing": "自圖片處理",
"Enable": "啟用", "Enable": "啟用",
"Aggregated": "聚合", "Aggregated": "聚合",
"Default Search Target": "默認搜索目標", "Default Search Target": "預設搜尋目標",
"Auto Language Filters": "自動語言篩選", "Auto Language Filters": "自動語言篩選",
"Check for updates on startup": "啟動時檢查更新", "Check for updates on startup": "啟動時檢查更新",
"Start Time": "開始時間", "Start Time": "開始時間",
"End Time": "結束時間", "End Time": "結束時間",
"Custom": "自定義", "Custom": "自",
"Reset": "重", "Reset": "重",
"Tags": "標籤", "Tags": "標籤",
"Authors": "作者", "Authors": "作者",
"Comics": "漫畫", "Comics": "漫畫",
@@ -670,45 +730,97 @@
"New Version": "新版本", "New Version": "新版本",
"@c updates": "@c 項更新", "@c updates": "@c 項更新",
"No updates": "無更新", "No updates": "無更新",
"Set comic source list url": "設漫畫源列表URL", "Set comic source list url": "設漫畫源列表URL",
"Deselect All": "取消全選", "Deselect All": "取消全選",
"Add keyword": "添加關鍵", "Add keyword": "添加關鍵",
"Keyword": "關鍵", "Keyword": "關鍵",
"Manage": "管理", "Manage": "管理",
"Verify": "驗證", "Verify": "驗證",
"Cloudflare verification required": "需要Cloudflare驗證", "Cloudflare verification required": "需要Cloudflare驗證",
"Success": "成功", "Success": "成功",
"Compressing": "壓縮中", "Compressing": "壓縮中",
"Exporting": "匯出中", "Exporting": "匯出中",
"Search Sources": "搜源", "Search Sources": "搜源",
"Removed": "已移除", "Removed": "已移除",
"Added to favorites": "已添加到收藏", "Added to favorites": "已添加到收藏",
"Not added": "未添加", "Not added": "未添加",
"Create a folder": "建收藏夾", "Create a folder": "建收藏夾",
"Created successfully": "建成功", "Created successfully": "建成功",
"name": "名稱", "name": "名稱",
"Reverse tap to turn Pages": "反轉點擊翻頁", "Reverse tap to turn Pages": "反轉點擊翻頁",
"Show all": "顯示全部", "Show all": "顯示全部",
"Number of images preloaded": "預載圖片數量", "Number of images preloaded": "預載圖片數量",
"Ascending": "升序", "Ascending": "升序",
"Descending": "降序", "Descending": "降序",
"Last Reading: Chapter @ep Page @page": "上次閱讀: 第 @ep 章 第 @page 頁", "Last Reading": "上次閱讀",
"Last Reading: Page @page": "上次閱讀: 第 @page 頁",
"Replies": "回覆", "Replies": "回覆",
"Follow Updates": "追更", "Follow Updates": "追更",
"Not Configured": "未配置", "Not Configured": "未配置",
"Choose a folder to follow updates." : "選擇一個文件夾以追更", "Choose a folder to follow updates.": "選擇一個資料夾以追更",
"Choose Folder": "選擇文件夾", "Choose Folder": "選擇資料夾",
"No folders available": "沒有可用的文件夾", "No folders available": "沒有可用的資料夾",
"Updating comics...": "更新漫畫中...", "Updating comics...": "更新漫畫中...",
"Automatic update checking enabled.": "已啟用自動更新檢查", "Automatic update checking enabled.": "已啟用自動更新檢查",
"The app will check for updates at most once a day.": "APP將每天最多檢查一次更新", "The app will check for updates at most once a day.": "APP將每天最多檢查一次更新",
"Change Folder": "更改文件夾", "Change Folder": "更改資料夾",
"Check Now": "立即檢查", "Check Now": "立即檢查",
"Updates": "更新", "Updates": "更新",
"No updates found": "未找到更新", "No updates found": "未找到更新",
"All Comics": "全部漫畫", "All Comics": "全部漫畫",
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新", "The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
"Disable": "用" "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": "為每本漫畫保存特定設定"
} }
} }

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. 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. - Install Venera. Using flutter to run the project is recommended since it's easier to debug.
- An editor that supports javascript. - An editor that supports javascript.
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs). - 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. 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). > Note: Javascript api document is [here](js_api.md).
### Write basic information #### Write basic information
```javascript ```javascript
class NewComicSource extends ComicSource { 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. - Change the class name to your source name.
- Fill in the name, key, version, minAppVersion, and url fields. - Fill in the name, key, version, minAppVersion, and url fields.
### init function #### init function
```javascript ```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. Remove this function if not used.
### Account #### Account
```javascript ```javascript
// [Optional] account related // [Optional] account related
@@ -140,7 +174,7 @@ In this part, you can implement login, logout, and register functions.
Remove this part if not used. Remove this part if not used.
### Explore page #### Explore page
```javascript ```javascript
// explore page list // 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. - 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. - 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 ```javascript
// categories // 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. A comic source can only have one category page.
### Category Comics Page #### Category Comics Page
```javascript ```javascript
/// category comic loading related /// 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. This part is used to load comics of a category.
### Search #### Search
```javascript ```javascript
/// search related /// search related
@@ -331,6 +365,11 @@ This part is used to load comics of a category.
// enable tags suggestions // enable tags suggestions
enableTagsSuggestions: false, 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. `load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored. If `load` function is implemented, `loadNext` function will be ignored.
### Favorites #### Favorites
```javascript ```javascript
// favorite related // 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. `load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored. If `load` function is implemented, `loadNext` function will be ignored.
### Comic Details #### Comic Details
```javascript ```javascript
/// single comic related /// 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. This part is used to load comic details.
### Settings #### Settings
```javascript ```javascript
/* /*
@@ -635,7 +674,7 @@ This part is used to load comic details.
This part is used to provide settings for the source. This part is used to provide settings for the source.
### Translations #### Translations
```javascript ```javascript
// [Optional] translations for the strings in this config // [Optional] translations for the strings in this config

View File

@@ -53,5 +53,7 @@
<true/> <true/>
<key>NSFaceIDUsageDescription</key> <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> </dict>
</plist> </plist>

View File

@@ -80,7 +80,7 @@ class _AppbarState extends State<Appbar> {
var content = Container( var content = Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.backgroundColor ?? color: widget.backgroundColor ??
context.colorScheme.surface.toOpacity(0.72), context.colorScheme.surface.toOpacity(0.86),
), ),
height: _kAppBarHeight + context.padding.top, height: _kAppBarHeight + context.padding.top,
child: Row( child: Row(
@@ -231,7 +231,7 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
child: BlurEffect( child: BlurEffect(
blur: 15, blur: 15,
child: Material( child: Material(
color: context.colorScheme.surface.toOpacity(0.72), color: context.colorScheme.surface.toOpacity(0.86),
elevation: 0, elevation: 0,
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
child: body, child: body,
@@ -632,6 +632,7 @@ class _TabViewBodyState extends State<TabViewBody> {
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_controller = widget.controller ?? DefaultTabController.of(context); _controller = widget.controller ?? DefaultTabController.of(context);
_currentIndex = _controller.index;
_controller.addListener(updateIndex); _controller.addListener(updateIndex);
} }

View File

@@ -334,7 +334,12 @@ class ComicTile extends StatelessWidget {
} }
var children = <Widget>[]; 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( children.add(Container(
margin: const EdgeInsets.fromLTRB(2, 0, 2, 2), margin: const EdgeInsets.fromLTRB(2, 0, 2, 2),
padding: constraints.maxWidth < 80 padding: constraints.maxWidth < 80
@@ -770,7 +775,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
@override @override
void didUpdateWidget(covariant SliverGridComics oldWidget) { void didUpdateWidget(covariant SliverGridComics oldWidget) {
if (!oldWidget.comics.isEqualTo(widget.comics)) { if (!comics.isEqualTo(widget.comics)) {
comics.clear(); comics.clear();
for (var comic in widget.comics) { for (var comic in widget.comics) {
if (isBlocked(comic) == null) { if (isBlocked(comic) == null) {
@@ -879,6 +884,7 @@ class _SliverGridComics extends StatelessWidget {
return comic; return comic;
} }
return AnimatedContainer( return AnimatedContainer(
key: ValueKey(comics[index].id),
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
@@ -1140,7 +1146,7 @@ class ComicListState extends State<ComicList> {
setState(() {}); setState(() {});
}); });
} }
if (_loading[page] == true) { if (_data[page] != null || _loading[page] == true) {
return; return;
} }
_loading[page] = true; _loading[page] = true;
@@ -1150,8 +1156,8 @@ class ComicListState extends State<ComicList> {
if (!mounted) return; if (!mounted) return;
if (res.success) { if (res.success) {
if (res.data.isEmpty) { if (res.data.isEmpty) {
_data[page] = const [];
setState(() { setState(() {
_data[page] = const [];
_maxPage = page; _maxPage = page;
}); });
} else { } else {
@@ -1201,6 +1207,11 @@ class ComicListState extends State<ComicList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var type = appdata.settings['comicListDisplayMode'];
return type == 'paging' ? buildPagingMode() : buildContinuousMode();
}
Widget buildPagingMode() {
if (_error != null) { if (_error != null) {
return Column( return Column(
children: [ children: [
@@ -1249,6 +1260,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 { class StarRating extends StatelessWidget {
@@ -1535,17 +1625,20 @@ class _SMClipper extends CustomClipper<Rect> {
} }
class SimpleComicTile extends StatelessWidget { 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 Comic comic;
final void Function()? onTap; final void Function()? onTap;
final bool withTitle;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var image = _findImageProvider(comic); var image = _findImageProvider(comic);
var child = image == null Widget child = image == null
? const SizedBox() ? const SizedBox()
: AnimatedImage( : AnimatedImage(
image: image, image: image,
@@ -1555,7 +1648,18 @@ class SimpleComicTile extends StatelessWidget {
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
); );
return AnimatedTapRegion( 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, borderRadius: 8,
onTap: onTap ?? onTap: onTap ??
() { () {
@@ -1566,16 +1670,29 @@ class SimpleComicTile extends StatelessWidget {
), ),
); );
}, },
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child, 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:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui' as ui; 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/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart'; import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/cloudflare.dart'; import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';

View File

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

View File

@@ -163,3 +163,29 @@ class SliverLazyToBoxAdapter extends StatelessWidget {
]); ]);
} }
} }
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

@@ -41,18 +41,22 @@ class NetworkError extends StatelessWidget {
], ],
), ),
), ),
const SizedBox( const SizedBox(height: 8),
height: 8,
),
Text( Text(
cfe == null ? message : "Cloudflare verification required".tl, cfe == null ? message : "Cloudflare verification required".tl,
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 3, maxLines: 3,
), ),
if (retry != null) TextButton(
const SizedBox( onPressed: () {
height: 12, saveFile(
data: utf8.encode(Log().toString()),
filename: 'log.txt',
);
},
child: Text("Export logs".tl),
), ),
const SizedBox(height: 8),
if (retry != null) if (retry != null)
if (cfe != null) if (cfe != null)
FilledButton( FilledButton(
@@ -74,15 +78,11 @@ class NetworkError extends StatelessWidget {
body = Column( body = Column(
children: [ children: [
const Appbar(title: Text("")), const Appbar(title: Text("")),
Expanded( Expanded(child: body),
child: body,
)
], ],
); );
} }
return Material( return Material(child: body);
child: body,
);
} }
} }
@@ -94,9 +94,7 @@ class ListLoadingIndicator extends StatelessWidget {
return const SizedBox( return const SizedBox(
width: double.infinity, width: double.infinity,
height: 80, height: 80,
child: Center( child: Center(child: FiveDotLoadingAnimation()),
child: FiveDotLoadingAnimation(),
),
); );
} }
} }
@@ -108,10 +106,9 @@ class SliverListLoadingIndicator extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// SliverToBoxAdapter can not been lazy loaded. // SliverToBoxAdapter can not been lazy loaded.
// Use SliverList to make sure the animation can be lazy loaded. // Use SliverList to make sure the animation can be lazy loaded.
return SliverList.list(children: const [ return SliverList.list(
SizedBox(), children: const [SizedBox(), ListLoadingIndicator()],
ListLoadingIndicator(), );
]);
} }
} }
@@ -178,10 +175,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
} }
Widget buildError() { Widget buildError() {
return NetworkError( return NetworkError(message: error!, retry: retry);
message: error!,
retry: retry,
);
} }
@override @override
@@ -323,11 +317,7 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
} }
Widget buildError(BuildContext context, String error) { Widget buildError(BuildContext context, String error) {
return NetworkError( return NetworkError(withAppbar: false, message: error, retry: reset);
withAppbar: false,
message: error,
retry: reset,
);
} }
@override @override
@@ -388,7 +378,7 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
Colors.green, Colors.green,
Colors.blue, Colors.blue,
Colors.yellow, Colors.yellow,
Colors.purple Colors.purple,
]; ];
static const _padding = 12.0; static const _padding = 12.0;
@@ -405,11 +395,10 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
return SizedBox( return SizedBox(
width: _dotSize * 5 + _padding * 6, width: _dotSize * 5 + _padding * 6,
height: _height, height: _height,
child: Stack( child: Stack(children: List.generate(5, (index) => buildDot(index))),
children: List.generate(5, (index) => buildDot(index)), );
), },
); );
});
} }
Widget buildDot(int index) { Widget buildDot(int index) {
@@ -417,7 +406,8 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
var startValue = index * 0.8; var startValue = index * 0.8;
return Positioned( return Positioned(
left: index * _dotSize + (index + 1) * _padding, 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), (_height - _dotSize),
child: Container( child: Container(
width: _dotSize, width: _dotSize,

View File

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

View File

@@ -125,11 +125,11 @@ class OverlayWidgetState extends State<OverlayWidget> {
void showDialogMessage(BuildContext context, String title, String message) { void showDialogMessage(BuildContext context, String title, String message) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => ContentDialog(
title: Text(title), title: title,
content: Text(message), content: Text(message).paddingHorizontal(16),
actions: [ actions: [
TextButton( FilledButton(
onPressed: context.pop, onPressed: context.pop,
child: Text("OK".tl), child: Text("OK".tl),
) )
@@ -290,7 +290,8 @@ class ContentDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var content = Column( var content = SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -312,6 +313,7 @@ class ContentDialog extends StatelessWidget {
).paddingRight(12), ).paddingRight(12),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
),
); );
return Dialog( return Dialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -357,6 +359,7 @@ Future<void> showInputDialog({
String confirmText = "Confirm", String confirmText = "Confirm",
String cancelText = "Cancel", String cancelText = "Cancel",
RegExp? inputValidator, RegExp? inputValidator,
String? image,
}) { }) {
var controller = TextEditingController(text: initialValue); var controller = TextEditingController(text: initialValue);
bool isLoading = false; bool isLoading = false;
@@ -369,7 +372,14 @@ Future<void> showInputDialog({
builder: (context, setState) { builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: title, title: title,
content: TextField( content: Column(
children: [
if (image != null)
SizedBox(
height: 108,
child: Image.network(image, fit: BoxFit.none),
).paddingBottom(8),
TextField(
controller: controller, controller: controller,
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
@@ -377,6 +387,8 @@ Future<void> showInputDialog({
errorText: error, errorText: error,
), ),
).paddingHorizontal(12), ).paddingHorizontal(12),
],
),
actions: [ actions: [
Button.filled( Button.filled(
isLoading: isLoading, isLoading: isLoading,

View File

@@ -51,10 +51,32 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
static bool _isMouseScroll = App.isDesktop; static bool _isMouseScroll = App.isDesktop;
late int id;
static int _id = 0;
var activeChildren = <int>{};
ScrollState? parent;
@override @override
void initState() { void initState() {
_controller = widget.controller ?? ScrollController(); _controller = widget.controller ?? ScrollController();
super.initState(); super.initState();
id = _id;
_id++;
}
@override
void didChangeDependencies() {
parent = ScrollState.maybeOf(context);
super.didChangeDependencies();
}
@override
void dispose() {
parent?.onChildInactive(id);
super.dispose();
} }
@override @override
@@ -66,8 +88,7 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
const BouncingScrollPhysics(), const BouncingScrollPhysics(),
); );
} }
return Listener( var child = Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) { onPointerDown: (event) {
_futurePosition = null; _futurePosition = null;
if (_isMouseScroll) { if (_isMouseScroll) {
@@ -77,6 +98,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
} }
}, },
onPointerSignal: (pointerSignal) { onPointerSignal: (pointerSignal) {
if (activeChildren.isNotEmpty) {
return;
}
if (pointerSignal is PointerScrollEvent) { if (pointerSignal is PointerScrollEvent) {
if (HardwareKeyboard.instance.isShiftPressed) { if (HardwareKeyboard.instance.isShiftPressed) {
return; return;
@@ -93,17 +117,28 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
_futurePosition ??= currentLocation; _futurePosition ??= currentLocation;
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1; double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
_futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k; _futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k;
var beforeOffset = (_futurePosition! - currentLocation).abs();
_futurePosition = _futurePosition!.clamp( _futurePosition = _futurePosition!.clamp(
_controller.position.minScrollExtent, _controller.position.minScrollExtent,
_controller.position.maxScrollExtent, _controller.position.maxScrollExtent,
); );
var afterOffset = (_futurePosition! - currentLocation).abs();
if (_futurePosition == old) return; if (_futurePosition == old) return;
var target = _futurePosition!; 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!, _futurePosition!,
duration: _fastAnimationDuration, duration: duration,
curve: Curves.linear, curve: Curves.linear,
).then((_) { )
.then((_) {
var current = _controller.position.pixels; var current = _controller.position.pixels;
if (current == target && current == _futurePosition) { if (current == target && current == _futurePosition) {
_futurePosition = null; _futurePosition = null;
@@ -111,8 +146,14 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
}); });
} }
}, },
child: ScrollControllerProvider._( child: ScrollState._(
controller: _controller, controller: _controller,
onChildActive: (id) {
activeChildren.add(id);
},
onChildInactive: (id) {
activeChildren.remove(id);
},
child: widget.builder( child: widget.builder(
context, context,
_controller, _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 { class ScrollState extends InheritedWidget {
const ScrollControllerProvider._({ const ScrollState._({
required this.controller, required this.controller,
required super.child, required super.child,
required this.onChildActive,
required this.onChildInactive,
}); });
final ScrollController controller; final ScrollController controller;
static ScrollController of(BuildContext context) { final void Function(int id) onChildActive;
final ScrollControllerProvider? provider =
context.dependOnInheritedWidgetOfExactType<ScrollControllerProvider>(); final void Function(int id) onChildInactive;
return provider!.controller;
static ScrollState of(BuildContext context) {
final ScrollState? provider =
context.dependOnInheritedWidgetOfExactType<ScrollState>();
return provider!;
}
static ScrollState? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ScrollState>();
} }
@override @override
bool updateShouldNotify(ScrollControllerProvider oldWidget) { bool updateShouldNotify(ScrollState oldWidget) {
return oldWidget.controller != controller; 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 ? 42.0 : 64.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, 4, 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'; part of 'components.dart';
class SideBarRoute<T> extends PopupRoute<T> { class SideBarRoute<T> extends PopupRoute<T> {
SideBarRoute(this.title, this.widget, SideBarRoute(this.widget,
{this.showBarrier = true, {this.showBarrier = true,
this.useSurfaceTintColor = false, this.useSurfaceTintColor = false,
required this.width, required this.width,
this.addBottomPadding = true, this.addBottomPadding = true,
this.addTopPadding = true}); this.addTopPadding = true});
final String? title;
final Widget widget; final Widget widget;
final bool showBarrier; final bool showBarrier;
@@ -36,11 +34,7 @@ class SideBarRoute<T> extends PopupRoute<T> {
Animation<double> secondaryAnimation) { Animation<double> secondaryAnimation) {
bool showSideBar = MediaQuery.of(context).size.width > width; bool showSideBar = MediaQuery.of(context).size.width > width;
Widget body = SidebarBody( Widget body = widget;
title: title,
widget: widget,
autoChangeTitleBarColor: !useSurfaceTintColor,
);
if (addTopPadding) { if (addTopPadding) {
body = Padding( 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, Future<void> showSideBar(BuildContext context, Widget widget,
{String? title, {bool showBarrier = true,
bool showBarrier = true,
bool useSurfaceTintColor = false, bool useSurfaceTintColor = false,
double width = 500, double width = 500,
bool addTopPadding = false}) { bool addTopPadding = false}) {
return Navigator.of(context).push( return Navigator.of(context).push(
SideBarRoute( SideBarRoute(
title,
widget, widget,
showBarrier: showBarrier, showBarrier: showBarrier,
useSurfaceTintColor: useSurfaceTintColor, useSurfaceTintColor: useSurfaceTintColor,

View File

@@ -10,6 +10,34 @@ import 'package:window_manager/window_manager.dart';
const _kTitleBarHeight = 36.0; const _kTitleBarHeight = 36.0;
class WindowFrameController extends InheritedWidget {
/// Whether the window frame is hidden.
final bool isWindowFrameHidden;
/// Sets the visibility of the window frame.
final void Function(bool) setWindowFrame;
/// 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;
/// Removes a close listener.
final void Function(WindowCloseListener listener) removeCloseListener;
const WindowFrameController._create({
required this.isWindowFrameHidden,
required this.setWindowFrame,
required this.addCloseListener,
required this.removeCloseListener,
required super.child,
});
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
return false;
}
}
class WindowFrame extends StatefulWidget { class WindowFrame extends StatefulWidget {
const WindowFrame(this.child, {super.key}); const WindowFrame(this.child, {super.key});
@@ -17,26 +45,63 @@ class WindowFrame extends StatefulWidget {
@override @override
State<WindowFrame> createState() => _WindowFrameState(); State<WindowFrame> createState() => _WindowFrameState();
static WindowFrameController of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
}
} }
typedef WindowCloseListener = bool Function();
class _WindowFrameState extends State<WindowFrame> { class _WindowFrameState extends State<WindowFrame> {
bool isHideWindowFrame = false; bool isWindowFrameHidden = false;
bool useDarkTheme = 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (App.isMobile) return widget.child; if (App.isMobile) return widget.child;
if (isHideWindowFrame) return widget.child;
var body = Stack( Widget body = Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: MediaQuery( child: MediaQuery(
data: MediaQuery.of(context).copyWith( data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)), padding: isWindowFrameHidden
? null
: const EdgeInsets.only(top: _kTitleBarHeight),
),
child: widget.child, child: widget.child,
), ),
), ),
if (!isWindowFrameHidden)
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@@ -82,7 +147,10 @@ class _WindowFrameState extends State<WindowFrame> {
onPressed: debug, onPressed: debug,
child: Text('Debug'), child: Text('Debug'),
), ),
if (!App.isMacOS) const WindowButtons() if (!App.isMacOS)
_WindowButtons(
onClose: _onClose,
)
], ],
), ),
); );
@@ -94,21 +162,29 @@ class _WindowFrameState extends State<WindowFrame> {
); );
if (App.isLinux) { if (App.isLinux) {
return VirtualWindowFrame(child: body); body = VirtualWindowFrame(child: body);
} else {
return body;
} }
return WindowFrameController._create(
isWindowFrameHidden: isWindowFrameHidden,
setWindowFrame: setWindowFrame,
addCloseListener: addCloseListener,
removeCloseListener: removeCloseListener,
child: body,
);
} }
} }
class WindowButtons extends StatefulWidget { class _WindowButtons extends StatefulWidget {
const WindowButtons({super.key}); const _WindowButtons({required this.onClose});
final void Function() onClose;
@override @override
State<WindowButtons> createState() => _WindowButtonsState(); State<_WindowButtons> createState() => _WindowButtonsState();
} }
class _WindowButtonsState extends State<WindowButtons> with WindowListener { class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
bool isMaximized = false; bool isMaximized = false;
@override @override
@@ -197,9 +273,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
color: !dark ? Colors.white : Colors.black, color: !dark ? Colors.white : Colors.black,
), ),
hoverColor: Colors.red, hoverColor: Colors.red,
onPressed: () { onPressed: widget.onClose,
windowManager.close();
},
) )
], ],
), ),
@@ -486,22 +560,18 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
} }
Widget _buildVirtualWindowFrame(BuildContext context) { Widget _buildVirtualWindowFrame(BuildContext context) {
return DecoratedBox( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_isMaximized ? 0 : 8),
color: Colors.transparent, color: Colors.transparent,
border: Border.all(
color: Theme.of(context).dividerColor,
width: (_isMaximized || _isFullScreen) ? 0 : 1,
),
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
if (!_isMaximized && !_isFullScreen)
BoxShadow( BoxShadow(
color: Colors.black.toOpacity(0.1), color: Colors.black.toOpacity(_isFocused ? 0.4 : 0.2),
offset: Offset(0.0, _isFocused ? 4 : 2), blurRadius: 4,
blurRadius: 6,
) )
], ],
), ),
clipBehavior: Clip.antiAlias,
child: widget.child, child: widget.child,
); );
} }
@@ -510,7 +580,10 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DragToResizeArea( return DragToResizeArea(
enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null, enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null,
child: Padding(
padding: EdgeInsets.all(_isMaximized ? 0 : 4),
child: _buildVirtualWindowFrame(context), child: _buildVirtualWindowFrame(context),
),
); );
} }
@@ -567,5 +640,5 @@ TransitionBuilder VirtualWindowFrameInit() {
} }
void debug() { void debug() {
ComicSource.reload(); ComicSourceManager().reload();
} }

View File

@@ -3,14 +3,17 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/history.dart';
import 'appdata.dart'; import 'appdata.dart';
import 'favorites.dart';
import 'local.dart';
export "widget_utils.dart"; export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.3.0"; final version = "1.4.6";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;
@@ -44,6 +47,7 @@ class _App {
late String dataPath; late String dataPath;
late String cachePath; late String cachePath;
String? externalStoragePath;
final rootNavigatorKey = GlobalKey<NavigatorState>(); final rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -51,6 +55,14 @@ class _App {
BuildContext get rootContext => rootNavigatorKey.currentContext!; BuildContext get rootContext => rootNavigatorKey.currentContext!;
final Appdata data = appdata;
final HistoryManager history = HistoryManager();
final LocalFavoritesManager favorites = LocalFavoritesManager();
final LocalManager local = LocalManager();
void rootPop() { void rootPop() {
rootNavigatorKey.currentState?.maybePop(); rootNavigatorKey.currentState?.maybePop();
} }
@@ -66,6 +78,18 @@ class _App {
Future<void> init() async { Future<void> init() async {
cachePath = (await getApplicationCacheDirectory()).path; cachePath = (await getApplicationCacheDirectory()).path;
dataPath = (await getApplicationSupportDirectory()).path; dataPath = (await getApplicationSupportDirectory()).path;
if (isAndroid) {
externalStoragePath = (await getExternalStorageDirectory())!.path;
}
}
Future<void> initComponents() async {
await Future.wait([
data.init(),
history.init(),
favorites.init(),
local.init(),
]);
} }
Function? _forceRebuildHandler; Function? _forceRebuildHandler;

View File

@@ -3,28 +3,32 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
class _Appdata { class Appdata with Init {
final _Settings settings = _Settings(); Appdata._create();
final Settings settings = Settings._create();
var searchHistory = <String>[]; var searchHistory = <String>[];
bool _isSavingData = false; bool _isSavingData = false;
Future<void> saveData([bool sync = true]) async { Future<void> saveData([bool sync = true]) async {
if (_isSavingData) { while (_isSavingData) {
await Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 20)); await Future.delayed(const Duration(milliseconds: 20));
return _isSavingData;
});
} }
_isSavingData = true; _isSavingData = true;
try {
var data = jsonEncode(toJson()); var data = jsonEncode(toJson());
var file = File(FilePath.join(App.dataPath, 'appdata.json')); var file = File(FilePath.join(App.dataPath, 'appdata.json'));
await file.writeAsString(data); await file.writeAsString(data);
} finally {
_isSavingData = false; _isSavingData = false;
}
if (sync) { if (sync) {
DataSync().uploadData(); DataSync().uploadData();
} }
@@ -51,33 +55,8 @@ class _Appdata {
saveData(); 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() { Map<String, dynamic> toJson() {
return { return {'settings': settings._data, 'searchHistory': searchHistory};
'settings': settings._data,
'searchHistory': searchHistory,
};
} }
/// Following fields are related to device-specific data and should not be synced. /// Following fields are related to device-specific data and should not be synced.
@@ -104,16 +83,57 @@ class _Appdata {
var implicitData = <String, dynamic>{}; var implicitData = <String, dynamic>{};
void writeImplicitData() { void writeImplicitData() async {
while (_isSavingData) {
await Future.delayed(const Duration(milliseconds: 20));
}
_isSavingData = true;
try {
var file = File(FilePath.join(App.dataPath, 'implicitData.json')); var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
file.writeAsString(jsonEncode(implicitData)); await file.writeAsString(jsonEncode(implicitData));
} finally {
_isSavingData = false;
} }
} }
final appdata = _Appdata(); @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();
}
}
}
class _Settings with ChangeNotifier { final appdata = Appdata._create();
_Settings();
class Settings with ChangeNotifier {
Settings._create();
final _data = <String, dynamic>{ final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief 'comicDisplayMode': 'detailed', // detailed, brief
@@ -132,8 +152,10 @@ class _Settings with ChangeNotifier {
'blockedWords': [], 'blockedWords': [],
'defaultSearchTarget': null, 'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds 'autoPageTurningInterval': 5, // in seconds
'enableComicSpecificSettings': false,
'readerMode': 'galleryLeftToRight', // values of [ReaderMode] 'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumber': 1, // 1 - 5 'readerScreenPicNumberForLandscape': 1, // 1 - 5
'readerScreenPicNumberForPortrait': 1, // 1 - 5
'enableTapToTurnPages': true, 'enableTapToTurnPages': true,
'reverseTapToTurnPages': false, 'reverseTapToTurnPages': false,
'enablePageAnimation': true, 'enablePageAnimation': true,
@@ -141,6 +163,7 @@ class _Settings with ChangeNotifier {
'cacheSize': 2048, // in MB 'cacheSize': 2048, // in MB
'downloadThreads': 5, 'downloadThreads': 5,
'enableLongPressToZoom': true, 'enableLongPressToZoom': true,
'longPressZoomPosition': "press", // press, center
'checkUpdateOnStart': false, 'checkUpdateOnStart': false,
'limitImageWidth': true, 'limitImageWidth': true,
'webdav': [], // empty means not configured 'webdav': [], // empty means not configured
@@ -157,9 +180,17 @@ class _Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing, 'customImageProcessing': defaultCustomImageProcessing,
'sni': true, 'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json", 'comicSourceListUrl': _defaultSourceListUrl,
'preloadImageCount': 4, 'preloadImageCount': 4,
'followUpdatesFolder': null, 'followUpdatesFolder': null,
'initialPage': '0',
'comicListDisplayMode': 'paging', // paging, continuous
'showPageNumberInReader': true,
'showSingleImageOnFirstPage': false,
'enableDoubleTapToZoom': true,
'reverseChapterOrder': false,
'showSystemStatusBar': false,
'comicSpecificSettings': <String, Map<String, dynamic>>{},
}; };
operator [](String key) { operator [](String key) {
@@ -168,6 +199,62 @@ class _Settings with ChangeNotifier {
operator []=(String key, dynamic value) { operator []=(String key, dynamic value) {
_data[key] = value; _data[key] = value;
if (key != "dataVersion") {
notifyListeners();
}
}
bool haveComicSpecificSettings(String comicId, String sourceKey, String key) {
return _data['comicSpecificSettings']?["$comicId@$sourceKey"]?.containsKey(
key,
) ??
false;
}
dynamic getReaderSetting(String comicId, String sourceKey, String key) {
if (key == 'enableComicSpecificSettings') {
return _data['enableComicSpecificSettings'];
}
if (_data['enableComicSpecificSettings'] == false) {
return _data[key];
}
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?[key] ??
_data[key];
}
void setReaderSetting(
String comicId,
String sourceKey,
String key,
dynamic value,
) {
if (key == 'enableComicSpecificSettings') {
_data['enableComicSpecificSettings'] = value;
notifyListeners();
return;
}
if (_data['enableComicSpecificSettings'] == false) {
_data[key] = value;
notifyListeners();
return;
}
(_data['comicSpecificSettings'] as Map<String, dynamic>).putIfAbsent(
"$comicId@$sourceKey",
() => <String, dynamic>{},
)[key] = value;
notifyListeners();
}
void resetComicReaderSettings(String comicId, String sourceKey) {
final allComicSettings = _data['comicSpecificSettings'] as Map;
if (allComicSettings.containsKey("$comicId@$sourceKey")) {
allComicSettings.remove("$comicId@$sourceKey");
}
notifyListeners();
}
void resetAllComicReaderSettings() {
_data['comicSpecificSettings'] = <String, Map<String, dynamic>>{};
notifyListeners(); notifyListeners();
} }
@@ -188,9 +275,12 @@ const defaultCustomImageProcessing = '''
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image * @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
*/ */
function processImage(image, cid, eid, page, sourceKey) { function processImage(image, cid, eid, page, sourceKey) {
let image = new Promise((resolve, reject) => { let futureImage = new Promise((resolve, reject) => {
resolve(image); 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:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -21,6 +23,51 @@ class CacheManager {
int _limitSize = 2 * 1024 * 1024 * 1024; int _limitSize = 2 * 1024 * 1024 * 1024;
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() { CacheManager._create() {
Directory(cachePath).createSync(recursive: true); Directory(cachePath).createSync(recursive: true);
_db = sqlite3.open('${App.dataPath}/cache.db'); _db = sqlite3.open('${App.dataPath}/cache.db');
@@ -33,10 +80,13 @@ class CacheManager {
type TEXT type TEXT
) )
'''); ''');
compute((path) => Directory(path).size, cachePath) _scanDir(_db.handle, cachePath).then((value) {
.then((value) => _currentSize = value); _currentSize = value;
checkCache();
});
} }
/// Get the singleton instance of CacheManager.
factory CacheManager() => instance ??= CacheManager._create(); factory CacheManager() => instance ??= CacheManager._create();
/// set cache size limit in MB /// set cache size limit in MB
@@ -44,35 +94,15 @@ class CacheManager {
_limitSize = size * 1024 * 1024; _limitSize = size * 1024 * 1024;
} }
void setType(String key, String? type){ /// Write cache to disk.
_db.execute(''' Future<void> writeCache(String key, List<int> data,
UPDATE cache [int duration = 7 * 24 * 60 * 60 * 1000]) async {
SET type = ? await delete(key);
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{
this.dir++; this.dir++;
this.dir %= 100; this.dir %= 100;
var dir = this.dir; 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'); 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.create(recursive: true);
await file.writeAsBytes(data); await file.writeAsBytes(data);
var expires = DateTime.now().millisecondsSinceEpoch + duration; var expires = DateTime.now().millisecondsSinceEpoch + duration;
@@ -85,20 +115,10 @@ class CacheManager {
checkCacheIfRequired(); checkCacheIfRequired();
} }
Future<CachingFile> openWrite(String key) async{ /// Find cache by key.
this.dir++; /// If cache is expired, it will be deleted and return null.
this.dir %= 100; /// If cache is not found, it will return null.
var dir = this.dir; /// If cache is found, it will return the file, and update the expires time.
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 { Future<File?> findCache(String key) async {
var res = _db.select(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
@@ -110,21 +130,51 @@ class CacheManager {
var row = res.first; var row = res.first;
var dir = row[1] as String; var dir = row[1] as String;
var name = row[2] as String; var name = row[2] as String;
var expires = row[3] as int;
var file = File('$cachePath/$dir/$name'); var file = File('$cachePath/$dir/$name');
var now = DateTime.now().millisecondsSinceEpoch;
if (expires < now) {
// expired
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
if (await file.exists()) { 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; return file;
} else {
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
} }
return null; return null;
} }
bool _isChecking = false; bool _isChecking = false;
/// Check cache size and delete expired cache.
/// Only check cache if current size is greater than limit size.
void checkCacheIfRequired() { void checkCacheIfRequired() {
if (_currentSize != null && _currentSize! > _limitSize) { if (_currentSize != null && _currentSize! > _limitSize) {
checkCache(); checkCache();
} }
} }
/// 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 { Future<void> checkCache() async {
if (_isChecking) { if (_isChecking) {
return; return;
@@ -139,28 +189,31 @@ class CacheManager {
var name = row[2] as String; var name = row[2] as String;
var file = File('$cachePath/$dir/$name'); 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(); await file.delete();
} }
} }
if (res.isNotEmpty) {
_db.execute(''' _db.execute('''
DELETE FROM cache DELETE FROM cache
WHERE expires < ? WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]); ''', [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(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
ORDER BY expires ASC ORDER BY expires ASC
limit 10 limit 10
'''); ''');
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) { for (var row in res) {
var key = row[0] as String; var key = row[0] as String;
var dir = row[1] as String; var dir = row[1] as String;
@@ -183,12 +236,12 @@ class CacheManager {
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
} }
count--;
} }
} }
_isChecking = false; _isChecking = false;
} }
/// Delete cache by key.
Future<void> delete(String key) async { Future<void> delete(String key) async {
var res = _db.select(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
@@ -215,6 +268,7 @@ class CacheManager {
} }
} }
/// Delete all cache.
Future<void> clear() async { Future<void> clear() async {
await Directory(cachePath).delete(recursive: true); await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true); Directory(cachePath).createSync(recursive: true);
@@ -223,75 +277,4 @@ class CacheManager {
'''); ''');
_currentSize = 0; _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 { abstract class BaseCategoryPart {
String get title; String get title;
List<String> get categories; List<CategoryItem> get categories;
List<String>? get categoryParams => null;
bool get enableRandom; bool get enableRandom;
String get categoryType;
/// Data class for building a part of category page. /// Data class for building a part of category page.
const BaseCategoryPart(); const BaseCategoryPart();
} }
class FixedCategoryPart extends BaseCategoryPart { class FixedCategoryPart extends BaseCategoryPart {
@override @override
final List<String> categories; final List<CategoryItem> categories;
@override @override
bool get enableRandom => false; bool get enableRandom => false;
@@ -59,19 +63,12 @@ class FixedCategoryPart extends BaseCategoryPart {
@override @override
final String title; final String title;
@override
final String categoryType;
@override
final List<String>? categoryParams;
/// A [BaseCategoryPart] that show fixed tags on category page. /// A [BaseCategoryPart] that show fixed tags on category page.
const FixedCategoryPart(this.title, this.categories, this.categoryType, const FixedCategoryPart(this.title, this.categories);
[this.categoryParams]);
} }
class RandomCategoryPart extends BaseCategoryPart { class RandomCategoryPart extends BaseCategoryPart {
final List<String> tags; final List<CategoryItem> all;
final int randomNumber; final int randomNumber;
@@ -81,71 +78,63 @@ class RandomCategoryPart extends BaseCategoryPart {
@override @override
bool get enableRandom => true; bool get enableRandom => true;
@override List<CategoryItem> _categories() {
final String categoryType; if (randomNumber >= all.length) {
return all;
List<String> _categories() {
if (randomNumber >= tags.length) {
return tags;
} }
var start = math.Random().nextInt(tags.length - randomNumber); var start = math.Random().nextInt(all.length - randomNumber);
return tags.sublist(start, start + randomNumber); return all.sublist(start, start + randomNumber);
} }
@override @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( const RandomCategoryPart(
this.title, this.tags, this.randomNumber, this.categoryType); this.title,
this.all,
this.randomNumber,
);
} }
class RandomCategoryPartWithRuntimeData extends BaseCategoryPart { class DynamicCategoryPart extends BaseCategoryPart {
final Iterable<String> Function() loadTags; final JSAutoFreeFunction loader;
final int randomNumber; final String sourceKey;
@override @override
final String title; List<CategoryItem> get categories {
var data = loader([]);
@override if (data is! List) {
bool get enableRandom => true; throw "DynamicCategoryPart loader must return a List";
@override
final String categoryType;
static final random = math.Random();
List<String> _categories() {
var tags = loadTags();
if (randomNumber >= tags.length) {
return tags.toList();
} }
final start = random.nextInt(tags.length - randomNumber); var res = <CategoryItem>[];
var res = List.filled(randomNumber, ''); for (var item in data) {
int index = -1; if (item is! Map) {
for (var s in tags) { throw "DynamicCategoryPart loader must return a List of Map";
index++;
if (start > index) {
continue;
} else if (index == start + randomNumber) {
break;
} }
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; return res;
} }
@override @override
List<String> get categories => _categories(); bool get enableRandom => false;
/// A [BaseCategoryPart] that show random tags on category page. @override
RandomCategoryPartWithRuntimeData( final String title;
this.title, this.loadTags, this.randomNumber, this.categoryType);
/// A [BaseCategoryPart] that show dynamic tags on category page.
const DynamicCategoryPart(this.title, this.loader, this.sourceKey);
} }
CategoryData getCategoryDataWithKey(String key) { CategoryData getCategoryDataWithKey(String key) {
for (var source in ComicSource._sources) { for (var source in ComicSource.all()) {
if (source.categoryData?.key == key) { if (source.categoryData?.key == key) {
return source.categoryData!; 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/comic_type.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/res.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/data_sync.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -27,81 +30,29 @@ part 'parser.dart';
part 'models.dart'; part 'models.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit. part 'types.dart';
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page. class ComicSourceManager with ChangeNotifier, Init {
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function( final List<ComicSource> _sources = [];
String? next);
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( factory ComicSourceManager() => _instance ??= ComicSourceManager._create();
String id, String? ep);
typedef CommentsLoader = Future<Res<List<Comment>>> Function( List<ComicSource> all() => List.from(_sources);
String id, String? subId, int page, String? replyTo);
typedef SendCommentFunc = Future<Res<bool>> Function( ComicSource? find(String key) =>
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) =>
_sources.firstWhereOrNull((element) => element.key == key); _sources.firstWhereOrNull((element) => element.key == key);
static ComicSource? fromIntKey(int key) => ComicSource? fromIntKey(int key) =>
_sources.firstWhereOrNull((element) => element.key.hashCode == 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"; final path = "${App.dataPath}/comic_source";
if (!(await Directory(path).exists())) { if (!(await Directory(path).exists())) {
Directory(path).create(); Directory(path).create();
@@ -120,26 +71,49 @@ class ComicSource {
} }
} }
static Future reload() async { Future reload() async {
_sources.clear(); _sources.clear();
JsEngine().runCode("ComicSource.sources = {};"); JsEngine().runCode("ComicSource.sources = {};");
await init(); await doInit();
notifyListeners(); notifyListeners();
} }
static void add(ComicSource source) { void add(ComicSource source) {
_sources.add(source); _sources.add(source);
notifyListeners(); notifyListeners();
} }
static void remove(String key) { void remove(String key) {
_sources.removeWhere((element) => element.key == key); _sources.removeWhere((element) => element.key == key);
notifyListeners(); 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. /// Name of this source.
final String name; final String name;
@@ -210,6 +184,9 @@ class ComicSource {
final HandleClickTagEvent? handleClickTagEvent; final HandleClickTagEvent? handleClickTagEvent;
/// Callback when a tag suggestion is selected in search.
final TagSuggestionSelectFunc? onTagSuggestionSelected;
final LinkHandler? linkHandler; final LinkHandler? linkHandler;
final bool enableTagsSuggestions; final bool enableTagsSuggestions;
@@ -285,6 +262,7 @@ class ComicSource {
this.idMatcher, this.idMatcher,
this.translations, this.translations,
this.handleClickTagEvent, this.handleClickTagEvent,
this.onTagSuggestionSelected,
this.linkHandler, this.linkHandler,
this.enableTagsSuggestions, this.enableTagsSuggestions,
this.enableTagsTranslate, this.enableTagsTranslate,
@@ -377,7 +355,7 @@ class ExplorePagePart {
/// - category:categoryName /// - category:categoryName
/// ///
/// End with `@`+`param` if the category has a parameter. /// End with `@`+`param` if the category has a parameter.
final String? viewMore; final PageJumpTarget? viewMore;
const ExplorePagePart(this.title, this.comics, this.viewMore); const ExplorePagePart(this.title, this.comics, this.viewMore);
} }

View File

@@ -111,6 +111,29 @@ class Comic {
@override @override
int get hashCode => id.hashCode ^ sourceKey.hashCode; 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 { class ComicDetails with HistoryMixin {
@@ -128,12 +151,7 @@ class ComicDetails with HistoryMixin {
final Map<String, List<String>> tags; final Map<String, List<String>> tags;
/// id-name /// id-name
final Map<String, String>? chapters; final ComicChapters? chapters;
/// key is group name.
/// When this field is not null, [chapters] will be a merged map of all groups.
/// Only available in some sources.
final Map<String, Map<String, String>>? groupedChapters;
final List<String>? thumbnails; final List<String>? thumbnails;
@@ -171,50 +189,20 @@ class ComicDetails with HistoryMixin {
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) { static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
var res = <String, List<String>>{}; var res = <String, List<String>>{};
map.forEach((key, value) { map.forEach((key, value) {
if (value is List) {
res[key] = List<String>.from(value); res[key] = List<String>.from(value);
}
}); });
return res; return res;
} }
static Map<String, String>? _getChapters(dynamic chapters) {
if (chapters == null) return null;
var result = <String, String>{};
if (chapters is Map) {
for (var entry in chapters.entries) {
var value = entry.value;
if (value is Map) {
result.addAll(Map.from(value));
} else {
result[entry.key.toString()] = value.toString();
}
}
}
return result;
}
static Map<String, Map<String, String>>? _getGroupedChapters(dynamic chapters) {
if (chapters == null) return null;
var result = <String, Map<String, String>>{};
if (chapters is Map) {
for (var entry in chapters.entries) {
var value = entry.value;
if (value is Map) {
result[entry.key.toString()] = Map.from(value);
}
}
}
if (result.isEmpty) return null;
return result;
}
ComicDetails.fromJson(Map<String, dynamic> json) ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"], : title = json["title"],
subTitle = json["subtitle"], subTitle = json["subtitle"],
cover = json["cover"], cover = json["cover"],
description = json["description"], description = json["description"],
tags = _generateMap(json["tags"]), tags = _generateMap(json["tags"]),
chapters = _getChapters(json["chapters"]), chapters = ComicChapters.fromJsonOrNull(json["chapters"]),
groupedChapters = _getGroupedChapters(json["chapters"]),
sourceKey = json["sourceKey"], sourceKey = json["sourceKey"],
comicId = json["comicId"], comicId = json["comicId"],
thumbnails = ListOrNull.from(json["thumbnails"]), thumbnails = ListOrNull.from(json["thumbnails"]),
@@ -342,3 +330,232 @@ class ArchiveInfo {
description = json["description"], description = json["description"],
id = json["id"]; 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

@@ -80,9 +80,8 @@ class ComicSourceParser {
Future<ComicSource> parse(String js, String filePath) async { Future<ComicSource> parse(String js, String filePath) async {
js = js.replaceAll("\r\n", "\n"); js = js.replaceAll("\r\n", "\n");
var line1 = js var line1 =
.split('\n') js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class "));
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
if (line1 == null || if (line1 == null ||
!line1.startsWith("class ") || !line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) { !line1.contains("extends ComicSource")) {
@@ -149,6 +148,7 @@ class ComicSourceParser {
_parseIdMatch(), _parseIdMatch(),
_parseTranslation(), _parseTranslation(),
_parseClickTagEvent(), _parseClickTagEvent(),
_parseTagSuggestionSelectFunc(),
_parseLinkHandler(), _parseLinkHandler(),
_getValue("search.enableTagsSuggestions") ?? false, _getValue("search.enableTagsSuggestions") ?? false,
_getValue("comic.enableTagsTranslate") ?? false, _getValue("comic.enableTagsTranslate") ?? false,
@@ -336,7 +336,7 @@ class ComicSourceParser {
(e['comics'] as List).map((e) { (e['comics'] as List).map((e) {
return Comic.fromJson(e, _key!); return Comic.fromJson(e, _key!);
}).toList(), }).toList(),
e['viewMore'], PageJumpTarget.parse(_key!, e['viewMore']),
); );
}), }),
), ),
@@ -404,6 +404,43 @@ class ComicSourceParser {
var categoryParts = <BaseCategoryPart>[]; var categoryParts = <BaseCategoryPart>[];
for (var c in doc["parts"]) { for (var c in doc["parts"]) {
if (c["categories"] != null && c["categories"] is! List) {
continue;
}
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 name = c["name"];
final String type = c["type"]; final String type = c["type"];
final List<String> tags = List.from(c["categories"]); final List<String> tags = List.from(c["categories"]);
@@ -413,12 +450,45 @@ class ComicSourceParser {
if (groupParam != null) { if (groupParam != null) {
categoryParams = List.filled(tags.length, groupParam); 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") { if (type == "fixed") {
categoryParts categoryParts.add(FixedCategoryPart(name, cs));
.add(FixedCategoryPart(name, tags, itemType, categoryParams));
} else if (type == "random") { } else if (type == "random") {
categoryParts.add( categoryParts
RandomCategoryPart(name, tags, c["randomNumber"] ?? 1, itemType)); .add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1));
}
} }
} }
@@ -620,7 +690,8 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder"); final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort"); final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic = _getValue("favorites.singleFolderForSingleComic"); final bool? singleFolderForSingleComic =
_getValue("favorites.singleFolderForSingleComic");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async { Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) { if (!ComicSource.find(_key!)!.isLogged) {
@@ -978,9 +1049,25 @@ class ComicSourceParser {
var res = JsEngine().runCode(""" var res = JsEngine().runCode("""
ComicSource.sources.$_key.comic.onClickTag(${jsonEncode(namespace)}, ${jsonEncode(tag)}) 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); 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";
}; };
} }

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:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
@@ -185,6 +187,18 @@ class FavoriteItemWithUpdateInfo extends FavoriteItem {
var sourceName = type.comicSource?.name ?? "Unknown"; var sourceName = type.comicSource?.name ?? "Unknown";
return "$updateTime | $sourceName"; 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 { class LocalFavoritesManager with ChangeNotifier {
@@ -197,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
late Database _db; 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 { Future<void> init() async {
counts = {};
_db = sqlite3.open("${App.dataPath}/local_favorite.db"); _db = sqlite3.open("${App.dataPath}/local_favorite.db");
_db.execute(""" _db.execute("""
create table if not exists folder_order ( create table if not exists folder_order (
@@ -212,7 +241,8 @@ class LocalFavoritesManager with ChangeNotifier {
source_folder text source_folder text
); );
"""); """);
for (var folder in _getFolderNamesWithDB()) { var folderNames = _getFolderNamesWithDB();
for (var folder in folderNames) {
var columns = _db.select(""" var columns = _db.select("""
pragma table_info("$folder"); pragma table_info("$folder");
"""); """);
@@ -221,7 +251,7 @@ class LocalFavoritesManager with ChangeNotifier {
alter table "$folder" alter table "$folder"
add column translated_tags TEXT; add column translated_tags TEXT;
"""); """);
var comics = getAllComics(folder); var comics = getFolderComics(folder);
for (var comic in comics) { for (var comic in comics) {
var translatedTags = _translateTags(comic.tags); var translatedTags = _translateTags(comic.tags);
_db.execute(""" _db.execute("""
@@ -234,6 +264,22 @@ class LocalFavoritesManager with ChangeNotifier {
break; 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) { List<String> find(String id, ComicType type) {
@@ -327,7 +373,7 @@ class LocalFavoritesManager with ChangeNotifier {
""").firstOrNull?["min_value"] ?? 0; """).firstOrNull?["min_value"] ?? 0;
} }
List<FavoriteItem> getAllComics(String folder) { List<FavoriteItem> getFolderComics(String folder) {
var rows = _db.select(""" var rows = _db.select("""
select * from "$folder" select * from "$folder"
ORDER BY display_order; ORDER BY display_order;
@@ -335,6 +381,54 @@ class LocalFavoritesManager with ChangeNotifier {
return rows.map((element) => FavoriteItem.fromRow(element)).toList(); 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) { void addTagTo(String folder, String id, String tag) {
_db.execute(""" _db.execute("""
update "$folder" update "$folder"
@@ -400,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
); );
"""); """);
notifyListeners(); notifyListeners();
counts[name] = 0;
return name; return name;
} }
@@ -514,6 +609,11 @@ class LocalFavoritesManager with ChangeNotifier {
""", [updateTime, comic.id, comic.type.value]); """, [updateTime, comic.id, comic.type.value]);
} }
} }
if (counts[folder] == null) {
counts[folder] = count(folder);
} else {
counts[folder] = counts[folder]! + 1;
}
notifyListeners(); notifyListeners();
return true; return true;
} }
@@ -553,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); 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 /// delete a folder
void deleteFolder(String name) { void deleteFolder(String name) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
@@ -563,14 +759,10 @@ class LocalFavoritesManager with ChangeNotifier {
delete from folder_order delete from folder_order
where folder_name == ?; where folder_name == ?;
""", [name]); """, [name]);
counts.remove(name);
notifyListeners(); notifyListeners();
} }
void deleteComic(String folder, FavoriteItem comic) {
_modifiedAfterLastCache = true;
deleteComicWithId(folder, comic.id, comic.type);
}
void deleteComicWithId(String folder, String id, ComicType type) { void deleteComicWithId(String folder, String id, ComicType type) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
LocalFavoriteImageProvider.delete(id, type.value); LocalFavoriteImageProvider.delete(id, type.value);
@@ -578,6 +770,60 @@ class LocalFavoritesManager with ChangeNotifier {
delete from "$folder" delete from "$folder"
where id == ? and type == ?; where id == ? and type == ?;
""", [id, type.value]); """, [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(); notifyListeners();
} }
@@ -608,11 +854,26 @@ class LocalFavoritesManager with ChangeNotifier {
if (!existsFolder(folder)) { if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found"); throw Exception("Failed to reorder: folder not found");
} }
deleteFolder(folder); _db.execute("BEGIN TRANSACTION");
createFolder(folder); try {
for (int i = 0; i < newFolder.length; i++) { for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], 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(); notifyListeners();
} }
@@ -637,6 +898,8 @@ class LocalFavoritesManager with ChangeNotifier {
set folder_name = ? set folder_name = ?
where folder_name == ?; where folder_name == ?;
""", [after, before]); """, [after, before]);
counts[after] = counts[before] ?? 0;
counts.remove(before);
notifyListeners(); notifyListeners();
} }
@@ -714,10 +977,10 @@ class LocalFavoritesManager with ChangeNotifier {
return comics; return comics;
} }
List<FavoriteItemWithFolderInfo> search(String keyword) { List<FavoriteItem> search(String keyword) {
var keywordList = keyword.split(" "); var keywordList = keyword.split(" ");
keyword = keywordList.first; keyword = keywordList.first;
var comics = <FavoriteItemWithFolderInfo>[]; var comics = <FavoriteItem>{};
for (var table in folderNames) { for (var table in folderNames) {
keyword = "%$keyword%"; keyword = "%$keyword%";
var res = _db.select(""" var res = _db.select("""
@@ -725,15 +988,18 @@ class LocalFavoritesManager with ChangeNotifier {
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?; WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword, keyword]); """, [keyword, keyword, keyword, keyword]);
for (var comic in res) { for (var comic in res) {
comics.add( comics.add(FavoriteItem.fromRow(comic));
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
} }
if (comics.length > 200) { if (comics.length > 200) {
break; 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)) { if (comic.name.contains(keyword)) {
return true; return true;
} else if (comic.author.contains(keyword)) { } else if (comic.author.contains(keyword)) {
@@ -744,12 +1010,14 @@ class LocalFavoritesManager with ChangeNotifier {
return false; return false;
} }
return comics.where((element) {
for (var i = 1; i < keywordList.length; i++) { for (var i = 1; i < keywordList.length; i++) {
comics = if (!test(element, keywordList[i])) {
comics.where((element) => test(element, keywordList[i])).toList(); return false;
} }
}
return comics; return true;
}).toList();
} }
void editTags(String id, String folder, List<String> tags) { void editTags(String id, String folder, List<String> tags) {
@@ -785,7 +1053,7 @@ class LocalFavoritesManager with ChangeNotifier {
} }
} }
void updateInfo(String folder, FavoriteItem comic) { void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
_db.execute(""" _db.execute("""
update "$folder" update "$folder"
set name = ?, author = ?, cover_path = ?, tags = ? set name = ?, author = ?, cover_path = ?, tags = ?
@@ -798,8 +1066,10 @@ class LocalFavoritesManager with ChangeNotifier {
comic.id, comic.id,
comic.type.value comic.type.value
]); ]);
if (notify) {
notifyListeners(); notifyListeners();
} }
}
String folderToJson(String folder) { String folderToJson(String folder) {
var res = _db.select(""" var res = _db.select("""
@@ -835,7 +1105,7 @@ class LocalFavoritesManager with ChangeNotifier {
} }
} }
void prepareTableForFollowUpdates(String table) { void prepareTableForFollowUpdates(String table, [bool clearData = true]) {
// check if the table has the column "last_update_time" "has_new_update" "last_check_time" // check if the table has the column "last_update_time" "has_new_update" "last_check_time"
var columns = _db.select(""" var columns = _db.select("""
pragma table_info("$table"); pragma table_info("$table");
@@ -852,10 +1122,12 @@ class LocalFavoritesManager with ChangeNotifier {
add column has_new_update int; add column has_new_update int;
"""); """);
} }
if (clearData) {
_db.execute(""" _db.execute("""
update "$table" update "$table"
set has_new_update = 0; set has_new_update = 0;
"""); """);
}
if (!columns.any((element) => element["name"] == "last_check_time")) { if (!columns.any((element) => element["name"] == "last_check_time")) {
_db.execute(""" _db.execute("""
alter table "$table" alter table "$table"
@@ -888,6 +1160,18 @@ class LocalFavoritesManager with ChangeNotifier {
]); ]);
} }
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) { int countUpdates(String folder) {
return _db.select(""" return _db.select("""
select count(*) as c from "$folder" select count(*) as c from "$folder"
@@ -949,4 +1233,8 @@ class LocalFavoritesManager with ChangeNotifier {
void close() { void close() {
_db.dispose(); _db.dispose();
} }
void notifyChanges() {
notifyListeners();
}
} }

View File

@@ -7,10 +7,10 @@ import 'dart:ffi' as ffi;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/common.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart'; import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -51,17 +51,24 @@ class History implements Comic {
@override @override
String cover; String cover;
/// index of chapters. 1-based.
int ep; int ep;
/// index of pages. 1-based.
int page; 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 @override
String id; String id;
/// readEpisode is a set of episode numbers that have been read. /// readEpisode is a set of episode numbers that have been read.
/// /// For normal chapters, it is a set of chapter numbers.
/// The number of episodes is 1-based. /// For grouped chapters, it is a set of strings in the format of "group_number-chapter_number".
Set<int> readEpisode; /// 1-based.
Set<String> readEpisode;
@override @override
int? maxPage; int? maxPage;
@@ -70,29 +77,17 @@ class History implements Comic {
{required HistoryMixin model, {required HistoryMixin model,
required this.ep, required this.ep,
required this.page, required this.page,
Set<int>? readChapters, this.group,
Set<String>? readChapters,
DateTime? time}) DateTime? time})
: type = model.historyType, : type = model.historyType,
title = model.title, title = model.title,
subtitle = model.subTitle ?? '', subtitle = model.subTitle ?? '',
cover = model.cover, cover = model.cover,
id = model.id, id = model.id,
readEpisode = readChapters ?? <int>{}, readEpisode = readChapters ?? <String>{},
time = time ?? DateTime.now(); 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) History.fromMap(Map<String, dynamic> map)
: type = HistoryType(map["type"]), : type = HistoryType(map["type"]),
time = DateTime.fromMillisecondsSinceEpoch(map["time"]), time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
@@ -102,8 +97,9 @@ class History implements Comic {
ep = map["ep"], ep = map["ep"],
page = map["page"], page = map["page"],
id = map["id"], id = map["id"],
readEpisode = Set<int>.from( readEpisode = Set<String>.from(
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}), (map["readEpisode"] as List<dynamic>?)?.toSet() ??
const <String>{}),
maxPage = map["max_page"]; maxPage = map["max_page"];
@override @override
@@ -120,11 +116,11 @@ class History implements Comic {
ep = row["ep"], ep = row["ep"],
page = row["page"], page = row["page"],
id = row["id"], id = row["id"],
readEpisode = Set<int>.from((row["readEpisode"] as String) readEpisode = Set<String>.from((row["readEpisode"] as String)
.split(',') .split(',')
.where((element) => element != "") .where((element) => element != "")),
.map((e) => int.parse(e))), maxPage = row["max_page"],
maxPage = row["max_page"]; group = row["chapter_group"];
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@@ -137,6 +133,11 @@ class History implements Comic {
@override @override
String get description { String get description {
var res = ""; var res = "";
if (group != null){
res += "${"Group @group".tlParams({
"group": group!,
})} - ";
}
if (ep >= 1) { if (ep >= 1) {
res += "Chapter @ep".tlParams({ res += "Chapter @ep".tlParams({
"ep": ep, "ep": ep,
@@ -213,18 +214,24 @@ class HistoryManager with ChangeNotifier {
ep int, ep int,
page int, page int,
readEpisode text, 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(); notifyListeners();
ImageFavoriteManager().init(); ImageFavoriteManager().init();
isInitialized = true; isInitialized = true;
} }
static const _insertHistorySql = """ static const _insertHistorySql = """
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page) insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page, chapter_group)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"""; """;
static Future<void> _addHistoryAsync(int dbAddr, History newItem) { static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
@@ -240,7 +247,8 @@ class HistoryManager with ChangeNotifier {
newItem.ep, newItem.ep,
newItem.page, newItem.page,
newItem.readEpisode.join(','), newItem.readEpisode.join(','),
newItem.maxPage newItem.maxPage,
newItem.group
]); ]);
}); });
} }
@@ -282,7 +290,8 @@ class HistoryManager with ChangeNotifier {
newItem.ep, newItem.ep,
newItem.page, newItem.page,
newItem.readEpisode.join(','), newItem.readEpisode.join(','),
newItem.maxPage newItem.maxPage,
newItem.group
]); ]);
if (_cachedHistoryIds == null) { if (_cachedHistoryIds == null) {
updateCache(); updateCache();
@@ -302,6 +311,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners(); 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 { void remove(String id, ComicType type) async {
_db.execute(""" _db.execute("""
delete from history delete from history
@@ -319,7 +353,7 @@ class HistoryManager with ChangeNotifier {
for (var element in res) { for (var element in res) {
_cachedHistoryIds![element["id"] as String] = true; _cachedHistoryIds![element["id"] as String] = true;
} }
for (var key in cachedHistories.keys) { for (var key in cachedHistories.keys.toList()) {
if (!_cachedHistoryIds!.containsKey(key)) { if (!_cachedHistoryIds!.containsKey(key)) {
cachedHistories.remove(key); cachedHistories.remove(key);
} }
@@ -377,4 +411,23 @@ class HistoryManager with ChangeNotifier {
isInitialized = false; isInitialized = false;
_db.dispose(); _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

@@ -97,7 +97,7 @@ class ImageFavoritesProvider
if (localComic == null) { if (localComic == null) {
return 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) { if (epIndex == -1 && localComic.hasChapters) {
return null; return null;
} }

View File

@@ -3,6 +3,8 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/io.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:flutter/services.dart';
import 'package:html/parser.dart' as html; import 'package:html/parser.dart' as html;
import 'package:html/dom.dart' as dom; import 'package:html/dom.dart' as dom;
@@ -24,6 +26,8 @@ import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.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 'comic_source/comic_source.dart';
import 'consts.dart'; import 'consts.dart';
@@ -40,7 +44,7 @@ class JavaScriptRuntimeException implements Exception {
} }
} }
class JsEngine with _JSEngineApi, JsUiApi { class JsEngine with _JSEngineApi, JsUiApi, Init {
factory JsEngine() => _cache ?? (_cache = JsEngine._create()); factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache; static JsEngine? _cache;
@@ -64,7 +68,9 @@ class JsEngine with _JSEngineApi, JsUiApi {
responseType: ResponseType.plain, validateStatus: (status) => true)); responseType: ResponseType.plain, validateStatus: (status) => true));
} }
Future<void> init() async { @override
@protected
Future<void> doInit() async {
if (!_closed) { if (!_closed) {
return; return;
} }
@@ -159,6 +165,13 @@ class JsEngine with _JSEngineApi, JsUiApi {
return "${App.locale.languageCode}_${App.locale.countryCode}"; return "${App.locale.languageCode}_${App.locale.countryCode}";
case "getPlatform": case "getPlatform":
return Platform.operatingSystem; 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;
});
} }
} }
return null; return null;
@@ -183,7 +196,7 @@ class JsEngine with _JSEngineApi, JsUiApi {
responseType: ResponseType.plain, responseType: ResponseType.plain,
validateStatus: (status) => true, validateStatus: (status) => true,
)); ));
var proxy = await AppDio.getProxy(); var proxy = await getProxy();
dio.httpClientAdapter = IOHttpClientAdapter( dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () { createHttpClient: () {
return HttpClient() return HttpClient()
@@ -360,6 +373,11 @@ mixin class _JSEngineApi {
switch (type) { switch (type) {
case "utf8": case "utf8":
return isEncode ? utf8.encode(value) : utf8.decode(value); 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": case "base64":
return isEncode ? base64Encode(value) : base64Decode(value); return isEncode ? base64Encode(value) : base64Decode(value);
case "md5": case "md5":

View File

@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:flutter_saf/flutter_saf.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.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/foundation/log.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'app.dart'; import 'app.dart';
@@ -34,7 +35,7 @@ class LocalComic with HistoryMixin implements Comic {
/// key: chapter id, value: chapter title /// key: chapter id, value: chapter title
/// ///
/// chapter id is the name of the directory in `LocalManager.path/$directory` /// 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; bool get hasChapters => chapters != null;
@@ -67,7 +68,7 @@ class LocalComic with HistoryMixin implements Comic {
subtitle = row[2] as String, subtitle = row[2] as String,
tags = List.from(jsonDecode(row[3] as String)), tags = List.from(jsonDecode(row[3] as String)),
directory = row[4] 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, cover = row[6] as String,
comicType = ComicType(row[7] as int), comicType = ComicType(row[7] as int),
downloadedChapters = List.from(jsonDecode(row[8] as String)), downloadedChapters = List.from(jsonDecode(row[8] as String)),
@@ -99,6 +100,7 @@ class LocalComic with HistoryMixin implements Comic {
"tags": tags, "tags": tags,
"description": description, "description": description,
"sourceKey": sourceKey, "sourceKey": sourceKey,
"chapters": chapters?.toJson(),
}; };
} }
@@ -107,14 +109,42 @@ class LocalComic with HistoryMixin implements Comic {
void read() { void read() {
var history = HistoryManager().find(id, comicType); 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( App.rootContext.to(
() => Reader( () => Reader(
type: comicType, type: comicType,
cid: id, cid: id,
name: title, name: title,
chapters: chapters, chapters: chapters,
initialChapter: history?.ep, initialChapter: history?.ep ?? firstDownloadedChapter,
initialPage: history?.page, initialPage: history?.page,
initialChapterGroup: history?.group ?? firstDownloadedChapterGroup,
history: history ?? history: history ??
History.fromModel( History.fromModel(
model: this, model: this,
@@ -264,6 +294,7 @@ class LocalManager with ChangeNotifier {
} }
_checkPathValidation(); _checkPathValidation();
_checkNoMedia(); _checkNoMedia();
await ComicSourceManager().ensureInit();
restoreDownloadingTasks(); restoreDownloadingTasks();
} }
@@ -391,7 +422,7 @@ class LocalManager with ChangeNotifier {
var directory = Directory(comic.baseDir); var directory = Directory(comic.baseDir);
if (comic.hasChapters) { if (comic.hasChapters) {
var cid = 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)); directory = Directory(FilePath.join(directory.path, cid));
} }
var files = <File>[]; var files = <File>[];
@@ -420,12 +451,30 @@ class LocalManager with ChangeNotifier {
return files.map((e) => "file://${e.path}").toList(); 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); var comic = find(id, type);
if (comic == null) return false; if (comic == null) return false;
if (comic.chapters == null || ep == null) return true; 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 return comic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(ep - 1)); .contains((chapters ?? comic.chapters)!.ids.elementAtOrNull(ep - 1));
} }
List<DownloadTask> downloadingTasks = []; List<DownloadTask> downloadingTasks = [];
@@ -441,6 +490,10 @@ class LocalManager with ChangeNotifier {
if (comic != null) { if (comic != null) {
return Directory(FilePath.join(path, comic.directory)); return Directory(FilePath.join(path, comic.directory));
} }
const comicDirectoryMaxLength = 80;
if (name.length > comicDirectoryMaxLength) {
name = name.substring(0, comicDirectoryMaxLength);
}
var dir = findValidDirectoryName(path, name); var dir = findValidDirectoryName(path, name);
return Directory(FilePath.join(path, dir)).create().then((value) => value); return Directory(FilePath.join(path, dir)).create().then((value) => value);
} }
@@ -522,6 +575,99 @@ class LocalManager with ChangeNotifier {
remove(c.id, c.comicType); remove(c.id, c.comicType);
notifyListeners(); 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 { enum LocalSortType {

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
class LogItem { class LogItem {
final LogLevel level; final LogLevel level;
@@ -28,9 +28,6 @@ class Log {
static bool ignoreLimitation = false; static bool ignoreLimitation = false;
/// only for debug
static const String? logFile = null;
static void printWarning(String text) { static void printWarning(String text) {
debugPrint('\x1B[33m$text\x1B[0m'); debugPrint('\x1B[33m$text\x1B[0m');
} }
@@ -39,7 +36,20 @@ class Log {
debugPrint('\x1B[31m$text\x1B[0m'); debugPrint('\x1B[31m$text\x1B[0m');
} }
static IOSink? _file;
static void addLog(LogLevel level, String title, String content) { static void addLog(LogLevel level, String title, String content) {
if (_file == null) {
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) { if (!ignoreLimitation && content.length > maxLogLength) {
content = "${content.substring(0, maxLogLength)}..."; content = "${content.substring(0, maxLogLength)}...";
} }
@@ -62,8 +72,8 @@ class Log {
} }
_logs.add(newLog); _logs.add(newLog);
if(logFile != null) { if(_file != null) {
File(logFile!).writeAsString(newLog.toString(), mode: FileMode.append); _file!.write(newLog.toString());
} }
if (_logs.length > maxLogNumber) { if (_logs.length > maxLogNumber) {
var res = _logs.remove( var res = _logs.remove(

View File

@@ -1,19 +1,22 @@
import 'dart:async';
import 'package:display_mode/display_mode.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_saf/flutter_saf.dart'; import 'package:flutter_saf/flutter_saf.dart';
import 'package:rhttp/rhttp.dart'; import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.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/js_engine.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/follow_updates_page.dart'; import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.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/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart'; import 'foundation/appdata.dart';
@@ -32,31 +35,62 @@ extension _FutureInit<T> on Future<T> {
} }
Future<void> init() async { Future<void> init() async {
await Rhttp.init();
await SAFTaskWorker().init().wait();
await AppTranslation.init().wait();
await appdata.init().wait();
await App.init().wait(); await App.init().wait();
await HistoryManager().init().wait(); await SingleInstanceCookieJar.createInstance();
await TagsTranslation.readData().wait(); var futures = [
await LocalFavoritesManager().init().wait(); Rhttp.init(),
SingleInstanceCookieJar("${App.dataPath}/cookie.db"); App.initComponents(),
await JsEngine().init().wait(); SAFTaskWorker().init().wait(),
await ComicSource.init().wait(); AppTranslation.init().wait(),
await LocalManager().init().wait(); TagsTranslation.readData().wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
OpenCC.init(),
];
await Future.wait(futures);
CacheManager().setLimitSize(appdata.settings['cacheSize']); CacheManager().setLimitSize(appdata.settings['cacheSize']);
_checkOldConfigs();
if (App.isAndroid) {
handleLinks();
handleTextShare();
try {
await FlutterDisplayMode.setHighRefreshRate();
} catch(e) {
Log.error("Display Mode", "Failed to set high refresh rate: $e");
}
}
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
if (App.isWindows) {
// Report to the monitor thread that the app is running
// https://github.com/venera-app/venera/issues/343
Timer.periodic(const Duration(seconds: 1), (_) {
const methodChannel = MethodChannel('venera/method_channel');
methodChannel.invokeMethod("heartBeat");
});
}
}
void _checkOldConfigs() {
if (appdata.settings['searchSources'] == null) { if (appdata.settings['searchSources'] == null) {
appdata.settings['searchSources'] = ComicSource.all() appdata.settings['searchSources'] = ComicSource.all()
.where((e) => e.searchPageData != null) .where((e) => e.searchPageData != null)
.map((e) => e.key) .map((e) => e.key)
.toList(); .toList();
} }
if (App.isAndroid) {
handleLinks(); 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();
} }
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
} }
Future<void> _checkAppUpdates() async { Future<void> _checkAppUpdates() async {
@@ -69,8 +103,7 @@ Future<void> _checkAppUpdates() async {
appdata.writeImplicitData(); appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate(); ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) { if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300)); await checkUpdateUi(false, true);
await checkUpdateUi(false);
} }
} }

View File

@@ -34,13 +34,16 @@ void main(List<String> args) {
await windowManager.setBackgroundColor(Colors.transparent); await windowManager.setBackgroundColor(Colors.transparent);
} }
await windowManager.setMinimumSize(const Size(500, 600)); 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 placement.applyToWindow();
await windowManager.show(); await windowManager.show();
WindowPlacement.loop();
} }
WindowPlacement.loop();
}); });
} }
}, (error, stack) { }, (error, stack) {
@@ -141,13 +144,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
) { ) {
String? font; String? font;
List<String>? fallback; List<String>? fallback;
if (App.isWindows) { if (App.isLinux || App.isWindows) {
font = 'Segoe UI'; font = 'Noto Sans CJK';
fallback = [ fallback = [
'Segoe UI', 'Segoe UI',
'Noto Sans SC',
'Noto Sans TC',
'Noto Sans',
'Microsoft YaHei', 'Microsoft YaHei',
'PingFang SC', 'PingFang SC',
'Noto Sans CJK',
'Arial', 'Arial',
'sans-serif' 'sans-serif'
]; ];
@@ -199,6 +204,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
'dark' => ThemeMode.dark, 'dark' => ThemeMode.dark,
_ => ThemeMode.system _ => ThemeMode.system
}, },
color: Colors.transparent,
localizationsDelegates: [ localizationsDelegates: [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
@@ -231,6 +237,27 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
); );
}; };
if (widget != null) { 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) {
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); widget = OverlayWidget(widget);
if (App.isDesktop) { if (App.isDesktop) {
widget = Shortcuts( widget = Shortcuts(
@@ -246,6 +273,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
); );
} }
return _SystemUiProvider(Material( return _SystemUiProvider(Material(
color: App.isLinux ? Colors.transparent : null,
child: widget, 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/appdata.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/cache.dart'; import 'package:venera/network/cache.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/network/proxy.dart';
import '../foundation/app.dart'; import '../foundation/app.dart';
import 'cloudflare.dart'; import 'cloudflare.dart';
@@ -96,7 +96,9 @@ class MyLogInterceptor implements Interceptor {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
Log.info("Network", "${options.method} ${options.uri}\n" Log.info(
"Network",
"${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n" "headers:\n${options.headers}\n"
"data:\n${options.data}"); "data:\n${options.data}");
options.connectTimeout = const Duration(seconds: 15); options.connectTimeout = const Duration(seconds: 15);
@@ -107,64 +109,15 @@ class MyLogInterceptor implements Interceptor {
} }
class AppDio with DioMixin { class AppDio with DioMixin {
String? _proxy = proxy;
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings( httpClientAdapter = RHttpAdapter();
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager()); interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor()); interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor()); interceptors.add(MyLogInterceptor());
} }
static String? proxy;
static 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;
}
static final Map<String, bool> _requests = {}; static final Map<String, bool> _requests = {};
@override @override
@@ -184,16 +137,6 @@ class AppDio with DioMixin {
_requests[path] = true; _requests[path] = true;
options!.headers!.remove('prevent-parallel'); 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 { try {
return super.request<T>( return super.request<T>(
path, path,
@@ -213,7 +156,26 @@ class AppDio with DioMixin {
} }
class RHttpAdapter implements HttpClientAdapter { 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,
),
);
}
static Map<String, List<String>> _getOverrides() { static Map<String, List<String>> _getOverrides() {
if (!appdata.settings['enableDnsOverrides'] == true) { if (!appdata.settings['enableDnsOverrides'] == true) {
@@ -231,22 +193,6 @@ class RHttpAdapter implements HttpClientAdapter {
return result; 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 @override
void close({bool force = false}) {} void close({bool force = false}) {}
@@ -256,21 +202,15 @@ class RHttpAdapter implements HttpClientAdapter {
Stream<Uint8List>? requestStream, Stream<Uint8List>? requestStream,
Future<void>? cancelFuture, Future<void>? cancelFuture,
) async { ) 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( var res = await rhttp.Rhttp.request(
method: switch (options.method) { method: rhttp.HttpMethod(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}'),
},
url: options.uri.toString(), url: options.uri.toString(),
settings: settings, settings: await settings,
expectBody: rhttp.HttpExpectBody.stream, expectBody: rhttp.HttpExpectBody.stream,
body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream), body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream),
headers: rhttp.HttpHeaders.rawMap( headers: rhttp.HttpHeaders.rawMap(
@@ -293,9 +233,29 @@ class RHttpAdapter implements HttpClientAdapter {
return ResponseBody( return ResponseBody(
res.body, res.body,
res.statusCode, res.statusCode,
statusMessage: null, statusMessage: _getStatusMessage(res.statusCode),
isRedirect: false, isRedirect: false,
headers: headers, 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 { class CloudflareException implements DioException {
final String url; final String url;
const CloudflareException(this.url); CloudflareException(this.url);
@override @override
String toString() { String toString() {
@@ -55,6 +55,9 @@ class CloudflareException implements DioException {
@override @override
DioExceptionType get type => DioExceptionType.badResponse; DioExceptionType get type => DioExceptionType.badResponse;
@override
DioExceptionReadableStringBuilder? stringBuilder;
} }
class CloudflareInterceptor extends Interceptor { class CloudflareInterceptor extends Interceptor {

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:flutter_qjs/flutter_qjs.dart';
@@ -8,7 +9,7 @@ import 'package:venera/utils/image.dart';
import 'app_dio.dart'; import 'app_dio.dart';
class ImageDownloader { abstract class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail( static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey, String url, String? sourceKey,
[String? cid]) async* { [String? cid]) async* {
@@ -82,7 +83,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( 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* { String imageKey, String? sourceKey, String cid, String eid) async* {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
final cache = await CacheManager().findCache(cacheKey); final cache = await CacheManager().findCache(cacheKey);
@@ -139,13 +173,11 @@ class ImageDownloader {
var buffer = <int>[]; var buffer = <int>[];
await for (var data in stream) { await for (var data in stream) {
buffer.addAll(data); buffer.addAll(data);
if (expectedBytes != null) {
yield ImageDownloadProgress( yield ImageDownloadProgress(
currentBytes: buffer.length, currentBytes: buffer.length,
totalBytes: expectedBytes, totalBytes: expectedBytes,
); );
} }
}
if (configs['onResponse'] is JSInvokable) { if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]); buffer = (configs['onResponse'] as JSInvokable)([buffer]);
@@ -191,10 +223,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 { class ImageDownloadProgress {
final int currentBytes; final int currentBytes;
final int totalBytes; final int? totalBytes;
final Uint8List? imageBytes; 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

@@ -90,7 +90,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
bool isLoading = true; bool isLoading = true;
static const _kComicHeight = 132.0; static const _kComicHeight = 162.0;
get _comicWidth => _kComicHeight * 0.7; get _comicWidth => _kComicHeight * 0.7;
@@ -152,7 +152,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
} }
Widget buildComic(Comic c) { Widget buildComic(Comic c) {
return SimpleComicTile(comic: c) return SimpleComicTile(comic: c, withTitle: true)
.paddingLeft(_kLeftPadding) .paddingLeft(_kLeftPadding)
.paddingBottom(2); .paddingBottom(2);
} }

View File

@@ -4,12 +4,10 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/pages/ranking_page.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/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'category_comics_page.dart';
import 'comic_source_page.dart'; import 'comic_source_page.dart';
class CategoriesPage extends StatefulWidget { class CategoriesPage extends StatefulWidget {
@@ -147,43 +145,6 @@ class _CategoryPage extends StatelessWidget {
return ""; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var children = <Widget>[]; var children = <Widget>[];
@@ -194,11 +155,11 @@ class _CategoryPage extends StatelessWidget {
child: Wrap( child: Wrap(
children: [ children: [
if (data.enableRankingPage) if (data.enableRankingPage)
buildTag("Ranking".tl, (p0, p1) { buildTag("Ranking".tl, () {
context.to(() => RankingPage(categoryKey: data.key)); context.to(() => RankingPage(categoryKey: data.key));
}), }),
for (var buttonData in data.buttons) 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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
buildTitleWithRefresh(part.title, () => updater(() {})), buildTitleWithRefresh(part.title, () => updater(() {})),
buildTagsWithParams( buildTags(part.categories)
part.categories,
part.categoryParams,
part.title,
(key, param) => handleClick(
key,
param,
part.categoryType,
part.title,
category,
),
)
], ],
); );
})); }));
} else { } else {
children.add(buildTitle(part.title)); children.add(buildTitle(part.title));
children.add( children.add(
buildTagsWithParams( buildTags(part.categories),
part.categories,
part.categoryParams,
part.title,
(tag, param) => handleClick(
tag,
param,
part.categoryType,
part.title,
data.key,
),
),
); );
} }
} }
@@ -280,30 +219,28 @@ class _CategoryPage extends StatelessWidget {
); );
} }
Widget buildTagsWithParams( Widget buildTags(
List<String> tags, List<CategoryItem> categories,
List<String>? params,
String? namespace,
ClickTagCallback onClick,
) { ) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16), padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
child: Wrap( child: Wrap(
children: List<Widget>.generate( children: List<Widget>.generate(
tags.length, categories.length,
(index) => buildTag( (index) => buildCategory(categories[index]),
tags[index],
onClick,
namespace,
params?.elementAtOrNull(index),
),
), ),
), ),
); );
} }
Widget buildTag(String tag, ClickTagCallback onClick, Widget buildCategory(CategoryItem c) {
[String? namespace, String? param]) { return buildTag(c.label, () {
var context = App.mainNavigatorKey!.currentContext!;
c.target.jump(context);
});
}
Widget buildTag(String label, VoidCallback onClick) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Builder( child: Builder(
@@ -313,10 +250,10 @@ class _CategoryPage extends StatelessWidget {
color: context.colorScheme.primaryContainer.toOpacity(0.72), color: context.colorScheme.primaryContainer.toOpacity(0.72),
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () => onClick(tag, param), onTap: onClick,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), 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, required this.category,
this.param, this.param,
required this.categoryKey, required this.categoryKey,
this.options,
super.key, super.key,
}); });
@@ -18,6 +19,8 @@ class CategoryComicsPage extends StatefulWidget {
final String categoryKey; final String categoryKey;
final List<String>? options;
@override @override
State<CategoryComicsPage> createState() => _CategoryComicsPageState(); State<CategoryComicsPage> createState() => _CategoryComicsPageState();
} }
@@ -31,6 +34,9 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
void findData() { void findData() {
for (final source in ComicSource.all()) { for (final source in ComicSource.all()) {
if (source.categoryData?.key == widget.categoryKey) { if (source.categoryData?.key == widget.categoryKey) {
if (source.categoryComicsData == null) {
throw "The comic source ${source.name} does not support category comics";
}
data = source.categoryComicsData!; data = source.categoryComicsData!;
options = data.options.where((element) { options = data.options.where((element) {
if (element.notShowWhen.contains(widget.category)) { if (element.notShowWhen.contains(widget.category)) {
@@ -40,7 +46,16 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
} }
return true; return true;
}).toList(); }).toList();
optionsValue = options.map((e) => e.options.keys.first).toList(); var defaultOptionsValue =
options.map((e) => e.options.keys.first).toList();
if (optionsValue.length != options.length) {
var newOptionsValue = List<String>.filled(options.length, "");
for (var i = 0; i < options.length; i++) {
newOptionsValue[i] =
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
}
optionsValue = newOptionsValue;
}
sourceKey = source.key; sourceKey = source.key;
return; return;
} }
@@ -50,6 +65,11 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
@override @override
void initState() { void initState() {
if (widget.options != null) {
optionsValue = widget.options!;
} else {
optionsValue = [];
}
findData(); findData();
super.initState(); super.initState();
} }

View File

@@ -95,7 +95,9 @@ abstract mixin class _ComicPageActions {
/// [ep] the episode number, start from 1 /// [ep] the episode number, start from 1
/// ///
/// [page] the page number, start from 1 /// [page] the page number, start from 1
void read([int? ep, int? page]) { ///
/// [group] the chapter group number, start from 1
void read([int? ep, int? page, int? group]) {
App.rootContext App.rootContext
.to( .to(
() => Reader( () => Reader(
@@ -105,6 +107,7 @@ abstract mixin class _ComicPageActions {
chapters: comic.chapters, chapters: comic.chapters,
initialChapter: ep, initialChapter: ep,
initialPage: page, initialPage: page,
initialChapterGroup: group,
history: history ?? History.fromModel(model: comic, ep: 0, page: 0), history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '', author: comic.findAuthor() ?? '',
tags: comic.plainTags, tags: comic.plainTags,
@@ -118,7 +121,8 @@ abstract mixin class _ComicPageActions {
void continueRead() { void continueRead() {
var ep = history?.ep ?? 1; var ep = history?.ep ?? 1;
var page = history?.page ?? 1; var page = history?.page ?? 1;
read(ep, page); var group = history?.group ?? 1;
read(ep, page, group);
} }
void onReadEnd(); void onReadEnd();
@@ -262,7 +266,7 @@ abstract mixin class _ComicPageActions {
if (localComic != null) { if (localComic != null) {
for (int i = 0; i < comic.chapters!.length; i++) { for (int i = 0; i < comic.chapters!.length; i++) {
if (localComic.downloadedChapters if (localComic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(i))) { .contains(comic.chapters!.ids.elementAt(i))) {
downloaded.add(i); downloaded.add(i);
} }
} }
@@ -270,7 +274,7 @@ abstract mixin class _ComicPageActions {
await showSideBar( await showSideBar(
App.rootContext, App.rootContext,
_SelectDownloadChapter( _SelectDownloadChapter(
comic.chapters!.values.toList(), comic.chapters!.titles.toList(),
(v) => selected = v, (v) => selected = v,
downloaded, downloaded,
), ),
@@ -281,7 +285,7 @@ abstract mixin class _ComicPageActions {
comicId: comic.id, comicId: comic.id,
comic: comic, comic: comic,
chapters: selected!.map((i) { chapters: selected!.map((i) {
return comic.chapters!.keys.elementAt(i); return comic.chapters!.ids.elementAt(i);
}).toList(), }).toList(),
)); ));
} }
@@ -290,27 +294,9 @@ abstract mixin class _ComicPageActions {
} }
void onTapTag(String tag, String namespace) { void onTapTag(String tag, String namespace) {
var config = comicSource.handleClickTagEvent?.call(namespace, tag) ?? var target = comicSource.handleClickTagEvent?.call(namespace, tag);
{
'action': 'search',
'keyword': tag,
};
var context = App.mainNavigatorKey!.currentContext!; var context = App.mainNavigatorKey!.currentContext!;
if (config['action'] == 'search') { target?.jump(context);
context.to(() => SearchResultPage(
text: config['keyword'] ?? '',
sourceKey: comicSource.key,
options: const [],
));
} else if (config['action'] == 'category') {
context.to(
() => CategoryComicsPage(
category: config['keyword'] ?? '',
categoryKey: comicSource.categoryData!.key,
param: config['param'],
),
);
}
} }
void showMoreActions() { void showMoreActions() {

View File

@@ -27,17 +27,18 @@ class _NormalComicChapters extends StatefulWidget {
class _NormalComicChaptersState extends State<_NormalComicChapters> { class _NormalComicChaptersState extends State<_NormalComicChapters> {
late _ComicPageState state; late _ComicPageState state;
bool reverse = false; late bool reverse;
bool showAll = false; bool showAll = false;
late History? history; late History? history;
late Map<String, String> chapters; late ComicChapters chapters;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history; history = widget.history;
} }
@@ -101,11 +102,11 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
if (reverse) { if (reverse) {
i = chapters.length - i - 1; i = chapters.length - i - 1;
} }
var key = chapters.keys.elementAt(i); var key = chapters.ids.elementAt(i);
var value = chapters[key]!; var value = chapters[key]!;
bool visited = (history?.readEpisode ?? {}).contains(i + 1); bool visited = (history?.readEpisode ?? {}).contains(i + 1);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Material( child: Material(
color: context.colorScheme.surfaceContainer, color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -113,7 +114,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
onTap: () => state.read(i + 1), onTap: () => state.read(i + 1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center( child: Center(
child: Text( child: Text(
value, value,
@@ -134,7 +135,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
}, },
), ),
gridDelegate: const SliverGridDelegateWithFixedHeight( gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 250,
itemHeight: 48, itemHeight: 48,
), ),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)), ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
@@ -176,30 +177,37 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late _ComicPageState state; late _ComicPageState state;
bool reverse = false; late bool reverse;
bool showAll = false; bool showAll = false;
late History? history; late History? history;
late Map<String, Map<String, String>> chapters; late ComicChapters chapters;
late TabController tabController; late TabController tabController;
int index = 0; late int index;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history; history = widget.history;
if (history?.group != null) {
index = history!.group! - 1;
} else {
index = 0;
}
} }
@override @override
void didChangeDependencies() { void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!; state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.groupedChapters!; chapters = state.comic.chapters!;
tabController = TabController( tabController = TabController(
length: chapters.keys.length, initialIndex: index,
length: chapters.ids.length,
vsync: this, vsync: this,
); );
tabController.addListener(onTabChange); tabController.addListener(onTabChange);
@@ -226,7 +234,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverLayoutBuilder( return SliverLayoutBuilder(
builder: (context, constrains) { builder: (context, constrains) {
var group = chapters.values.elementAt(index); var group = chapters.getGroupByIndex(index);
int length = group.length; int length = group.length;
bool canShowAll = showAll; bool canShowAll = showAll;
if (!showAll) { if (!showAll) {
@@ -265,7 +273,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
child: AppTabBar( child: AppTabBar(
withUnderLine: false, withUnderLine: false,
controller: tabController, controller: tabController,
tabs: chapters.keys.map((e) => Tab(text: e)).toList(), tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
), ),
), ),
SliverPadding(padding: const EdgeInsets.only(top: 8)), SliverPadding(padding: const EdgeInsets.only(top: 8)),
@@ -279,25 +287,30 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
var key = group.keys.elementAt(i); var key = group.keys.elementAt(i);
var value = group[key]!; var value = group[key]!;
var chapterIndex = 0; var chapterIndex = 0;
for (var j = 0; j < chapters.length; j++) { for (var j = 0; j < chapters.groupCount; j++) {
if (j == index) { if (j == index) {
chapterIndex += i; chapterIndex += i;
break; break;
} }
chapterIndex += chapters.values.elementAt(j).length; 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);
} }
bool visited =
(history?.readEpisode ?? {}).contains(chapterIndex + 1);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Material( child: Material(
color: context.colorScheme.surfaceContainer, color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
child: InkWell( child: InkWell(
onTap: () => state.read(chapterIndex + 1), onTap: () => state.read(chapterIndex + 1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center( child: Center(
child: Text( child: Text(
value, value,
@@ -318,7 +331,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
}, },
), ),
gridDelegate: const SliverGridDelegateWithFixedHeight( gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 250,
itemHeight: 48, itemHeight: 48,
), ),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)), ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),

View File

@@ -17,10 +17,8 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/category_comics_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -75,6 +73,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
bool isDownloaded = false; bool isDownloaded = false;
bool showFAB = false;
@override @override
void onReadEnd() { void onReadEnd() {
history ??= history ??=
@@ -114,7 +114,15 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ComicDetails get comic => data!; ComicDetails get comic => data!;
void onScroll() { void onScroll() {
if (scrollController.offset > 100) { var offset = scrollController.position.pixels -
scrollController.position.minScrollExtent;
var showFAB = offset > 0;
if (showFAB != this.showFAB) {
setState(() {
this.showFAB = showFAB;
});
}
if (offset > 100) {
if (!showAppbarTitle) { if (!showAppbarTitle) {
setState(() { setState(() {
showAppbarTitle = true; showAppbarTitle = true;
@@ -133,7 +141,18 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override @override
Widget buildContent(BuildContext context, ComicDetails data) { Widget buildContent(BuildContext context, ComicDetails data) {
return SmoothCustomScrollView( return Scaffold(
floatingActionButton: showFAB
? FloatingActionButton(
onPressed: () {
scrollController.animateTo(0,
duration: const Duration(milliseconds: 200),
curve: Curves.ease);
},
child: const Icon(Icons.arrow_upward),
)
: null,
body: SmoothCustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
...buildTitle(), ...buildTitle(),
@@ -144,8 +163,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildComments(), buildComments(),
buildThumbnails(), buildThumbnails(),
buildRecommend(), buildRecommend(),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), SliverPadding(
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB
),
], ],
),
); );
} }
@@ -165,6 +187,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
cid: widget.id, cid: widget.id,
name: localComic.title, name: localComic.title,
chapters: localComic.chapters, chapters: localComic.chapters,
initialPage: history?.page,
initialChapter: history?.ep,
initialChapterGroup: history?.group,
history: history ?? history: history ??
History.fromModel( History.fromModel(
model: localComic, model: localComic,
@@ -369,7 +394,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow, color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(24),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -381,16 +406,32 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
bool haveChapter = comic.chapters != null; bool haveChapter = comic.chapters != null;
var page = history!.page; var page = history!.page;
var ep = history!.ep; var ep = history!.ep;
var group = history!.group;
String text; String text;
if (haveChapter) { if (haveChapter) {
text = "Last Reading: Chapter @ep Page @page".tlParams({ var epName = "E$ep";
'ep': ep, String? groupName;
'page': page, try {
}); if (group == null){
epName = comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
);
} else { } else {
text = "Last Reading: Page @page".tlParams({ groupName = comic.chapters!.groups.elementAt(group - 1);
'page': page, epName = comic.chapters!
}); .getGroupByIndex(group - 1)
.values
.elementAt(ep - 1);
}
}
catch(e) {
// ignore
}
text = groupName == null
? "${"Last Reading".tl}: $epName P$page"
: "${"Last Reading".tl}: $groupName $epName P$page";
} else {
text = "${"Last Reading".tl}: P$page";
} }
return Text(text); return Text(text);
}, },
@@ -430,7 +471,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.tags.isEmpty && if (comic.tags.isEmpty &&
comic.uploader == null && comic.uploader == null &&
comic.uploadTime == null && comic.uploadTime == null &&
comic.uploadTime == null) { comic.uploadTime == null &&
comic.maxPage == null) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
@@ -594,6 +636,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildTag(text: formatTime(comic.updateTime!)), buildTag(text: formatTime(comic.updateTime!)),
], ],
), ),
if (comic.maxPage != null)
buildWrap(
children: [
buildTag(text: 'Pages'.tl, isTitle: true),
buildTag(text: comic.maxPage.toString()),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
const Divider(), const Divider(),
], ],
@@ -607,7 +656,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
} }
return _ComicChapters( return _ComicChapters(
history: history, history: history,
groupedMode: comic.groupedChapters != null, groupedMode: comic.chapters!.isGrouped,
); );
} }

View File

@@ -99,7 +99,11 @@ class _CommentsPageState extends State<CommentsPage> {
return Column( return Column(
children: [ children: [
Expanded( Expanded(
child: ListView.builder( child: SmoothScrollProvider(
builder: (context, controller, physics) {
return ListView.builder(
controller: controller,
physics: physics,
primary: false, primary: false,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemCount: _comments!.length + 2, itemCount: _comments!.length + 2,
@@ -156,6 +160,8 @@ class _CommentsPageState extends State<CommentsPage> {
showAvatar: showAvatar, showAvatar: showAvatar,
); );
}, },
);
},
), ),
), ),
buildBottom(context) buildBottom(context)
@@ -651,10 +657,16 @@ class _CommentImage {
} }
class RichCommentContent extends StatefulWidget { 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 String text;
final bool showImages;
@override @override
State<RichCommentContent> createState() => _RichCommentContentState(); State<RichCommentContent> createState() => _RichCommentContentState();
} }
@@ -808,7 +820,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
children: textSpan, children: textSpan,
), ),
); );
if (images.isNotEmpty) { if (images.isNotEmpty && widget.showImages) {
content = Column( content = Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

View File

@@ -138,7 +138,10 @@ class _CommentWidget extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Expanded( Expanded(
child: RichCommentContent(text: comment.content).fixWidth(324), child: RichCommentContent(
text: comment.content,
showImages: false,
).fixWidth(324),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (comment.time != null) if (comment.time != null)

View File

@@ -40,19 +40,18 @@ class ComicSourcePage extends StatelessWidget {
} }
} }
if (shouldUpdate.isNotEmpty) { if (shouldUpdate.isNotEmpty) {
var updates = <String, String>{};
for (var key in shouldUpdate) { for (var key in shouldUpdate) {
ComicSource.availableUpdates[key] = versions[key]!; updates[key] = versions[key]!;
} }
ComicSource.notifyListeners(); ComicSourceManager().updateAvailableUpdates(updates);
} }
return shouldUpdate.length; return shouldUpdate.length;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(body: const _Body());
body: const _Body(),
);
} }
} }
@@ -73,23 +72,20 @@ class _BodyState extends State<_Body> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
ComicSource.addListener(updateUI); ComicSourceManager().addListener(updateUI);
} }
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
ComicSource.removeListener(updateUI); ComicSourceManager().removeListener(updateUI);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SmoothCustomScrollView( return SmoothCustomScrollView(
slivers: [ slivers: [
SliverAppbar( SliverAppbar(title: Text('Comic Source'.tl), style: AppbarStyle.shadow),
title: Text('Comic Source'.tl),
style: AppbarStyle.shadow,
),
buildCard(context), buildCard(context),
for (var source in ComicSource.all()) for (var source in ComicSource.all())
_SliverComicSource( _SliverComicSource(
@@ -108,14 +104,12 @@ class _BodyState extends State<_Body> {
showConfirmDialog( showConfirmDialog(
context: App.rootContext, context: App.rootContext,
title: "Delete".tl, title: "Delete".tl,
content: "Delete comic source '@n' ?".tlParams({ content: "Delete comic source '@n' ?".tlParams({"n": source.name}),
"n": source.name,
}),
btnColor: context.colorScheme.error, btnColor: context.colorScheme.error,
onConfirm: () { onConfirm: () {
var file = File(source.filePath); var file = File(source.filePath);
file.delete(); file.delete();
ComicSource.remove(source.key); ComicSourceManager().remove(source.key);
_validatePages(); _validatePages();
App.forceRebuild(); App.forceRebuild();
}, },
@@ -133,13 +127,15 @@ class _BodyState extends State<_Body> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text("cancel")), child: const Text("cancel"),
),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await ComicSource.reload(); await ComicSourceManager().reload();
App.forceRebuild(); App.forceRebuild();
}, },
child: const Text("continue")), child: const Text("continue"),
),
], ],
), ),
); );
@@ -150,19 +146,21 @@ class _BodyState extends State<_Body> {
} }
context.to( context.to(
() => _EditFilePage(source.filePath, () async { () => _EditFilePage(source.filePath, () async {
await ComicSource.reload(); await ComicSourceManager().reload();
setState(() {}); setState(() {});
}), }),
); );
} }
static Future<void> update(ComicSource source, static Future<void> update(
[bool showLoading = true]) async { ComicSource source, [
bool showLoading = true,
]) async {
if (!source.url.isURL) { if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config"); App.rootContext.showMessage(message: "Invalid url config");
return; return;
} }
ComicSource.remove(source.key); ComicSourceManager().remove(source.key);
bool cancel = false; bool cancel = false;
LoadingDialogController? controller; LoadingDialogController? controller;
if (showLoading) { if (showLoading) {
@@ -173,32 +171,26 @@ class _BodyState extends State<_Body> {
); );
} }
try { try {
var res = await AppDio().get<String>(source.url, var res = await AppDio().get<String>(
options: Options(responseType: ResponseType.plain)); source.url,
options: Options(responseType: ResponseType.plain),
);
if (cancel) return; if (cancel) return;
controller?.close(); controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath); await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!); await File(source.filePath).writeAsString(res.data!);
if (ComicSource.availableUpdates.containsKey(source.key)) { if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
ComicSource.availableUpdates.remove(source.key); ComicSourceManager().availableUpdates.remove(source.key);
} }
} catch (e) { } catch (e) {
if (cancel) return; if (cancel) return;
App.rootContext.showMessage(message: e.toString()); App.rootContext.showMessage(message: e.toString());
} }
await ComicSource.reload(); await ComicSourceManager().reload();
App.forceRebuild(); App.forceRebuild();
} }
Widget buildCard(BuildContext context) { Widget buildCard(BuildContext context) {
Widget buildButton(
{required Widget child, required VoidCallback onPressed}) {
return Button.normal(
onPressed: onPressed,
child: child,
).fixHeight(32);
}
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
@@ -217,16 +209,21 @@ class _BodyState extends State<_Body> {
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton( suffix: IconButton(
onPressed: () => handleAddSource(url), onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check))), icon: const Icon(Icons.check),
),
),
onChanged: (value) { onChanged: (value) {
url = value; url = value;
}, },
onSubmitted: handleAddSource, onSubmitted: handleAddSource,
).paddingHorizontal(16).paddingBottom(8), ).paddingHorizontal(16).paddingBottom(8),
ListTile( Wrap(
title: Text("Comic Source list".tl), spacing: 8,
trailing: buildButton( runSpacing: 8,
child: Text("View".tl), children: [
FilledButton.tonalIcon(
icon: Icon(Icons.article_outlined),
label: Text("Comic Source list".tl),
onPressed: () { onPressed: () {
showPopUpWidget( showPopUpWidget(
App.rootContext, App.rootContext,
@@ -234,25 +231,19 @@ class _BodyState extends State<_Body> {
); );
}, },
), ),
), FilledButton.tonalIcon(
ListTile( icon: Icon(Icons.file_open_outlined),
title: Text("Use a config file".tl), label: Text("Use a config file".tl),
trailing: buildButton(
onPressed: _selectFile, onPressed: _selectFile,
child: Text("Select".tl),
), ),
), FilledButton.tonalIcon(
ListTile( icon: Icon(Icons.help_outline),
title: Text("Help".tl), label: Text("Help".tl),
trailing: buildButton(
onPressed: help, onPressed: help,
child: Text("Open".tl),
),
),
ListTile(
title: Text("Check updates".tl),
trailing: _CheckUpdatesButton(),
), ),
_CheckUpdatesButton(),
],
).paddingHorizontal(12).paddingVertical(8),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),
@@ -276,7 +267,8 @@ class _BodyState extends State<_Body> {
void help() { void help() {
launchUrlString( 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 { Future<void> handleAddSource(String url) async {
@@ -287,11 +279,16 @@ class _BodyState extends State<_Body> {
splits.removeWhere((element) => element == ""); splits.removeWhere((element) => element == "");
var fileName = splits.last; var fileName = splits.last;
bool cancel = false; bool cancel = false;
var controller = showLoadingDialog(App.rootContext, var controller = showLoadingDialog(
onCancel: () => cancel = true, barrierDismissible: false); App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
try { try {
var res = await AppDio() var res = await AppDio().get<String>(
.get<String>(url, options: Options(responseType: ResponseType.plain)); url,
options: Options(responseType: ResponseType.plain),
);
if (cancel) return; if (cancel) return;
controller.close(); controller.close();
await addSource(res.data!, fileName); await addSource(res.data!, fileName);
@@ -304,7 +301,7 @@ class _BodyState extends State<_Body> {
Future<void> addSource(String js, String fileName) async { Future<void> addSource(String js, String fileName) async {
var comicSource = await ComicSourceParser().createAndParse(js, fileName); var comicSource = await ComicSourceParser().createAndParse(js, fileName);
ComicSource.add(comicSource); ComicSourceManager().add(comicSource);
_addAllPagesWithComicSource(comicSource); _addAllPagesWithComicSource(comicSource);
appdata.saveData(); appdata.saveData();
App.forceRebuild(); App.forceRebuild();
@@ -321,60 +318,139 @@ class _ComicSourceList extends StatefulWidget {
} }
class _ComicSourceListState extends State<_ComicSourceList> { class _ComicSourceListState extends State<_ComicSourceList> {
bool loading = true;
List? json; List? json;
bool changed = false;
var controller = TextEditingController();
void load() async { void load() async {
var dio = AppDio(); if (json != null) {
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']); setState(() {
if (res.statusCode != 200) { json = null;
context.showMessage(message: "Network error".tl); });
}
if (controller.text.isEmpty) {
setState(() {
json = [];
});
return; return;
} }
var dio = AppDio();
try {
var res = await dio.get<String>(controller.text);
if (res.statusCode != 200) {
throw "error";
}
if (mounted) {
setState(() { setState(() {
json = jsonDecode(res.data!); json = jsonDecode(res.data!);
loading = false;
}); });
} }
} 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopUpWidgetScaffold( return PopUpWidgetScaffold(title: "Comic Source".tl, body: buildBody());
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(),
);
} }
Widget buildBody() { Widget buildBody() {
if (loading) {
load();
return const Center(child: CircularProgressIndicator());
} else {
var currentKey = ComicSource.all().map((e) => e.key).toList(); var currentKey = ComicSource.all().map((e) => e.key).toList();
return ListView.builder( return ListView.builder(
itemCount: json!.length, itemCount: (json?.length ?? 1) + 1,
itemBuilder: (context, index) { 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 key = json![index]["key"];
var action = currentKey.contains(key) var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8) ? const Icon(Icons.check, size: 20).paddingRight(8)
@@ -402,16 +478,20 @@ class _ComicSourceListState extends State<_ComicSourceList> {
}, },
).fixHeight(32); ).fixHeight(32);
var description = json![index]["version"];
if (json![index]["description"] != null) {
description = "$description\n${json![index]["description"]}";
}
return ListTile( return ListTile(
title: Text(json![index]["name"]), title: Text(json![index]["name"]),
subtitle: Text(json![index]["version"]), subtitle: Text(description),
trailing: action, trailing: action,
); );
}, },
); );
} }
} }
}
void _validatePages() { void _validatePages() {
List explorePages = appdata.settings['explore_pages']; List explorePages = appdata.settings['explore_pages'];
@@ -460,6 +540,7 @@ void _addAllPagesWithComicSource(ComicSource source) {
var explorePages = appdata.settings['explore_pages']; var explorePages = appdata.settings['explore_pages'];
var categoryPages = appdata.settings['categories']; var categoryPages = appdata.settings['categories'];
var networkFavorites = appdata.settings['favorites']; var networkFavorites = appdata.settings['favorites'];
var searchPages = appdata.settings['searchSources'];
if (source.explorePages.isNotEmpty) { if (source.explorePages.isNotEmpty) {
for (var page in source.explorePages) { for (var page in source.explorePages) {
@@ -476,10 +557,14 @@ void _addAllPagesWithComicSource(ComicSource source) {
!networkFavorites.contains(source.favoriteData!.key)) { !networkFavorites.contains(source.favoriteData!.key)) {
networkFavorites.add(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['explore_pages'] = explorePages.toSet().toList();
appdata.settings['categories'] = categoryPages.toSet().toList(); appdata.settings['categories'] = categoryPages.toSet().toList();
appdata.settings['favorites'] = networkFavorites.toSet().toList(); appdata.settings['favorites'] = networkFavorites.toSet().toList();
appdata.settings['searchSources'] = searchPages.toSet().toList();
appdata.saveData(); appdata.saveData();
} }
@@ -514,15 +599,10 @@ class __EditFilePageState extends State<_EditFilePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: Appbar( appBar: Appbar(title: Text("Edit".tl)),
title: Text("Edit".tl),
),
body: Column( body: Column(
children: [ children: [
Container( Container(height: 0.6, color: context.colorScheme.outlineVariant),
height: 0.6,
color: context.colorScheme.outlineVariant,
),
Expanded( Expanded(
child: CodeEditor( child: CodeEditor(
initialValue: current, initialValue: current,
@@ -563,9 +643,11 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
} }
void showUpdateDialog() async { void showUpdateDialog() async {
var text = ComicSource.availableUpdates.entries.map((e) { var text = ComicSourceManager().availableUpdates.entries
.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}"; return "${ComicSource.find(e.key)!.name}: ${e.value}";
}).join("\n"); })
.join("\n");
bool doUpdate = false; bool doUpdate = false;
await showDialog( await showDialog(
context: App.rootContext, context: App.rootContext,
@@ -592,9 +674,9 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
withProgress: true, withProgress: true,
); );
int current = 0; int current = 0;
int total = ComicSource.availableUpdates.length; int total = ComicSourceManager().availableUpdates.length;
try { try {
var shouldUpdate = ComicSource.availableUpdates.keys.toList(); var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
for (var key in shouldUpdate) { for (var key in shouldUpdate) {
var source = ComicSource.find(key)!; var source = ComicSource.find(key)!;
await _BodyState.update(source, false); await _BodyState.update(source, false);
@@ -610,11 +692,15 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
@override @override
Widget build(BuildContext context) { 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, onPressed: check,
isLoading: isLoading, );
child: Text("Check".tl),
).fixHeight(32);
} }
} }
@@ -692,7 +778,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var newVersion = ComicSource.availableUpdates[source.key]; var newVersion = ComicSourceManager().availableUpdates[source.key];
bool hasUpdate = bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version); newVersion != null && compareSemVer(newVersion, source.version);
@@ -703,10 +789,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
child: ListTile( child: ListTile(
title: Row( title: Row(
children: [ children: [
Text( Text(source.name, style: ts.s18),
source.name,
style: ts.s18,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -739,7 +822,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ),
).paddingLeft(4) ).paddingLeft(4),
], ],
), ),
trailing: Row( trailing: Row(
@@ -784,15 +867,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(children: buildSourceSettings().toList()),
children: buildSourceSettings().toList(),
),
),
SliverToBoxAdapter(
child: Column(
children: _buildAccount().toList(),
),
), ),
SliverToBoxAdapter(child: Column(children: _buildAccount().toList())),
], ],
); );
} }
@@ -818,8 +895,10 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
} }
} }
} else { } else {
current = item.value['options'] current =
.firstWhere((e) => e['value'] == current)['text'] ?? item.value['options'].firstWhere(
(e) => e['value'] == current,
)['text'] ??
current; current;
} }
yield ListTile( yield ListTile(
@@ -827,8 +906,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: Select( trailing: Select(
current: (current as String).ts(source.key), current: (current as String).ts(source.key),
values: (item.value['options'] as List) values: (item.value['options'] as List)
.map<String>((e) => .map<String>(
((e['text'] ?? e['value']) as String).ts(source.key)) (e) => ((e['text'] ?? e['value']) as String).ts(source.key),
)
.toList(), .toList(),
onTap: (i) { onTap: (i) {
source.data['settings'][key] = source.data['settings'][key] =
@@ -856,8 +936,11 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
source.data['settings'][key] ?? item.value['default'] ?? ''; source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile( yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)), title: Text((item.value['title'] as String).ts(source.key)),
subtitle: subtitle: Text(
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis), current,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () { onPressed: () {
@@ -898,10 +981,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () async { onTap: () async {
await context.to( await context.to(
() => _LoginPage( () => _LoginPage(config: source.account!, source: source),
config: source.account!,
source: source,
),
); );
source.saveData(); source.saveData();
setState(() {}); setState(() {});
@@ -947,9 +1027,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: loading trailing: loading
? const SizedBox.square( ? const SizedBox.square(
dimension: 24, dimension: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(strokeWidth: 2),
strokeWidth: 2,
),
) )
: const Icon(Icons.refresh), : const Icon(Icons.refresh),
); );
@@ -960,7 +1038,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
source.data["account"] = null; source.data["account"] = null;
source.account?.logout(); source.account?.logout();
source.saveData(); source.saveData();
ComicSource.notifyListeners(); ComicSourceManager().notifyStateChange();
setState(() {}); setState(() {});
}, },
trailing: const Icon(Icons.logout), trailing: const Icon(Icons.logout),
@@ -990,9 +1068,7 @@ class _LoginPageState extends State<_LoginPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: const Appbar( appBar: const Appbar(title: Text('')),
title: Text(''),
),
body: Center( body: Center(
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -1120,8 +1196,9 @@ class _LoginPageState extends State<_LoginPage> {
setState(() { setState(() {
loading = true; loading = true;
}); });
var cookies = var cookies = widget.config.cookieFields!
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList(); .map((e) => _cookies[e] ?? '')
.toList();
widget.config.validateCookies!(cookies).then((value) { widget.config.validateCookies!(cookies).then((value) {
if (value) { if (value) {
widget.source.data['account'] = 'ok'; widget.source.data['account'] = 'ok';

View File

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

View File

@@ -6,13 +6,10 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/global_state.dart'; import 'package:venera/foundation/global_state.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/pages/comic_source_page.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/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'category_comics_page.dart';
class ExplorePage extends StatefulWidget { class ExplorePage extends StatefulWidget {
const ExplorePage({super.key}); const ExplorePage({super.key});
@@ -445,30 +442,7 @@ Iterable<Widget> _buildExplorePagePart(
TextButton( TextButton(
onPressed: () { onPressed: () {
var context = App.mainNavigatorKey!.currentContext!; var context = App.mainNavigatorKey!.currentContext!;
if (part.viewMore!.startsWith("search:")) { part.viewMore!.jump(context);
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,
),
);
}
}, },
child: Text("View more".tl), child: Text("View more".tl),
) )

View File

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

View File

@@ -18,14 +18,16 @@ import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.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/io.dart';
import 'package:venera/utils/opencc.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
part 'favorite_actions.dart'; part 'favorite_actions.dart';
part 'side_bar.dart'; part 'side_bar.dart';
part 'local_favorites_page.dart'; part 'local_favorites_page.dart';
part 'network_favorites_page.dart'; part 'network_favorites_page.dart';
part 'local_search_page.dart';
const _kLeftBarWidth = 256.0; const _kLeftBarWidth = 256.0;
@@ -65,6 +67,11 @@ class _FavoritesPageState extends State<FavoritesPage> {
folder = data['name']; folder = data['name'];
isNetwork = data['isNetwork'] ?? false; isNetwork = data['isNetwork'] ?? false;
} }
if (folder != null
&& !isNetwork
&& !LocalFavoritesManager().existsFolder(folder!)) {
folder = null;
}
super.initState(); super.initState();
} }

View File

@@ -1,5 +1,11 @@
part of 'favorites_page.dart'; 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 { class _LocalFavoritesPage extends StatefulWidget {
const _LocalFavoritesPage({required this.folder, super.key}); const _LocalFavoritesPage({required this.folder, super.key});
@@ -31,25 +37,132 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
int? lastSelectedIndex; int? lastSelectedIndex;
void updateComics() { bool get isAllFolder => widget.folder == _localAllFolderLabel;
if (keyword.isEmpty) {
LocalFavoritesManager get manager => LocalFavoritesManager();
bool isLoading = false;
var searchResults = <FavoriteItem>[];
void updateSearchResult() {
setState(() { setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder); if (keyword.trim().isEmpty) {
}); searchResults = comics;
} else { } else {
setState(() { searchResults = [];
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword); for (var comic in comics) {
if (matchKeyword(keyword, comic) ||
matchKeywordT(keyword, comic) ||
matchKeywordS(keyword, comic)) {
searchResults.add(comic);
}
}
}
}); });
} }
void updateComics() {
if (isLoading) return;
if (isAllFolder) {
var totalComics = manager.totalComics;
if (totalComics < _asyncDataFetchLimit) {
comics = manager.getAllComics();
} else {
isLoading = true;
manager
.getAllComicsAsync()
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
} else {
var folderComics = manager.folderComics(widget.folder);
if (folderComics < _asyncDataFetchLimit) {
comics = manager.getFolderComics(widget.folder);
} else {
isLoading = true;
manager
.getFolderComicsAsync(widget.folder)
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
}
setState(() {});
}
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 @override
void initState() { void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
comics = LocalFavoritesManager().getAllComics(widget.folder); if (!isAllFolder) {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder); var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a; networkSource = a;
networkFolder = b; networkFolder = b;
} else {
networkSource = null;
networkFolder = null;
}
comics = [];
updateComics();
LocalFavoritesManager().addListener(updateComics); LocalFavoritesManager().addListener(updateComics);
super.initState(); super.initState();
} }
@@ -62,16 +175,33 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
void selectAll() { void selectAll() {
setState(() { setState(() {
if (searchMode) {
selectedComics = searchResults.asMap().map((k, v) => MapEntry(v, true));
} else {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
}
}); });
} }
void invertSelection() { void invertSelection() {
setState(() { setState(() {
comics.asMap().forEach((k, v) { if (searchMode) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false); for (var c in searchResults) {
}); if (selectedComics.containsKey(c)) {
selectedComics.removeWhere((k, v) => !v); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var title = favPage.folder ?? "Unselected".tl;
if (title == _localAllFolderLabel) {
title = "All".tl;
}
Widget body = SmoothCustomScrollView( Widget body = SmoothCustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
@@ -135,10 +270,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
onTap: context.width < _kTwoPanelChangeWidth onTap: context.width < _kTwoPanelChangeWidth
? favPage.showFolderSelector ? favPage.showFolderSelector
: null, : null,
child: Text(favPage.folder ?? "Unselected".tl), child: Text(title),
), ),
actions: [ actions: [
if (networkSource != null) if (networkSource != null && !isAllFolder)
Tooltip( Tooltip(
message: "Sync".tl, message: "Sync".tl,
child: Flyout( child: Flyout(
@@ -191,11 +326,14 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
setState(() { setState(() {
keyword = "";
searchMode = true; searchMode = true;
updateSearchResult();
}); });
}, },
), ),
), ),
if (!isAllFolder)
MenuButton( MenuButton(
entries: [ entries: [
MenuEntry( MenuEntry(
@@ -220,7 +358,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return null; return null;
}, },
); );
}), },
),
MenuEntry( MenuEntry(
icon: Icons.reorder, icon: Icons.reorder,
text: "Reorder".tl, text: "Reorder".tl,
@@ -241,7 +380,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} }
}, },
); );
}), },
),
MenuEntry( MenuEntry(
icon: Icons.upload_file, icon: Icons.upload_file,
text: "Export".tl, text: "Export".tl,
@@ -253,7 +393,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
data: utf8.encode(json), data: utf8.encode(json),
filename: "${widget.folder}.json", filename: "${widget.folder}.json",
); );
}), },
),
MenuEntry( MenuEntry(
icon: Icons.update, icon: Icons.update,
text: "Update Comics Info".tl, text: "Update Comics Info".tl,
@@ -265,7 +406,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}); });
} }
}); });
}), },
),
MenuEntry( MenuEntry(
icon: Icons.delete_outline, icon: Icons.delete_outline,
text: "Delete Folder".tl, text: "Delete Folder".tl,
@@ -284,7 +426,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
favPage.folderList?.updateFolders(); favPage.folderList?.updateFolders();
}, },
); );
}), },
),
], ],
), ),
], ],
@@ -310,10 +453,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
"Selected @c comics".tlParams({"c": selectedComics.length})), "Selected @c comics".tlParams({"c": selectedComics.length})),
actions: [ actions: [
MenuButton(entries: [ MenuButton(entries: [
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.drive_file_move, icon: Icons.drive_file_move,
text: "Move to folder".tl, text: "Move to folder".tl,
onClick: () => favoriteOption('move')), onClick: () => favoriteOption('move')),
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.copy, icon: Icons.copy,
text: "Copy to folder".tl, text: "Copy to folder".tl,
@@ -330,6 +475,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: Icons.flip, icon: Icons.flip,
text: "Invert Selection".tl, text: "Invert Selection".tl,
onClick: invertSelection), onClick: invertSelection),
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.delete_outline, icon: Icons.delete_outline,
text: "Delete Comic".tl, text: "Delete Comic".tl,
@@ -379,10 +525,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
child: IconButton( child: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () { onPressed: () {
setState(() {
setState(() { setState(() {
searchMode = false; searchMode = false;
keyword = ""; });
updateComics();
}); });
}, },
), ),
@@ -391,19 +537,30 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Search".tl, hintText: "Search".tl,
border: InputBorder.none, border: UnderlineInputBorder(),
), ),
onChanged: (v) { onChanged: (v) {
keyword = v; keyword = v;
updateComics(); updateSearchResult();
}, },
).paddingBottom(8).paddingRight(8),
),
if (isLoading)
SliverToBoxAdapter(
child: SizedBox(
height: 200,
child: const Center(
child: CircularProgressIndicator(),
), ),
), ),
)
else
SliverGridComics( SliverGridComics(
comics: comics, comics: searchMode ? searchResults : comics,
selections: selectedComics, selections: selectedComics,
menuBuilder: (c) { menuBuilder: (c) {
return [ return [
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.delete, icon: Icons.delete,
text: "Delete".tl, text: "Delete".tl,
@@ -518,11 +675,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
), ),
], ],
); );
body = Scrollbar( body = AppScrollBar(
topPadding: 48,
controller: scrollController, controller: scrollController,
thickness: App.isDesktop ? 8 : 12,
radius: const Radius.circular(8),
interactive: true,
child: ScrollConfiguration( child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: body, child: body,
@@ -640,32 +795,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return; return;
} }
if (option == 'move') { if (option == 'move') {
for (var c in selectedComics.keys) { var comics = selectedComics.keys
for (var s in selectedLocalFolders) { .map((e) => e as FavoriteItem)
LocalFavoritesManager().moveFavorite( .toList();
for (var f in selectedLocalFolders) {
LocalFavoritesManager().batchMoveFavorites(
favPage.folder as String, favPage.folder as String,
s, f,
c.id, comics,
(c as FavoriteItem).type);
}
}
} 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 ?? [],
),
); );
} }
} else {
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(); App.rootContext.pop();
@@ -701,13 +850,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} }
void _deleteComicWithId() { void _deleteComicWithId() {
for (var c in selectedComics.keys) { var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList();
LocalFavoritesManager().deleteComicWithId( LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted);
widget.folder,
c.id,
(c as FavoriteItem).type,
);
}
_cancel(); _cancel();
} }
} }
@@ -727,7 +871,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
final _key = GlobalKey(); final _key = GlobalKey();
var reorderWidgetKey = UniqueKey(); var reorderWidgetKey = UniqueKey();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
late var comics = LocalFavoritesManager().getAllComics(widget.name); late var comics = LocalFavoritesManager().getFolderComics(widget.name);
bool changed = false; bool changed = false;
static int _floatToInt8(double x) { static int _floatToInt8(double x) {
@@ -748,7 +892,10 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
@override @override
void dispose() { void dispose() {
if (changed) { if (changed) {
// Delay to ensure navigation is completed
Future.delayed(const Duration(milliseconds: 200), () {
LocalFavoritesManager().reorder(comics, widget.name); LocalFavoritesManager().reorder(comics, widget.name);
});
} }
super.dispose(); super.dispose();
} }
@@ -783,7 +930,9 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
appBar: Appbar( appBar: Appbar(
title: Text("Reorder".tl), title: Text("Reorder".tl),
actions: [ actions: [
IconButton( Tooltip(
message: "Information".tl,
child: IconButton(
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.info_outline),
onPressed: () { onPressed: () {
showInfoDialog( showInfoDialog(
@@ -793,17 +942,19 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
); );
}, },
), ),
IconButton( ),
Tooltip(
message: "Reverse".tl,
child: IconButton(
icon: const Icon(Icons.swap_vert), icon: const Icon(Icons.swap_vert),
onPressed: () { onPressed: () {
setState(() { setState(() {
comics = comics.reversed.toList(); comics = comics.reversed.toList();
changed = true; changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
}); });
}, },
), ),
)
], ],
), ),
body: ReorderableBuilder<FavoriteItem>( 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), child: Text(widget.data.title),
), ),
actions: [ actions: [
Tooltip(
message: "Refresh".tl,
child: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
comicListKey.currentState!.refresh();
},
),
),
MenuButton(entries: [ MenuButton(entries: [
MenuEntry( MenuEntry(
icon: Icons.sync, icon: Icons.sync,

View File

@@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
findNetworkFolders(); findNetworkFolders();
appdata.settings.addListener(updateFolders); appdata.settings.addListener(updateFolders);
LocalFavoritesManager().addListener(updateFolders);
super.initState(); super.initState();
} }
@@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
void dispose() { void dispose() {
super.dispose(); super.dispose();
appdata.settings.removeListener(updateFolders); appdata.settings.removeListener(updateFolders);
LocalFavoritesManager().removeListener(updateFolders);
} }
@override @override
@@ -86,9 +88,34 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
padding: widget.withAppbar padding: widget.withAppbar
? EdgeInsets.zero ? EdgeInsets.zero
: EdgeInsets.only(top: context.padding.top), : EdgeInsets.only(top: context.padding.top),
itemCount: folders.length + networkFolders.length + 2, itemCount: folders.length + networkFolders.length + 3,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) { if (index == 0) {
return buildLocalTitle();
}
index--;
if (index == 0) {
return buildLocalFolder(_localAllFolderLabel);
}
index--;
if (index < folders.length) {
return buildLocalFolder(folders[index]);
}
index -= folders.length;
if (index == 0) {
return buildNetworkTitle();
}
index--;
return buildNetworkFolder(networkFolders[index]);
},
),
)
],
),
);
}
Widget buildLocalTitle() {
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
@@ -102,21 +129,13 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
const Spacer(), const Spacer(),
MenuButton( MenuButton(
entries: [ entries: [
MenuEntry(
icon: Icons.search,
text: 'Search'.tl,
onClick: () {
context.to(() => const LocalSearchPage());
},
),
MenuEntry( MenuEntry(
icon: Icons.add, icon: Icons.add,
text: 'Create Folder'.tl, text: 'Create Folder'.tl,
onClick: () { onClick: () {
newFolder().then((value) { newFolder().then((value) {
setState(() { setState(() {
folders = folders = LocalFavoritesManager().folderNames;
LocalFavoritesManager().folderNames;
}); });
}); });
}, },
@@ -127,8 +146,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
onClick: () { onClick: () {
sortFolders().then((value) { sortFolders().then((value) {
setState(() { setState(() {
folders = folders = LocalFavoritesManager().folderNames;
LocalFavoritesManager().folderNames;
}); });
}); });
}, },
@@ -139,12 +157,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
).paddingHorizontal(16), ).paddingHorizontal(16),
); );
} }
index--;
if (index < folders.length) { Widget buildNetworkTitle() {
return buildLocalFolder(folders[index]);
}
index -= folders.length;
if (index == 0) {
return Container( return Container(
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8), margin: const EdgeInsets.only(top: 8),
@@ -178,18 +192,18 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
).paddingHorizontal(16), ).paddingHorizontal(16),
); );
} }
index--;
return buildNetworkFolder(networkFolders[index]);
},
),
)
],
),
);
}
Widget buildLocalFolder(String name) { Widget buildLocalFolder(String name) {
bool isSelected = name == favPage.folder && !favPage.isNetwork; 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( return InkWell(
onTap: () { onTap: () {
if (isSelected) { if (isSelected) {
@@ -214,7 +228,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
), ),
), ),
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: Text(name), child: Row(
children: [
Expanded(
child: Text(folderName),
),
Container(
margin: EdgeInsets.only(right: 8),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(count.toString()),
),
],
),
), ),
); );
} }

View File

@@ -6,6 +6,7 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import '../foundation/global_state.dart'; import '../foundation/global_state.dart';
@@ -133,7 +134,18 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
} else if (b.updateTime == null) { } else if (b.updateTime == null) {
return 1; return 1;
} }
return b.updateTime!.compareTo(a.updateTime!); 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;
}
}); });
} }
@@ -270,6 +282,27 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
"Updates".tl, "Updates".tl,
style: ts.s18, 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();
},
);
},
),
], ],
), ),
), ),
@@ -408,7 +441,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
} }
void setFolder(String folder) async { void setFolder(String folder) async {
FollowUpdatesService.cancelChecking?.call(); FollowUpdatesService._cancelChecking?.call();
LocalFavoritesManager().prepareTableForFollowUpdates(folder); LocalFavoritesManager().prepareTableForFollowUpdates(folder);
var count = LocalFavoritesManager().count(folder); var count = LocalFavoritesManager().count(folder);
@@ -447,7 +480,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
} }
void checkNow() async { void checkNow() async {
FollowUpdatesService.cancelChecking?.call(); FollowUpdatesService._cancelChecking?.call();
bool isCanceled = false; bool isCanceled = false;
void onCancel() { void onCancel() {
@@ -570,7 +603,7 @@ void _updateFolderBase(
tags: newTags, tags: newTags,
); );
LocalFavoritesManager().updateInfo(folder, item); LocalFavoritesManager().updateInfo(folder, item, false);
var updateTime = newInfo.findUpdateTime(); var updateTime = newInfo.findUpdateTime();
if (updateTime != null && updateTime != c.updateTime) { if (updateTime != null && updateTime != c.updateTime) {
@@ -580,6 +613,8 @@ void _updateFolderBase(
c.type, c.type,
updateTime, updateTime,
); );
} else {
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
} }
updated++; updated++;
return; return;
@@ -606,6 +641,10 @@ void _updateFolderBase(
await Future.wait(futures); await Future.wait(futures);
if (updated > 0) {
LocalFavoritesManager().notifyChanges();
}
stream.close(); stream.close();
} }
@@ -617,12 +656,14 @@ Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) {
/// Background service for checking updates /// Background service for checking updates
abstract class FollowUpdatesService { abstract class FollowUpdatesService {
static bool isChecking = false; static bool _isChecking = false;
static void Function()? cancelChecking; static void Function()? _cancelChecking;
static void check() async { static bool _isInitialized = false;
if (isChecking) {
static void _check() async {
if (_isChecking) {
return; return;
} }
var folder = appdata.settings["followUpdatesFolder"]; var folder = appdata.settings["followUpdatesFolder"];
@@ -630,11 +671,16 @@ abstract class FollowUpdatesService {
return; return;
} }
bool isCanceled = false; bool isCanceled = false;
cancelChecking = () { _cancelChecking = () {
isCanceled = true; isCanceled = true;
}; };
isChecking = true; _isChecking = true;
while (DataSync().isDownloading) {
await Future.delayed(const Duration(milliseconds: 100));
}
int updated = 0; int updated = 0;
try { try {
await for (var progress in _updateFolder(folder, false)) { await for (var progress in _updateFolder(folder, false)) {
@@ -644,21 +690,28 @@ abstract class FollowUpdatesService {
updated = progress.updated; updated = progress.updated;
} }
} finally { } finally {
cancelChecking = null; _cancelChecking = null;
isChecking = false; _isChecking = false;
if (updated > 0) { if (updated > 0) {
updateFollowUpdatesUI(); updateFollowUpdatesUI();
} }
} }
} }
/// Initialize the checker.
static void initChecker() { static void initChecker() {
Timer.periodic(const Duration(hours: 1), (timer) { if (_isInitialized) return;
check(); _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() { void updateFollowUpdatesUI() {
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount(); GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics(); GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics();

View File

@@ -29,21 +29,108 @@ class _HistoryPageState extends State<HistoryPage> {
void onUpdate() { void onUpdate() {
setState(() { setState(() {
comics = HistoryManager().getAll(); comics = HistoryManager().getAll();
if (multiSelectMode) {
selectedComics.removeWhere((comic, _) => !comics.contains(comic));
if (selectedComics.isEmpty) {
multiSelectMode = false;
}
}
}); });
} }
var comics = HistoryManager().getAll(); var comics = HistoryManager().getAll();
var controller = FlyoutController(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( List<Widget> selectActions = [
body: SmoothCustomScrollView( IconButton(
slivers: [ icon: const Icon(Icons.select_all),
SliverAppbar( tooltip: "Select All".tl,
title: Text('History'.tl), onPressed: selectAll
actions: [ ),
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( Tooltip(
message: 'Clear History'.tl, message: 'Clear History'.tl,
child: Flyout( child: Flyout(
@@ -51,9 +138,16 @@ class _HistoryPageState extends State<HistoryPage> {
flyoutBuilder: (context) { flyoutBuilder: (context) {
return FlyoutContent( return FlyoutContent(
title: 'Clear History'.tl, title: 'Clear History'.tl,
content: Text( content: Text('Are you sure you want to clear your history?'.tl),
'Are you sure you want to clear your history?'.tl),
actions: [ actions: [
Button.outlined(
onPressed: () {
HistoryManager().clearUnfavoritedHistory();
context.pop();
},
child: Text('Clear Unfavorited'.tl),
),
const SizedBox(width: 4),
Button.filled( Button.filled(
color: context.colorScheme.error, color: context.colorScheme.error,
onPressed: () { onPressed: () {
@@ -73,10 +167,63 @@ class _HistoryPageState extends State<HistoryPage> {
), ),
), ),
) )
], ];
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 {
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( SliverGridComics(
comics: comics, 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) { badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name; return ComicSource.find(c.sourceKey)?.name;
}, },
@@ -87,22 +234,7 @@ class _HistoryPageState extends State<HistoryPage> {
text: 'Remove'.tl, text: 'Remove'.tl,
color: context.colorScheme.error, color: context.colorScheme.error,
onClick: () { onClick: () {
if (c.sourceKey.startsWith("Unknown")) { _removeHistory(c as History);
HistoryManager().remove(
c.id,
ComicType(int.parse(c.sourceKey.split(':')[1])),
);
} else if (c.sourceKey == 'local') {
HistoryManager().remove(
c.id,
ComicType.local,
);
} else {
HistoryManager().remove(
c.id,
ComicType(c.sourceKey.hashCode),
);
}
}, },
), ),
]; ];
@@ -110,6 +242,7 @@ class _HistoryPageState extends State<HistoryPage> {
), ),
], ],
), ),
),
); );
} }

View File

@@ -52,7 +52,7 @@ class _SearchBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Container( child: Container(
height: 52, height: App.isMobile ? 52 : 46,
width: double.infinity, width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Material( child: Material(
@@ -162,16 +162,50 @@ class _SyncDataWidgetState extends State<_SyncDataWidget>
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( IconButton(
icon: const Icon(Icons.cloud_upload_outlined), icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async { onPressed: () async {
DataSync().uploadData(); DataSync().uploadData();
}), },
),
IconButton( IconButton(
icon: const Icon(Icons.cloud_download_outlined), icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async { onPressed: () async {
DataSync().downloadData(); DataSync().downloadData();
}), },
),
], ],
), ),
), ),
@@ -197,11 +231,13 @@ class _HistoryState extends State<_History> {
late int count; late int count;
void onHistoryChange() { void onHistoryChange() {
if (mounted) {
setState(() { setState(() {
history = HistoryManager().getRecent(); history = HistoryManager().getRecent();
count = HistoryManager().count(); count = HistoryManager().count();
}); });
} }
}
@override @override
void initState() { void initState() {
@@ -261,7 +297,7 @@ class _HistoryState extends State<_History> {
).paddingHorizontal(16), ).paddingHorizontal(16),
if (history.isNotEmpty) if (history.isNotEmpty)
SizedBox( SizedBox(
height: 128, height: 136,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: history.length, itemCount: history.length,
@@ -364,13 +400,14 @@ class _LocalState extends State<_Local> {
).paddingHorizontal(16), ).paddingHorizontal(16),
if (local.isNotEmpty) if (local.isNotEmpty)
SizedBox( SizedBox(
height: 128, height: 136,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: local.length, itemCount: local.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return SimpleComicTile(comic: local[index]) return SimpleComicTile(comic: local[index])
.paddingHorizontal(8); .paddingHorizontal(8)
.paddingVertical(2);
}, },
), ),
).paddingHorizontal(8), ).paddingHorizontal(8),
@@ -536,7 +573,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
], ],
), ),
onPressed: () { 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), ).fixWidth(90).paddingRight(8),
Button.filled( Button.filled(
@@ -593,16 +631,29 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
@override @override
void initState() { void initState() {
comicSources = ComicSource.all().map((e) => e.name).toList(); comicSources = ComicSource.all().map((e) => e.name).toList();
ComicSource.addListener(onComicSourceChange); ComicSourceManager().addListener(onComicSourceChange);
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
ComicSource.removeListener(onComicSourceChange); ComicSourceManager().removeListener(onComicSourceChange);
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
@@ -666,7 +717,7 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}).toList(), }).toList(),
).paddingHorizontal(16).paddingBottom(16), ).paddingHorizontal(16).paddingBottom(16),
), ),
if (ComicSource.availableUpdates.isNotEmpty) if (_availableUpdates > 0)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@@ -682,14 +733,24 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.update, color: context.colorScheme.primary, size: 20,), Icon(
Icons.update,
color: context.colorScheme.primary,
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text("@c updates".tlParams({ Text(
'c': ComicSource.availableUpdates.length, "@c updates".tlParams({
}), style: ts.withColor(context.colorScheme.primary),), 'c': _availableUpdates,
}),
style: ts.withColor(context.colorScheme.primary),
),
], ],
), ),
).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8), )
.toAlign(Alignment.centerLeft)
.paddingHorizontal(16)
.paddingBottom(8),
], ],
), ),
), ),
@@ -829,7 +890,8 @@ class _ImageFavoritesState extends State<ImageFavorites> {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color:
Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
@@ -880,7 +942,7 @@ class _ImageFavoritesState extends State<ImageFavorites> {
displayType = type; displayType = type;
}); });
await Future.delayed(const Duration(milliseconds: 20)); await Future.delayed(const Duration(milliseconds: 20));
var scrollController = ScrollControllerProvider.of(context); var scrollController = ScrollState.of(context).controller;
scrollController.animateTo( scrollController.animateTo(
scrollController.position.maxScrollExtent, scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/comic_details_page/comic_page.dart';
@@ -13,6 +14,7 @@ import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart'; import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:zip_flutter/zip_flutter.dart'; import 'package:zip_flutter/zip_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
class LocalComicsPage extends StatefulWidget { class LocalComicsPage extends StatefulWidget {
const LocalComicsPage({super.key}); const LocalComicsPage({super.key});
@@ -142,6 +144,14 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
addFavorite(selectedComics.keys.toList()); 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) if (selectedComics.length == 1)
MenuEntry( MenuEntry(
icon: Icons.chrome_reader_mode_outlined, icon: Icons.chrome_reader_mode_outlined,
@@ -304,11 +314,21 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
} }
}); });
} else { } else {
(c as LocalComic).read(); // prevent dirty data
var comic =
LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
comic.read();
} }
}, },
menuBuilder: (c) { menuBuilder: (c) {
return [ return [
MenuEntry(
icon: Icons.folder_open,
text: "Open Folder".tl,
onClick: () {
openComicFolder(c as LocalComic);
},
),
MenuEntry( MenuEntry(
icon: Icons.delete, icon: Icons.delete,
text: "Delete".tl, text: "Delete".tl,
@@ -357,10 +377,22 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {
bool removeComicFile = true; bool removeComicFile = true;
bool removeFavoriteAndHistory = true;
return StatefulBuilder(builder: (context, state) { return StatefulBuilder(builder: (context, state) {
return ContentDialog( return ContentDialog(
title: "Delete".tl, title: "Delete".tl,
content: CheckboxListTile( 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), title: Text("Also remove files on disk".tl),
value: removeComicFile, value: removeComicFile,
onChanged: (v) { onChanged: (v) {
@@ -368,17 +400,26 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
removeComicFile = !removeComicFile; removeComicFile = !removeComicFile;
}); });
}, },
)
],
), ),
actions: [ actions: [
if (comics.length == 1 && comics.first.hasChapters)
TextButton(
child: Text("Delete Chapters".tl),
onPressed: () {
context.pop();
showDeleteChaptersPopWindow(context, comics.first);
},
),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
context.pop(); context.pop();
for (var comic in comics) { LocalManager().batchDeleteComics(
LocalManager().deleteComic( comics,
comic,
removeComicFile, removeComicFile,
removeFavoriteAndHistory,
); );
}
isDeleted = true; isDeleted = true;
}, },
child: Text("Confirm".tl), child: Text("Confirm".tl),
@@ -441,7 +482,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
var fileName = ""; var fileName = "";
// For each comic, export it to a file // For each comic, export it to a file
for (var comic in comics) { for (var comic in comics) {
fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext); fileName = FilePath.join(
cacheDir,
sanitizeFileName(comic.title, maxLength: 100) + ext,
);
await export(comic, fileName); await export(comic, fileName);
current++; current++;
if (comics.length > 1) { if (comics.length > 1) {
@@ -490,3 +534,102 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
typedef ExportComicFunc = Future<File> Function( typedef ExportComicFunc = Future<File> Function(
LocalComic comic, String outFilePath); 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

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/pages/categories_page.dart'; import 'package:venera/pages/categories_page.dart';
import 'package:venera/pages/search_page.dart'; import 'package:venera/pages/search_page.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
@@ -39,6 +40,7 @@ class _MainPageState extends State<MainPage> {
_observer = NaviObserver(); _observer = NaviObserver();
_navigatorKey = GlobalKey(); _navigatorKey = GlobalKey();
App.mainNavigatorKey = _navigatorKey; App.mainNavigatorKey = _navigatorKey;
index = int.tryParse(appdata.settings['initialPage'].toString()) ?? 0;
super.initState(); super.initState();
} }
@@ -60,6 +62,7 @@ class _MainPageState extends State<MainPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return NaviPane( return NaviPane(
initialPage: index,
observer: _observer, observer: _observer,
navigatorKey: _navigatorKey!, navigatorKey: _navigatorKey!,
paneItems: [ 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

@@ -24,6 +24,8 @@ class ComicImage extends StatefulWidget {
Map<String, String>? headers, Map<String, String>? headers,
int? cacheWidth, int? cacheWidth,
int? cacheHeight, int? cacheHeight,
this.onInit,
this.onDispose,
}) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0), assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0); assert(cacheHeight == null || cacheHeight > 0);
@@ -60,6 +62,10 @@ class ComicImage extends StatefulWidget {
final bool isAntiAlias; final bool isAntiAlias;
final void Function(State<ComicImage> state)? onInit;
final void Function(State<ComicImage> state)? onDispose;
static void clear() => _ComicImageState.clear(); static void clear() => _ComicImageState.clear();
@override @override
@@ -87,6 +93,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this); _scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this);
widget.onInit?.call(this);
} }
@override @override
@@ -97,6 +104,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
_completerHandle?.dispose(); _completerHandle?.dispose();
_scrollAwareContext.dispose(); _scrollAwareContext.dispose();
_replaceImage(info: null); _replaceImage(info: null);
widget.onDispose?.call(this);
super.dispose(); super.dispose();
} }
@@ -136,6 +144,15 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.reassemble(); 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() { void _updateInvertColors() {
_invertColors = MediaQuery.maybeInvertColorsOf(context) ?? _invertColors = MediaQuery.maybeInvertColorsOf(context) ??
SemanticsBinding.instance.accessibilityFeatures.invertColors; SemanticsBinding.instance.accessibilityFeatures.invertColors;

View File

@@ -131,11 +131,11 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
} }
if (context.reader.mode.key.startsWith('gallery')) { if (context.reader.mode.key.startsWith('gallery')) {
if (forward) { if (forward) {
if (!context.reader.toNextPage()) { if (!context.reader.toNextPage(reader.cid, reader.type) && !context.reader.isLastChapterOfGroup) {
context.reader.toNextChapter(); context.reader.toNextChapter();
} }
} else { } else {
if (!context.reader.toPrevPage()) { if (!context.reader.toPrevPage(reader.cid, reader.type) && !context.reader.isFirstChapterOfGroup) {
context.reader.toPrevChapter(); context.reader.toPrevChapter();
} }
} }
@@ -152,12 +152,19 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
bool _dragInProgress = false; bool _dragInProgress = false;
bool get _enableDoubleTapToZoom =>
appdata.settings.getReaderSetting(reader.cid, reader.type.sourceKey, 'enableDoubleTapToZoom');
void onTapUp(TapUpDetails event) { void onTapUp(TapUpDetails event) {
if (_longPressInProgress) { if (_longPressInProgress) {
_longPressInProgress = false; _longPressInProgress = false;
return; return;
} }
final location = event.globalPosition; final location = event.globalPosition;
if (!_enableDoubleTapToZoom) {
onTap(location);
return;
}
final previousLocation = _previousEvent?.globalPosition; final previousLocation = _previousEvent?.globalPosition;
if (previousLocation != null) { if (previousLocation != null) {
if ((location - previousLocation).distanceSquared < if ((location - previousLocation).distanceSquared <
@@ -184,7 +191,8 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
} else if (context.readerScaffold.isOpen) { } else if (context.readerScaffold.isOpen) {
context.readerScaffold.openOrClose(); context.readerScaffold.openOrClose();
} else { } else {
if (appdata.settings['enableTapToTurnPages']) { if (appdata.settings.getReaderSetting(
reader.cid, reader.type.sourceKey, 'enableTapToTurnPages')) {
bool isLeft = false, isRight = false, isTop = false, isBottom = false; bool isLeft = false, isRight = false, isTop = false, isBottom = false;
final width = context.width; final width = context.width;
final height = context.height; final height = context.height;
@@ -201,11 +209,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
isBottom = true; isBottom = true;
} }
bool isCenter = false; bool isCenter = false;
var prev = context.reader.toPrevPage; var prev = () => context.reader.toPrevPage(context.reader.cid, context.reader.type);
var next = context.reader.toNextPage; var next = () => context.reader.toNextPage(context.reader.cid, context.reader.type);
if (appdata.settings['reverseTapToTurnPages']) { if (appdata.settings.getReaderSetting(
prev = context.reader.toNextPage; reader.cid, reader.type.sourceKey, 'reverseTapToTurnPages')) {
next = context.reader.toPrevPage; prev = () => context.reader.toNextPage(context.reader.cid, context.reader.type);
next = () => context.reader.toPrevPage(context.reader.cid, context.reader.type);
} }
switch (context.reader.mode) { switch (context.reader.mode) {
case ReaderMode.galleryLeftToRight: case ReaderMode.galleryLeftToRight:
@@ -281,6 +290,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
context.pop(); 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),
),
], ],
); );
} }
@@ -303,6 +324,27 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
@override @override
Object? get key => "reader_gesture"; 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 { class _DragListener {

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ class _ReaderWithLoadingState
history: data.history, history: data.history,
initialChapter: widget.initialEp ?? data.history.ep, initialChapter: widget.initialEp ?? data.history.ep,
initialPage: widget.initialPage ?? data.history.page, initialPage: widget.initialPage ?? data.history.page,
initialChapterGroup: data.history.group,
author: data.author, author: data.author,
tags: data.tags, tags: data.tags,
); );
@@ -101,7 +102,7 @@ class ReaderProps {
final String name; final String name;
final Map<String, String>? chapters; final ComicChapters? chapters;
final History history; final History history;

View File

@@ -15,6 +15,7 @@ import 'package:photo_view/photo_view_gallery.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/components/custom_slider.dart'; import 'package:venera/components/custom_slider.dart';
import 'package:venera/components/window_frame.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
@@ -28,7 +29,9 @@ import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/images.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/clipboard_image.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/file_type.dart';
@@ -40,11 +43,17 @@ import 'package:window_manager/window_manager.dart';
import 'package:battery_plus/battery_plus.dart'; import 'package:battery_plus/battery_plus.dart';
part 'scaffold.dart'; part 'scaffold.dart';
part 'images.dart'; part 'images.dart';
part 'gesture.dart'; part 'gesture.dart';
part 'comic_image.dart'; part 'comic_image.dart';
part 'loading.dart'; part 'loading.dart';
part 'chapters.dart';
extension _ReaderContext on BuildContext { extension _ReaderContext on BuildContext {
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!; _ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
@@ -62,6 +71,7 @@ class Reader extends StatefulWidget {
required this.history, required this.history,
this.initialPage, this.initialPage,
this.initialChapter, this.initialChapter,
this.initialChapterGroup,
required this.author, required this.author,
required this.tags, required this.tags,
}); });
@@ -76,9 +86,7 @@ class Reader extends StatefulWidget {
final String name; final String name;
/// key: Chapter ID, value: Chapter Name final ComicChapters? chapters;
/// null if the comic is a gallery
final Map<String, String>? chapters;
/// Starts from 1, invalid values equal to 1 /// Starts from 1, invalid values equal to 1
final int? initialPage; final int? initialPage;
@@ -86,56 +94,48 @@ class Reader extends StatefulWidget {
/// Starts from 1, invalid values equal to 1 /// Starts from 1, invalid values equal to 1
final int? initialChapter; final int? initialChapter;
/// Starts from 1, invalid values equal to 1
final int? initialChapterGroup;
final History history; final History history;
@override @override
State<Reader> createState() => _ReaderState(); State<Reader> createState() => _ReaderState();
} }
class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow { class _ReaderState extends State<Reader>
with _ReaderLocation, _ReaderWindow, _VolumeListener, _ImagePerPageHandler {
@override @override
void update() { void update() {
setState(() {}); setState(() {});
} }
@override @override
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil(); int get maxPage {
if (images == null) {
return 1;
}
if (!showSingleImageOnFirstPage(cid, type)) {
return (images!.length / imagesPerPage(cid, type)).ceil();
} else {
return 1 + ((images!.length - 1) / imagesPerPage(cid, type)).ceil();
}
}
ComicType get type => widget.type; ComicType get type => widget.type;
String get cid => widget.cid; String get cid => widget.cid;
String get eid => widget.chapters?.keys.elementAt(chapter - 1) ?? '0'; String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
List<String>? images; List<String>? images;
@override
late ReaderMode mode; late ReaderMode mode;
int get imagesPerPage => appdata.settings['readerScreenPicNumber'] ?? 1;
int _lastImagesPerPage = appdata.settings['readerScreenPicNumber'] ?? 1;
@override @override
void didChangeDependencies() { bool get isPortrait =>
super.didChangeDependencies(); MediaQuery.of(context).orientation == Orientation.portrait;
_checkImagesPerPageChange();
}
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
}
}
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
}
History? history; History? history;
@@ -144,26 +144,32 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
var focusNode = FocusNode(); var focusNode = FocusNode();
VolumeListener? volumeListener;
@override @override
void initState() { void initState() {
page = widget.initialPage ?? 1; page = widget.initialPage ?? 1;
chapter = widget.initialChapter ?? 1;
if (page < 1) { if (page < 1) {
page = 1; page = 1;
} }
chapter = widget.initialChapter ?? 1;
if (chapter < 1) { if (chapter < 1) {
chapter = 1; chapter = 1;
} }
mode = ReaderMode.fromKey(appdata.settings['readerMode']); if (widget.initialChapterGroup != null) {
for (int i = 0; i < (widget.initialChapterGroup! - 1); i++) {
chapter += widget.chapters!.getGroupByIndex(i).length;
}
}
if (widget.initialPage != null) {
page = widget.initialPage!;
}
// mode = ReaderMode.fromKey(appdata.settings['readerMode']);
mode = ReaderMode.fromKey(appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'));
history = widget.history; history = widget.history;
Future.microtask(() { if (!appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSystemStatusBar')) {
updateHistory();
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if (appdata.settings['enableTurnPageByVolumeKey']) { }
handleVolumeEvent(); if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) {
handleVolumeEvent(cid, type);
} }
setImageCacheSize(); setImageCacheSize();
Future.delayed(const Duration(milliseconds: 200), () { Future.delayed(const Duration(milliseconds: 200), () {
@@ -172,6 +178,21 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
super.initState(); super.initState();
} }
bool _isInitialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInitialized) {
initImagesPerPage(cid, type, widget.initialPage ?? 1);
_isInitialized = true;
} else {
// For orientation changed
_checkImagesPerPageChange(cid, type);
}
initReaderWindow();
}
void setImageCacheSize() async { void setImageCacheSize() async {
var availableRAM = await MemoryInfo.getFreePhysicalMemorySize(); var availableRAM = await MemoryInfo.getFreePhysicalMemorySize();
if (availableRAM == null) return; if (availableRAM == null) return;
@@ -192,6 +213,9 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
@override @override
void dispose() { void dispose() {
if (isFullscreen) {
fullscreen();
}
autoPageTurningTimer?.cancel(); autoPageTurningTimer?.cancel();
focusNode.dispose(); focusNode.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -200,25 +224,35 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
DataSync().onDataChanged(); DataSync().onDataChanged();
}); });
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20;
disposeReaderWindow();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_checkImagesPerPageChange(); _checkImagesPerPageChange(cid, type);
return KeyboardListener( return KeyboardListener(
focusNode: focusNode, focusNode: focusNode,
autofocus: true, autofocus: true,
onKeyEvent: onKeyEvent, onKeyEvent: onKeyEvent,
child: _ReaderScaffold( child: Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return _ReaderScaffold(
child: _ReaderGestureDetector( child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())), child: _ReaderImages(key: Key(chapter.toString())),
), ),
);
})
],
), ),
); );
} }
void onKeyEvent(KeyEvent event) { void onKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.f12 && event is KeyUpEvent) {
fullscreen();
}
_imageViewController?.handleKeyEvent(event); _imageViewController?.handleKeyEvent(event);
} }
@@ -236,12 +270,36 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
void updateHistory() { void updateHistory() {
if (history != null) { if (history != null) {
history!.page = page; if (page == maxPage) {
history!.ep = chapter; /// Record the last image of chapter
if (maxPage > 1) { history!.page = images?.length ?? 1;
history!.maxPage = maxPage; } else {
/// Record the first image of the page
if (!showSingleImageOnFirstPage(cid, type) || imagesPerPage(cid, type) == 1) {
history!.page = (page - 1) * imagesPerPage(cid, type) + 1;
} else {
if (page == 1) {
history!.page = 1;
} else {
history!.page = (page - 2) * imagesPerPage(cid, type) + 2;
}
}
}
history!.maxPage = images?.length ?? 1;
if (widget.chapters?.isGrouped ?? false) {
int g = 0;
int c = chapter;
while (c > widget.chapters!.getGroupByIndex(g).length) {
c -= widget.chapters!.getGroupByIndex(g).length;
g++;
}
history!.readEpisode.add('${g + 1}-$c');
history!.ep = c;
history!.group = g + 1;
} else {
history!.readEpisode.add(chapter.toString());
history!.ep = chapter;
} }
history!.readEpisode.add(chapter);
history!.time = DateTime.now(); history!.time = DateTime.now();
_updateHistoryTimer?.cancel(); _updateHistoryTimer?.cancel();
_updateHistoryTimer = Timer(const Duration(seconds: 1), () { _updateHistoryTimer = Timer(const Duration(seconds: 1), () {
@@ -251,7 +309,152 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} }
} }
void handleVolumeEvent() { bool get isFirstChapterOfGroup {
if (widget.chapters?.isGrouped ?? false) {
int c = chapter - 1;
int g = 1;
while (c > 0) {
c -= widget.chapters!.getGroupByIndex(g - 1).length;
g++;
}
if (c == 0) {
return true;
} else {
return false;
}
}
return chapter == 1;
}
bool get isLastChapterOfGroup {
if (widget.chapters?.isGrouped ?? false) {
int c = chapter;
int g = 1;
while (c > 0) {
c -= widget.chapters!.getGroupByIndex(g - 1).length;
g++;
}
if (c == 0) {
return true;
} else {
return false;
}
}
return chapter == maxChapter;
}
/// Get the size of the reader.
/// The size is not always the same as the size of the screen.
Size get size {
var renderBox = context.findRenderObject() as RenderBox;
return renderBox.size;
}
}
abstract mixin class _ImagePerPageHandler {
late int _lastImagesPerPage;
late bool _lastOrientation;
bool get isPortrait;
int get page;
set page(int value);
ReaderMode get mode;
void initImagesPerPage(String cid, ComicType type, int initialPage) {
_lastImagesPerPage = imagesPerPage(cid, type);
_lastOrientation = isPortrait;
if (imagesPerPage(cid, type) != 1) {
if (showSingleImageOnFirstPage(cid, type)) {
page = ((initialPage - 1) / imagesPerPage(cid, type)).ceil() + 1;
} else {
page = (initialPage / imagesPerPage(cid, type)).ceil();
}
}
}
bool showSingleImageOnFirstPage(String cid, ComicType type) =>
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
/// The number of images displayed on one screen
int imagesPerPage(String cid, ComicType type) {
if (mode.isContinuous) return 1;
if (isPortrait) {
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
} else {
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForLandscape') ?? 1;
}
}
/// Check if the number of images per page has changed
void _checkImagesPerPageChange(String cid, ComicType type) {
int currentImagesPerPage = imagesPerPage(cid, type);
bool currentOrientation = isPortrait;
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
_adjustPageForImagesPerPageChange(
cid, type, _lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
_lastOrientation = currentOrientation;
}
}
/// Adjust the page number when the number of images per page changes
void _adjustPageForImagesPerPageChange(
String cid, ComicType type, int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = 1;
if (!showSingleImageOnFirstPage(cid, type) || oldImagesPerPage == 1) {
previousImageIndex = (page - 1) * oldImagesPerPage + 1;
} else {
if (page == 1) {
previousImageIndex = 1;
} else {
previousImageIndex = (page - 2) * oldImagesPerPage + 2;
}
}
int newPage;
if (newImagesPerPage != 1) {
if (showSingleImageOnFirstPage(cid, type)) {
newPage = ((previousImageIndex - 1) / newImagesPerPage).ceil() + 1;
} else {
newPage = (previousImageIndex / newImagesPerPage).ceil();
}
} else {
newPage = previousImageIndex;
}
page = newPage>0 ? newPage : 1;
}
}
abstract mixin class _VolumeListener {
bool toNextPage(String cid, ComicType type);
bool toPrevPage(String cid, ComicType type);
bool toNextChapter();
bool toPrevChapter();
VolumeListener? volumeListener;
void onDown(String cid, ComicType type) {
if (!toNextPage(cid, type)) {
toNextChapter();
}
}
void onUp(String cid, ComicType type) {
if (!toPrevPage(cid, type)) {
toPrevChapter();
}
}
void handleVolumeEvent(String cid, ComicType type) {
if (!App.isAndroid) { if (!App.isAndroid) {
// Currently only support Android // Currently only support Android
return; return;
@@ -260,12 +463,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
volumeListener?.cancel(); volumeListener?.cancel();
} }
volumeListener = VolumeListener( volumeListener = VolumeListener(
onDown: () { onDown: () => onDown(cid, type),
toNextPage(); onUp: () => onUp(cid, type),
},
onUp: () {
toPrevPage();
},
)..listen(); )..listen();
} }
@@ -297,7 +496,7 @@ abstract mixin class _ReaderLocation {
void update(); void update();
bool get enablePageAnimation => appdata.settings['enablePageAnimation']; bool enablePageAnimation(String cid, ComicType type) => appdata.settings.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
_ImageViewController? _imageViewController; _ImageViewController? _imageViewController;
@@ -316,28 +515,25 @@ abstract mixin class _ReaderLocation {
} }
/// Returns true if the page is changed /// Returns true if the page is changed
bool toNextPage() { bool toNextPage(String cid, ComicType type) {
return toPage(page + 1); return toPage(cid, type, page + 1);
} }
/// Returns true if the page is changed /// Returns true if the page is changed
bool toPrevPage() { bool toPrevPage(String cid, ComicType type) {
return toPage(page - 1); return toPage(cid, type, page - 1);
} }
int _animationCount = 0; int _animationCount = 0;
bool toPage(int page) { bool toPage(String cid, ComicType type, int page) {
if (_validatePage(page)) { if (_validatePage(page)) {
if (page == this.page) { if (page == this.page && page != 1 && page != maxPage) {
if (!(chapter == 1 && page == 1) &&
!(chapter == maxChapter && page == maxPage)) {
return false; return false;
} }
}
this.page = page; this.page = page;
update(); update();
if (enablePageAnimation) { if (enablePageAnimation(cid, type)) {
_animationCount++; _animationCount++;
_imageViewController!.animateToPage(page).then((_) { _imageViewController!.animateToPage(page).then((_) {
_animationCount--; _animationCount--;
@@ -376,17 +572,17 @@ abstract mixin class _ReaderLocation {
Timer? autoPageTurningTimer; Timer? autoPageTurningTimer;
void autoPageTurning() { void autoPageTurning(String cid, ComicType type) {
if (autoPageTurningTimer != null) { if (autoPageTurningTimer != null) {
autoPageTurningTimer!.cancel(); autoPageTurningTimer!.cancel();
autoPageTurningTimer = null; autoPageTurningTimer = null;
} else { } else {
int interval = appdata.settings['autoPageTurningInterval']; int interval = appdata.settings.getReaderSetting(cid, type.sourceKey, 'autoPageTurningInterval');
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) { autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
if (page == maxPage) { if (page == maxPage) {
autoPageTurningTimer!.cancel(); autoPageTurningTimer!.cancel();
} }
toNextPage(); toNextPage(cid, type);
}); });
} }
} }
@@ -395,9 +591,38 @@ abstract mixin class _ReaderLocation {
mixin class _ReaderWindow { mixin class _ReaderWindow {
bool isFullscreen = false; bool isFullscreen = false;
void fullscreen() { late WindowFrameController windowFrame;
windowManager.setFullScreen(!isFullscreen);
bool _isInit = false;
void initReaderWindow() {
if (!App.isDesktop || _isInit) return;
windowFrame = WindowFrame.of(App.rootContext);
windowFrame.addCloseListener(onWindowClose);
_isInit = true;
}
void fullscreen() async {
if (!App.isDesktop) return;
await windowManager.hide();
await windowManager.setFullScreen(!isFullscreen);
await windowManager.show();
isFullscreen = !isFullscreen; isFullscreen = !isFullscreen;
WindowFrame.of(App.rootContext).setWindowFrame(!isFullscreen);
}
bool onWindowClose() {
if (Navigator.of(App.rootContext).canPop()) {
Navigator.of(App.rootContext).pop();
return false;
} else {
return true;
}
}
void disposeReaderWindow() {
if (!App.isDesktop) return;
windowFrame.removeCloseListener(onWindowClose);
} }
} }
@@ -442,4 +667,8 @@ abstract interface class _ImageViewController {
/// Returns true if the event is handled. /// Returns true if the event is handled.
bool handleOnTap(Offset location); bool handleOnTap(Offset location);
Future<Uint8List?> getImageByOffset(Offset offset);
String? getImageKeyByOffset(Offset offset);
} }

View File

@@ -26,73 +26,21 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
var lastValue = 0; var lastValue = 0;
var fABValue = ValueNotifier<double>(0);
_ReaderGestureDetectorState? _gestureDetectorState; _ReaderGestureDetectorState? _gestureDetectorState;
_DragListener? _floatingButtonDragListener;
void setFloatingButton(int value) { void setFloatingButton(int value) {
lastValue = showFloatingButtonValue; lastValue = showFloatingButtonValue;
if (value == 0) { if (value == 0) {
if (showFloatingButtonValue != 0) { if (showFloatingButtonValue != 0) {
showFloatingButtonValue = 0; showFloatingButtonValue = 0;
fABValue.value = 0;
update(); update();
} }
if (_floatingButtonDragListener != null) {
_gestureDetectorState!.removeDragListener(_floatingButtonDragListener!);
_floatingButtonDragListener = null;
} }
}
var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) { if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1; showFloatingButtonValue = 1;
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy;
} else if (readerMode == ReaderMode.continuousLeftToRight) {
fABValue.value -= offset.dx;
} else if (readerMode == ReaderMode.continuousRightToLeft) {
fABValue.value += offset.dx;
}
},
onEnd: () {
if (fABValue.value.abs() > 58 * 3) {
setState(() {
showFloatingButtonValue = 0;
});
context.reader.toNextChapter();
}
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update(); update();
} else if (value == -1 && showFloatingButtonValue == 0) { } else if (value == -1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = -1; showFloatingButtonValue = -1;
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value += offset.dy;
} else if (readerMode == ReaderMode.continuousLeftToRight) {
fABValue.value += offset.dx;
} else if (readerMode == ReaderMode.continuousRightToLeft) {
fABValue.value -= offset.dx;
}
},
onEnd: () {
if (fABValue.value.abs() > 58 * 3) {
setState(() {
showFloatingButtonValue = 0;
});
context.reader.toPrevChapter();
}
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update(); update();
} }
} }
@@ -159,7 +107,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
if (!_isOpen) { if (!_isOpen) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else { } else {
if (!appdata.settings['showSystemStatusBar']) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
} }
setState(() { setState(() {
_isOpen = !_isOpen; _isOpen = !_isOpen;
@@ -176,9 +128,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return Stack(
children: [ children: [
Positioned.fill( Positioned.fill(child: widget.child),
child: widget.child, if (appdata.settings['showPageNumberInReader'] == true)
),
buildPageInfoText(), buildPageInfoText(),
buildStatusInfo(), buildStatusInfo(),
AnimatedPositioned( AnimatedPositioned(
@@ -213,12 +164,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: Container( child: Container(
padding: EdgeInsets.only(top: context.padding.top), padding: EdgeInsets.only(top: context.padding.top),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.82), color: context.colorScheme.surface.toOpacity(0.92),
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(color: Colors.grey.toOpacity(0.5), width: 0.5),
color: Colors.grey.toOpacity(0.5),
width: 0.5,
),
), ),
), ),
child: Row( child: Row(
@@ -259,12 +207,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
} }
void addImageFavorite() { void addImageFavorite() async {
try { try {
if (context.reader.images![0].contains('file://')) { if (context.reader.images![0].contains('file://')) {
showToast( showToast(
message: "Local comic collection is not supported at present".tl, message: "Local comic collection is not supported at present".tl,
context: context); context: context,
);
return; return;
} }
String id = context.reader.cid; String id = context.reader.cid;
@@ -273,14 +222,18 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
String title = context.reader.history!.title; String title = context.reader.history!.title;
String subTitle = context.reader.history!.subtitle; String subTitle = context.reader.history!.subtitle;
int maxPage = context.reader.images!.length; int maxPage = context.reader.images!.length;
int page = context.reader.page; int? page = await selectImage();
if (page == null) return;
page += 1;
String sourceKey = context.reader.type.sourceKey; String sourceKey = context.reader.type.sourceKey;
String imageKey = context.reader.images![page - 1]; String imageKey = context.reader.images![page - 1];
List<String> tags = context.reader.widget.tags; List<String> tags = context.reader.widget.tags;
String author = context.reader.widget.author; String author = context.reader.widget.author;
var epName = context.reader.widget.chapters?.values var epName =
.elementAtOrNull(context.reader.chapter - 1) ?? context.reader.widget.chapters?.titles.elementAtOrNull(
context.reader.chapter - 1,
) ??
"E${context.reader.chapter}"; "E${context.reader.chapter}";
var translatedTags = tags.map((e) => e.translateTagsToCN).toList(); var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
@@ -293,7 +246,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
return; return;
} }
ImageFavoriteManager().deleteImageFavorite([ ImageFavoriteManager().deleteImageFavorite([
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName) ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName),
]); ]);
showToast( showToast(
message: "Uncollected the image".tl, message: "Uncollected the image".tl,
@@ -301,7 +254,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
seconds: 1, seconds: 1,
); );
} else { } else {
var imageFavoritesComic = ImageFavoriteManager().find(id, sourceKey) ?? var imageFavoritesComic =
ImageFavoriteManager().find(id, sourceKey) ??
ImageFavoritesComic( ImageFavoritesComic(
id, id,
[], [],
@@ -315,10 +269,19 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
subTitle, subTitle,
maxPage, maxPage,
); );
ImageFavorite imageFavorite = ImageFavorite imageFavorite = ImageFavorite(
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName); page,
ImageFavoritesEp? imageFavoritesEp = imageKey,
imageFavoritesComic.imageFavoritesEp.firstWhereOrNull((e) { null,
eid,
id,
ep,
sourceKey,
epName,
);
ImageFavoritesEp? imageFavoritesEp = imageFavoritesComic
.imageFavoritesEp
.firstWhereOrNull((e) {
return e.ep == ep; return e.ep == ep;
}); });
if (imageFavoritesEp == null) { if (imageFavoritesEp == null) {
@@ -330,10 +293,20 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
// 不是第一页的话, 自动塞一个封面进去 // 不是第一页的话, 自动塞一个封面进去
imageFavoritesEp = ImageFavoritesEp( imageFavoritesEp = ImageFavoritesEp(
eid, ep, [copy, imageFavorite], epName, maxPage); eid,
ep,
[copy, imageFavorite],
epName,
maxPage,
);
} else { } else {
imageFavoritesEp = imageFavoritesEp = ImageFavoritesEp(
ImageFavoritesEp(eid, ep, [imageFavorite], epName, maxPage); eid,
ep,
[imageFavorite],
epName,
maxPage,
);
} }
imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp); imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp);
} else { } else {
@@ -357,7 +330,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic); ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic);
showToast( showToast(
message: "Successfully collected".tl, context: context, seconds: 1); message: "Successfully collected".tl,
context: context,
seconds: 1,
);
} }
update(); update();
} catch (e, stackTrace) { } catch (e, stackTrace) {
@@ -376,9 +352,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
height: kBottomBarHeight, height: kBottomBarHeight,
child: Column( child: Column(
children: [ children: [
const SizedBox( const SizedBox(height: 8),
height: 8,
),
Row( Row(
children: [ children: [
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -386,34 +360,45 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
onPressed: () => !isReversed onPressed: () => !isReversed
? context.reader.chapter > 1 ? context.reader.chapter > 1
? context.reader.toPrevChapter() ? context.reader.toPrevChapter()
: context.reader.toPage(1) : context.reader.toPage(
context.reader.cid,
context.reader.type,
1,
)
: context.reader.chapter < context.reader.maxChapter : context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter() ? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage), : context.reader.toPage(
context.reader.cid,
context.reader.type,
context.reader.maxPage,
),
icon: const Icon(Icons.first_page), icon: const Icon(Icons.first_page),
), ),
Expanded( Expanded(child: buildSlider()),
child: buildSlider(),
),
IconButton.filledTonal( IconButton.filledTonal(
onPressed: () => !isReversed onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter ? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter() ? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage) : context.reader.toPage(
context.reader.cid,
context.reader.type,
context.reader.maxPage,
)
: context.reader.chapter > 1 : context.reader.chapter > 1
? context.reader.toPrevChapter() ? context.reader.toPrevChapter()
: context.reader.toPage(1), : context.reader.toPage(
icon: const Icon(Icons.last_page)), context.reader.cid,
const SizedBox( context.reader.type,
width: 8, 1,
), ),
icon: const Icon(Icons.last_page),
),
const SizedBox(width: 8),
], ],
), ),
Row( Row(
children: [ children: [
const SizedBox( const SizedBox(width: 16),
width: 16,
),
Container( Container(
height: 24, height: 24,
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0), padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
@@ -421,19 +406,19 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
color: Theme.of(context).colorScheme.tertiaryContainer, color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Center( child: Center(child: Text(text)),
child: Text(text),
),
), ),
const Spacer(), const Spacer(),
Tooltip( Tooltip(
message: "Collect the image".tl, message: "Collect the image".tl,
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(
isLiked() ? Icons.favorite : Icons.favorite_border), isLiked() ? Icons.favorite : Icons.favorite_border,
onPressed: addImageFavorite),
), ),
if (App.isWindows) onPressed: addImageFavorite,
),
),
if (App.isDesktop)
Tooltip( Tooltip(
message: "${"Full Screen".tl}(F12)", message: "${"Full Screen".tl}(F12)",
child: IconButton( child: IconButton(
@@ -471,14 +456,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
}); });
SystemChrome.setPreferredOrientations([ SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight DeviceOrientation.landscapeRight,
]); ]);
} else { } else {
setState(() { setState(() {
rotation = null; rotation = null;
}); });
SystemChrome.setPreferredOrientations( SystemChrome.setPreferredOrientations(
DeviceOrientation.values); DeviceOrientation.values,
);
} }
}, },
), ),
@@ -490,7 +476,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
? const Icon(Icons.timer) ? const Icon(Icons.timer)
: const Icon(Icons.timer_sharp), : const Icon(Icons.timer_sharp),
onPressed: () { onPressed: () {
context.reader.autoPageTurning(); context.reader.autoPageTurning(
context.reader.cid,
context.reader.type,
);
update(); update();
}, },
), ),
@@ -517,9 +506,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
onPressed: share, onPressed: share,
), ),
), ),
const SizedBox(width: 4) const SizedBox(width: 4),
], ],
) ),
], ],
), ),
); );
@@ -527,7 +516,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
return BlurEffect( return BlurEffect(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.82), color: context.colorScheme.surface.toOpacity(0.92),
border: isOpen border: isOpen
? Border( ? Border(
top: BorderSide( top: BorderSide(
@@ -550,19 +539,26 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
focusNode: sliderFocus, focusNode: sliderFocus,
value: context.reader.page.toDouble(), value: context.reader.page.toDouble(),
min: 1, min: 1,
max: max: context.reader.maxPage
context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(), .clamp(context.reader.page, 1 << 16)
.toDouble(),
reversed: isReversed, reversed: isReversed,
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16), divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
onChanged: (i) { onChanged: (i) {
context.reader.toPage(i.toInt()); context.reader.toPage(
context.reader.cid,
context.reader.type,
i.toInt(),
);
}, },
); );
} }
Widget buildPageInfoText() { Widget buildPageInfoText() {
var epName = context.reader.widget.chapters?.values var epName =
.elementAtOrNull(context.reader.chapter - 1) ?? context.reader.widget.chapters?.titles.elementAtOrNull(
context.reader.chapter - 1,
) ??
"E${context.reader.chapter}"; "E${context.reader.chapter}";
if (epName.length > 8) { if (epName.length > 8) {
epName = "${epName.substring(0, 8)}..."; epName = "${epName.substring(0, 8)}...";
@@ -614,99 +610,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
void openChapterDrawer() { void openChapterDrawer() {
showSideBar( showSideBar(
context, context,
_ChaptersView(context.reader), context.reader.widget.chapters!.isGrouped
? _GroupedChaptersView(context.reader)
: _ChaptersView(context.reader),
width: 400, width: 400,
); );
} }
Future<Uint8List?> _getCurrentImageData() async {
var imageKey = context.reader.images![context.reader.page - 1];
var reader = context.reader;
if (context.reader.mode.isContinuous) {
var continuesState =
context.reader._imageViewController as _ContinuousModeState;
var imagesOnScreen =
continuesState.itemPositionsListener.itemPositions.value;
var images = imagesOnScreen
.map((e) => context.reader.images!.elementAtOrNull(e.index - 1))
.whereType<String>()
.toList();
String? selected;
if (images.length > 1) {
await showPopUpWidget(
context,
PopUpWidgetScaffold(
title: "Select an image on screen".tl,
body: GridView.builder(
itemCount: images.length,
itemBuilder: (context, index) {
ImageProvider image;
var imageKey = images[index];
if (imageKey.startsWith('file://')) {
image = FileImage(File(imageKey.replaceFirst("file://", '')));
} else {
image = ReaderImageProvider(
imageKey,
reader.type.comicSource!.key,
reader.cid,
reader.eid,
reader.page,
);
}
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: () {
selected = images[index];
App.rootContext.pop();
},
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
width: double.infinity,
height: double.infinity,
child: Image(
width: double.infinity,
height: double.infinity,
image: image,
),
),
).padding(const EdgeInsets.all(8));
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.7,
),
),
),
);
} else {
selected = images.first;
}
if (selected == null) {
return null;
} else {
imageKey = selected!;
}
}
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
void saveCurrentImage() async { void saveCurrentImage() async {
var data = await _getCurrentImageData(); var data = await selectImageToData();
if (data == null) { if (data == null) {
return; return;
} }
@@ -716,30 +628,41 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
void share() async { void share() async {
var data = await _getCurrentImageData(); var data = await selectImageToData();
if (data == null) { if (data == null) {
return; return;
} }
var fileType = detectFileType(data); var fileType = detectFileType(data);
var filename = "${context.reader.page}${fileType.ext}"; var filename = "${context.reader.page}${fileType.ext}";
Share.shareFile( Share.shareFile(data: data, filename: filename, mime: fileType.mime);
data: data,
filename: filename,
mime: fileType.mime,
);
} }
void openSetting() { void openSetting() {
showSideBar( showSideBar(
context, context,
ReaderSettings( ReaderSettings(
comicId: context.reader.cid,
comicSource: context.reader.type.sourceKey,
onChanged: (key) { onChanged: (key) {
if (key == "readerMode") { if (key == "readerMode") {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]); context.reader.mode = ReaderMode.fromKey(
appdata.settings.getReaderSetting(
context.reader.cid,
context.reader.type.sourceKey,
key,
),
);
} }
if (key == "enableTurnPageByVolumeKey") { if (key == "enableTurnPageByVolumeKey") {
if (appdata.settings[key]) { if (appdata.settings.getReaderSetting(
context.reader.handleVolumeEvent(); context.reader.cid,
context.reader.type.sourceKey,
key,
)) {
context.reader.handleVolumeEvent(
context.reader.cid,
context.reader.type,
);
} else { } else {
context.reader.stopVolumeEvent(); context.reader.stopVolumeEvent();
} }
@@ -776,22 +699,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
case -1: case -1:
case 1: case 1:
return Container( return SizedBox(
width: 58, width: 58,
height: 58, height: 58,
clipBehavior: Clip.antiAlias, child: Material(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), elevation: 2,
child: ValueListenableBuilder(
valueListenable: fABValue,
builder: (context, value, child) {
return Stack(
children: [
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
if (showFloatingButtonValue == 1) { if (showFloatingButtonValue == 1) {
@@ -808,35 +722,86 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
? Icons.arrow_forward_ios ? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined, : Icons.arrow_back_ios_outlined,
size: 24, size: 24,
color: Theme.of(context) color: Theme.of(context).colorScheme.onPrimaryContainer,
.colorScheme
.onPrimaryContainer,
), ),
), ),
), ),
), ),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
height: value.clamp(0, 58 * 3) / 3,
child: ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceTint
.toOpacity(0.2),
child: const SizedBox.expand(),
),
),
],
);
},
),
); );
} }
return const SizedBox(); return const SizedBox();
} }
/// If there is only one image on screen, return it.
///
/// If there are multiple images on screen,
/// show an overlay to let the user select an image.
///
/// The return value is the index of the selected image.
Future<int?> selectImage() async {
var reader = context.reader;
var imageViewController = context.reader._imageViewController;
if (imageViewController is _GalleryModeState && reader.imagesPerPage == 1) {
return reader.page - 1;
} else {
var location = await _showSelectImageOverlay();
if (location == null) {
return null;
}
var imageKey = imageViewController!.getImageKeyByOffset(location);
if (imageKey == null) {
return null;
}
return reader.images!.indexOf(imageKey);
}
}
/// Same as [selectImage], but return the image data.
Future<Uint8List?> selectImageToData() async {
var i = await selectImage();
if (i == null) {
return null;
}
var imageKey = context.reader.images![i];
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
))!.readAsBytes();
}
}
Future<Offset?> _showSelectImageOverlay() {
if (_isOpen) {
openOrClose();
}
var completer = Completer<Offset?>();
var overlay = Overlay.of(context);
OverlayEntry? entry;
entry = OverlayEntry(
builder: (context) {
return Positioned.fill(
child: _SelectImageOverlayContent(
onTap: (offset) {
completer.complete(offset);
entry!.remove();
},
onDispose: () {
if (!completer.isCompleted) {
completer.complete(null);
}
},
),
);
},
);
overlay.insert(entry);
return completer.future;
}
} }
class _BatteryWidget extends StatefulWidget { class _BatteryWidget extends StatefulWidget {
@@ -929,9 +894,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
size: 16, size: 16,
color: batteryColor, color: batteryColor,
// Stroke // Stroke
shadows: List.generate( shadows: List.generate(9, (index) {
9,
(index) {
if (index == 4) { if (index == 4) {
return null; return null;
} }
@@ -941,8 +904,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
color: context.colorScheme.onInverseSurface, color: context.colorScheme.onInverseSurface,
offset: Offset(offsetX, offsetY), offset: Offset(offsetX, offsetY),
); );
}, }).whereType<Shadow>().toList(),
).whereType<Shadow>().toList(),
), ),
Stack( Stack(
children: [ children: [
@@ -1018,77 +980,64 @@ class _ClockWidgetState extends State<_ClockWidget> {
} }
} }
class _ChaptersView extends StatefulWidget { class _SelectImageOverlayContent extends StatefulWidget {
const _ChaptersView(this.reader); const _SelectImageOverlayContent({
required this.onTap,
required this.onDispose,
});
final _ReaderState reader; final void Function(Offset) onTap;
final void Function() onDispose;
@override @override
State<_ChaptersView> createState() => _ChaptersViewState(); State<_SelectImageOverlayContent> createState() =>
_SelectImageOverlayContentState();
} }
class _ChaptersViewState extends State<_ChaptersView> { class _SelectImageOverlayContentState
bool desc = false; extends State<_SelectImageOverlayContent> {
@override
void dispose() {
widget.onDispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var chapters = widget.reader.widget.chapters!; return GestureDetector(
var current = widget.reader.chapter - 1; behavior: HitTestBehavior.opaque,
return Scaffold( onTapUp: (details) {
body: SmoothCustomScrollView( widget.onTap(details.globalPosition);
slivers: [
SliverAppbar(
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;
});
}, },
child: Container(
color: Colors.black.withAlpha(50),
child: Align(
alignment: Alignment(0, -0.8),
child: Container(
width: 232,
height: 42,
decoration: BoxDecoration(
color: context.colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: context.colorScheme.outlineVariant),
),
child: Row(
children: [
const SizedBox(width: 8),
const Icon(Icons.info_outline),
const SizedBox(width: 16),
Text(
"Click to select an image".tl,
style: TextStyle(
fontSize: 16,
color: context.colorScheme.onSurface,
), ),
), ),
], ],
), ),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (desc) {
index = chapters.length - 1 - index;
}
var chapter = chapters.values.elementAt(index);
return ListTile(
shape: Border(
left: BorderSide(
color: current == index
? context.colorScheme.primary
: Colors.transparent,
width: 4,
), ),
), ),
title: Text(
chapter,
style: current == index
? ts.withColor(context.colorScheme.primary).bold
: null,
),
onTap: () {
widget.reader.toChapter(index + 1);
Navigator.of(context).pop();
},
);
},
childCount: chapters.length,
),
),
],
), ),
); );
} }

View File

@@ -376,11 +376,16 @@ class _SearchPageState extends State<SearchPage> {
controller.text = controller.text =
controller.text.replaceLast(words[words.length - 1], ""); controller.text.replaceLast(words[words.length - 1], "");
} }
if (type != null) { final source = ComicSource.find(searchTarget);
controller.text += "${type.name}:$text "; String insert;
if (source?.onTagSuggestionSelected != null) {
insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
} else { } else {
controller.text += "$text "; var t = text;
if (t.contains(' ')) t = "'$t'";
insert = type != null ? "${type.name}:$t" : t;
} }
controller.text += "$insert ";
suggestions.clear(); suggestions.clear();
update(); update();
focusNode.requestFocus(); focusNode.requestFocus();

View File

@@ -124,7 +124,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
options = widget.options ?? const []; options = widget.options ?? const [];
validateOptions(); validateOptions();
appdata.addSearchHistory(text); appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller); suggestionsController = _SuggestionsController(controller, sourceKey);
super.initState(); super.initState();
} }
@@ -213,6 +213,8 @@ class _SuggestionsController {
final SearchBarController controller; final SearchBarController controller;
final String sourceKey;
OverlayEntry? entry; OverlayEntry? entry;
void updateWidget() { void updateWidget() {
@@ -270,7 +272,7 @@ class _SuggestionsController {
find(TagsTranslation.cosplayerTags, TranslationType.cosplayer); find(TagsTranslation.cosplayerTags, TranslationType.cosplayer);
} }
_SuggestionsController(this.controller); _SuggestionsController(this.controller, this.sourceKey);
} }
class _Suggestions extends StatefulWidget { class _Suggestions extends StatefulWidget {
@@ -400,14 +402,16 @@ class _SuggestionsState extends State<_Suggestions> {
controller.text = controller.text =
controller.text.replaceLast(words[words.length - 1], ""); controller.text.replaceLast(words[words.length - 1], "");
} }
if (text.contains(' ')) { final source = ComicSource.find(widget.controller.sourceKey);
text = "'$text'"; String insert;
} if (source?.onTagSuggestionSelected != null) {
if (type != null) { insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
controller.text += "${type.name}:$text ";
} else { } else {
controller.text += "$text "; var t = text;
if (t.contains(' ')) t = "'$t'";
insert = type != null ? "${type.name}:$t" : t;
} }
controller.text += "$insert ";
widget.controller.suggestions.clear(); widget.controller.suggestions.clear();
widget.controller.remove(); widget.controller.remove();
} }
@@ -441,6 +445,11 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var sources = ComicSource.all();
var enabled = appdata.settings['searchSources'] as List;
sources.removeWhere((e) {
return !enabled.contains(e.key);
});
return ContentDialog( return ContentDialog(
title: "Settings".tl, title: "Settings".tl,
content: Column( content: Column(
@@ -452,7 +461,7 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: ComicSource.all().map((e) { children: sources.map((e) {
return OptionChip( return OptionChip(
text: e.name.tl, text: e.name.tl,
isSelected: searchTarget == e.key, isSelected: searchTarget == e.key,

View File

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

View File

@@ -140,17 +140,6 @@ class _AppSettingsState extends State<AppSettings> {
}, },
actionTitle: 'Set'.tl, actionTitle: 'Set'.tl,
).toSliver(), ).toSliver(),
_SettingPartTitle(
title: "Log".tl,
icon: Icons.error_outline,
),
_CallbackSetting(
title: "Open Log".tl,
callback: () {
context.to(() => const LogsPage());
},
actionTitle: 'Open'.tl,
).toSliver(),
_SettingPartTitle( _SettingPartTitle(
title: "User".tl, title: "User".tl,
icon: Icons.person_outline, icon: Icons.person_outline,
@@ -204,12 +193,46 @@ class LogsPage extends StatefulWidget {
} }
class _LogsPageState extends State<LogsPage> { class _LogsPageState extends State<LogsPage> {
String logLevelToShow = "all";
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var logToShow = logLevelToShow == "all"
? Log.logs
: Log.logs.where((log) => log.level.name == logLevelToShow).toList();
return Scaffold( return Scaffold(
appBar: Appbar( appBar: Appbar(
title: const Text("Logs"), title: Text("Logs".tl),
actions: [ actions: [
IconButton(
onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
MediaQuery.of(context).padding.top + kToolbarHeight,
0.0,
0.0,
);
showMenu(context: context, position: position, items: [
PopupMenuItem(
child: Text("all"),
onTap: () => setState(() => logLevelToShow = "all")
),
PopupMenuItem(
child: Text("info"),
onTap: () => setState(() => logLevelToShow = "info")
),
PopupMenuItem(
child: Text("warning"),
onTap: () => setState(() => logLevelToShow = "warning")
),
PopupMenuItem(
child: Text("error"),
onTap: () => setState(() => logLevelToShow = "error")
),
]);
}),
icon: const Icon(Icons.filter_list_outlined)
),
IconButton( IconButton(
onPressed: () => setState(() { onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB( final RelativeRect position = RelativeRect.fromLTRB(
@@ -228,7 +251,7 @@ class _LogsPageState extends State<LogsPage> {
onTap: () { onTap: () {
Log.ignoreLimitation = true; Log.ignoreLimitation = true;
context.showMessage( context.showMessage(
message: "Only valid for this run"); message: "Only valid for this run".tl);
}, },
), ),
PopupMenuItem( PopupMenuItem(
@@ -243,9 +266,9 @@ class _LogsPageState extends State<LogsPage> {
body: ListView.builder( body: ListView.builder(
reverse: true, reverse: true,
controller: ScrollController(), controller: ScrollController(),
itemCount: Log.logs.length, itemCount: logToShow.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
index = Log.logs.length - index - 1; index = logToShow.length - index - 1;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: SelectionArea( child: SelectionArea(
@@ -264,7 +287,7 @@ class _LogsPageState extends State<LogsPage> {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1), padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(Log.logs[index].title), child: Text(logToShow[index].title),
), ),
), ),
const SizedBox( const SizedBox(
@@ -276,16 +299,16 @@ class _LogsPageState extends State<LogsPage> {
Theme.of(context).colorScheme.error, Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.errorContainer, Theme.of(context).colorScheme.errorContainer,
Theme.of(context).colorScheme.primaryContainer Theme.of(context).colorScheme.primaryContainer
][Log.logs[index].level.index], ][logToShow[index].level.index],
borderRadius: borderRadius:
const BorderRadius.all(Radius.circular(16)), const BorderRadius.all(Radius.circular(16)),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1), padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text( child: Text(
Log.logs[index].level.name, logToShow[index].level.name,
style: TextStyle( style: TextStyle(
color: Log.logs[index].level.index == 0 color: logToShow[index].level.index == 0
? Colors.white ? Colors.white
: Colors.black), : Colors.black),
), ),
@@ -293,14 +316,14 @@ class _LogsPageState extends State<LogsPage> {
), ),
], ],
), ),
Text(Log.logs[index].content), Text(logToShow[index].content),
Text(Log.logs[index].time Text(logToShow[index].time
.toString() .toString()
.replaceAll(RegExp(r"\.\w+"), "")), .replaceAll(RegExp(r"\.\w+"), "")),
TextButton( TextButton(
onPressed: () { onPressed: () {
Clipboard.setData( Clipboard.setData(
ClipboardData(text: Log.logs[index].content)); ClipboardData(text: logToShow[index].content));
}, },
child: Text("Copy".tl), child: Text("Copy".tl),
), ),
@@ -330,9 +353,9 @@ class _WebdavSettingState extends State<_WebdavSetting> {
String url = ""; String url = "";
String user = ""; String user = "";
String pass = ""; String pass = "";
bool autoSync = true;
bool isTesting = false; bool isTesting = false;
bool upload = true; bool upload = true;
@override @override
@@ -348,6 +371,15 @@ class _WebdavSettingState extends State<_WebdavSetting> {
url = configs[0]; url = configs[0];
user = configs[1]; user = configs[1];
pass = configs[2]; pass = configs[2];
autoSync = appdata.implicitData['webdavAutoSync'] ?? true;
}
void onAutoSyncChanged(bool value) {
setState(() {
autoSync = value;
appdata.implicitData['webdavAutoSync'] = value;
appdata.writeImplicitData();
});
} }
@override @override
@@ -359,8 +391,9 @@ class _WebdavSettingState extends State<_WebdavSetting> {
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
decoration: const InputDecoration( decoration: InputDecoration(
labelText: "URL", labelText: "URL",
hintText: "A valid WebDav directory URL".tl,
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
controller: TextEditingController(text: url), controller: TextEditingController(text: url),
@@ -385,6 +418,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
onChanged: (value) => pass = value, onChanged: (value) => pass = value,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
ListTile(
leading: Icon(Icons.sync),
title: Text("Auto Sync Data".tl),
contentPadding: EdgeInsets.zero,
trailing: Switch(
value: autoSync,
onChanged: onAutoSyncChanged,
),
),
const SizedBox(height: 12),
Row( Row(
children: [ children: [
Text("Operation".tl), Text("Operation".tl),
@@ -411,12 +454,60 @@ class _WebdavSettingState extends State<_WebdavSetting> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
AnimatedSize(
duration: const Duration(milliseconds: 200),
child: autoSync
? Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
"Once the operation is successful, app will automatically sync data with the server."
.tl),
),
],
),
)
: const SizedBox.shrink(),
),
const SizedBox(height: 16),
Center( Center(
child: Button.filled( child: Button.filled(
isLoading: isTesting, isLoading: isTesting,
onPressed: () async { onPressed: () async {
var oldConfig = appdata.settings['webdav']; var oldConfig = appdata.settings['webdav'];
var oldAutoSync = appdata.implicitData['webdavAutoSync'];
if (url.trim().isEmpty &&
user.trim().isEmpty &&
pass.trim().isEmpty) {
appdata.settings['webdav'] = [];
appdata.implicitData['webdavAutoSync'] = false;
appdata.writeImplicitData();
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
return;
}
appdata.settings['webdav'] = [url, user, pass]; appdata.settings['webdav'] = [url, user, pass];
appdata.implicitData['webdavAutoSync'] = autoSync;
appdata.writeImplicitData();
if (!autoSync) {
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
return;
}
setState(() { setState(() {
isTesting = true; isTesting = true;
}); });
@@ -428,12 +519,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
isTesting = false; isTesting = false;
}); });
appdata.settings['webdav'] = oldConfig; appdata.settings['webdav'] = oldConfig;
appdata.implicitData['webdavAutoSync'] = oldAutoSync;
appdata.writeImplicitData();
appdata.saveData();
context.showMessage(message: testResult.errorMessage!); context.showMessage(message: testResult.errorMessage!);
return; context.showMessage(message: "Saved Failed".tl);
} } else {
appdata.saveData(); appdata.saveData();
context.showMessage(message: "Saved".tl); context.showMessage(message: "Saved".tl);
App.rootPop(); App.rootPop();
}
}, },
child: Text("Continue".tl), child: Text("Continue".tl),
), ),

View File

@@ -0,0 +1,95 @@
part of 'settings_page.dart';
class DebugPage extends StatefulWidget {
const DebugPage({super.key});
@override
State<DebugPage> createState() => DebugPageState();
}
class DebugPageState extends State<DebugPage> {
final controller = TextEditingController();
var result = "";
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Debug".tl)),
_CallbackSetting(
title: "Reload Configs".tl,
actionTitle: "Reload".tl,
callback: () {
ComicSourceManager().reload();
},
).toSliver(),
_CallbackSetting(
title: "Open Log".tl,
callback: () {
context.to(() => const LogsPage());
},
actionTitle: 'Open'.tl,
).toSliver(),
SliverToBoxAdapter(
child: Column(
children: [
const SizedBox(height: 8),
const Text(
"JS Evaluator",
style: TextStyle(fontSize: 16),
).toAlign(Alignment.centerLeft).paddingLeft(16),
Container(
width: double.infinity,
height: 200,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: TextField(
controller: controller,
maxLines: null,
expands: true,
textAlign: TextAlign.start,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.all(8),
),
),
),
TextButton(
onPressed: () {
try {
var res = JsEngine().runCode(controller.text);
setState(() {
result = res.toString();
});
} catch (e) {
setState(() {
result = e.toString();
});
}
},
child: const Text("Run"),
).toAlign(Alignment.centerRight).paddingRight(16),
const Text(
"Result",
style: TextStyle(fontSize: 16),
).toAlign(Alignment.centerLeft).paddingLeft(16),
Container(
width: double.infinity,
height: 200,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.outline),
borderRadius: BorderRadius.circular(4),
),
child: SingleChildScrollView(
child: Text(result).paddingAll(4),
),
),
],
),
),
],
);
}
}

View File

@@ -25,8 +25,8 @@ class _ExploreSettingsState extends State<ExploreSettings> {
title: "Size of comic tile".tl, title: "Size of comic tile".tl,
settingsIndex: "comicTileScale", settingsIndex: "comicTileScale",
interval: 0.05, interval: 0.05,
min: 0.75, min: 0.5,
max: 1.25, max: 1.5,
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Explore Pages".tl, title: "Explore Pages".tl,
@@ -52,6 +52,10 @@ class _ExploreSettingsState extends State<ExploreSettings> {
title: "Show history on comic tile".tl, title: "Show history on comic tile".tl,
settingKey: "showHistoryStatusOnTile", settingKey: "showHistoryStatusOnTile",
).toSliver(), ).toSliver(),
_SwitchSetting(
title: "Reverse default chapter order".tl,
settingKey: "reverseChapterOrder",
).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Keyword blocking".tl, title: "Keyword blocking".tl,
builder: () => const _ManageBlockingWordView(), builder: () => const _ManageBlockingWordView(),
@@ -80,6 +84,24 @@ class _ExploreSettingsState extends State<ExploreSettings> {
'japanese': "Japanese", 'japanese': "Japanese",
}, },
).toSliver(), ).toSliver(),
SelectSetting(
title: "Initial Page".tl,
settingKey: "initialPage",
optionTranslation: {
'0': "Home Page".tl,
'1': "Favorites Page".tl,
'2': "Explore Page".tl,
'3': "Categories Page".tl,
},
).toSliver(),
SelectSetting(
title: "Display mode of comic list".tl,
settingKey: "comicListDisplayMode",
optionTranslation: {
"paging": "Paging".tl,
"Continuous": "Continuous".tl,
},
).toSliver(),
], ],
); );
} }

View File

@@ -1,9 +1,16 @@
part of 'settings_page.dart'; part of 'settings_page.dart';
class ReaderSettings extends StatefulWidget { class ReaderSettings extends StatefulWidget {
const ReaderSettings({super.key, this.onChanged}); const ReaderSettings({
super.key,
this.onChanged,
this.comicId,
this.comicSource,
});
final void Function(String key)? onChanged; final void Function(String key)? onChanged;
final String? comicId;
final String? comicSource;
@override @override
State<ReaderSettings> createState() => _ReaderSettingsState(); State<ReaderSettings> createState() => _ReaderSettingsState();
@@ -21,6 +28,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("enableTapToTurnPages"); widget.onChanged?.call("enableTapToTurnPages");
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Reverse tap to turn Pages".tl, title: "Reverse tap to turn Pages".tl,
@@ -28,6 +37,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("reverseTapToTurnPages"); widget.onChanged?.call("reverseTapToTurnPages");
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Page animation".tl, title: "Page animation".tl,
@@ -35,6 +46,15 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("enablePageAnimation"); widget.onChanged?.call("enablePageAnimation");
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(),
_SwitchSetting(
title: "Enable comic specific settings".tl,
settingKey: "enableComicSpecificSettings",
onChanged: () {
widget.onChanged?.call("enableComicSpecificSettings");
},
).toSliver(), ).toSliver(),
SelectSetting( SelectSetting(
title: "Reading mode".tl, title: "Reading mode".tl,
@@ -48,13 +68,18 @@ class _ReaderSettingsState extends State<ReaderSettings> {
"continuousTopToBottom": "Continuous (Top to Bottom)".tl, "continuousTopToBottom": "Continuous (Top to Bottom)".tl,
}, },
onChanged: () { onChanged: () {
setState(() {});
var readerMode = appdata.settings['readerMode']; var readerMode = appdata.settings['readerMode'];
if (readerMode?.toLowerCase().startsWith('continuous') ?? false) { if (readerMode?.toLowerCase().startsWith('continuous') ?? false) {
appdata.settings['readerScreenPicNumber'] = 1; appdata.settings['readerScreenPicNumberForLandscape'] = 1;
widget.onChanged?.call('readerScreenPicNumber'); widget.onChanged?.call('readerScreenPicNumberForLandscape');
appdata.settings['readerScreenPicNumberForPortrait'] = 1;
widget.onChanged?.call('readerScreenPicNumberForPortrait');
} }
widget.onChanged?.call("readerMode"); widget.onChanged?.call("readerMode");
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
_SliderSetting( _SliderSetting(
title: "Auto page turning interval".tl, title: "Auto page turning interval".tl,
@@ -63,43 +88,95 @@ class _ReaderSettingsState extends State<ReaderSettings> {
min: 1, min: 1,
max: 20, max: 20,
onChanged: () { onChanged: () {
setState(() {});
widget.onChanged?.call("autoPageTurningInterval"); widget.onChanged?.call("autoPageTurningInterval");
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
SliverToBoxAdapter( SliverAnimatedVisibility(
child: AbsorbPointer( visible: appdata.settings['readerMode']!.startsWith('gallery'),
absorbing: (appdata.settings['readerMode']
?.toLowerCase()
.startsWith('continuous') ??
false),
child: AnimatedOpacity(
opacity: (appdata.settings['readerMode']
?.toLowerCase()
.startsWith('continuous') ??
false)
? 0.5
: 1.0,
duration: Duration(milliseconds: 300),
child: _SliderSetting( child: _SliderSetting(
title: "The number of pic in screen (Only Gallery Mode)".tl, title:
settingsIndex: "readerScreenPicNumber", "The number of pic in screen for landscape (Only Gallery Mode)"
.tl,
settingsIndex: "readerScreenPicNumberForLandscape",
interval: 1, interval: 1,
min: 1, min: 1,
max: 5, max: 5,
onChanged: () { onChanged: () {
widget.onChanged?.call("readerScreenPicNumber"); setState(() {});
widget.onChanged?.call("readerScreenPicNumberForLandscape");
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
), ),
), ),
SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('gallery'),
child: _SliderSetting(
title:
"The number of pic in screen for portrait (Only Gallery Mode)"
.tl,
settingsIndex: "readerScreenPicNumberForPortrait",
interval: 1,
min: 1,
max: 5,
onChanged: () {
widget.onChanged?.call("readerScreenPicNumberForPortrait");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
), ),
), ),
SliverAnimatedVisibility(
visible:
appdata.settings['readerMode']!.startsWith('gallery') &&
(appdata.settings['readerScreenPicNumberForLandscape'] > 1 ||
appdata.settings['readerScreenPicNumberForPortrait'] > 1),
child: _SwitchSetting(
title: "Show single image on first page".tl,
settingKey: "showSingleImageOnFirstPage",
onChanged: () {
widget.onChanged?.call("showSingleImageOnFirstPage");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
),
),
_SwitchSetting(
title: 'Double tap to zoom'.tl,
settingKey: 'enableDoubleTapToZoom',
onChanged: () {
setState(() {});
widget.onChanged?.call('enableDoubleTapToZoom');
},
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(),
_SwitchSetting( _SwitchSetting(
title: 'Long press to zoom'.tl, title: 'Long press to zoom'.tl,
settingKey: 'enableLongPressToZoom', settingKey: 'enableLongPressToZoom',
onChanged: () { onChanged: () {
setState(() {});
widget.onChanged?.call('enableLongPressToZoom'); widget.onChanged?.call('enableLongPressToZoom');
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
SliverAnimatedVisibility(
visible: appdata.settings['enableLongPressToZoom'] == true,
child: SelectSetting(
title: "Long press zoom position".tl,
settingKey: "longPressZoomPosition",
optionTranslation: {
"press": "Press position".tl,
"center": "Screen center".tl,
},
comicId: widget.comicId,
comicSource: widget.comicSource,
),
),
_SwitchSetting( _SwitchSetting(
title: 'Limit image width'.tl, title: 'Limit image width'.tl,
subtitle: 'When using Continuous(Top to Bottom) mode'.tl, subtitle: 'When using Continuous(Top to Bottom) mode'.tl,
@@ -107,6 +184,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call('limitImageWidth'); widget.onChanged?.call('limitImageWidth');
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
if (App.isAndroid) if (App.isAndroid)
_SwitchSetting( _SwitchSetting(
@@ -115,6 +194,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call('enableTurnPageByVolumeKey'); widget.onChanged?.call('enableTurnPageByVolumeKey');
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Display time & battery info in reader".tl, title: "Display time & battery info in reader".tl,
@@ -122,6 +203,17 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () { onChanged: () {
widget.onChanged?.call("enableClockAndBatteryInfoInReader"); widget.onChanged?.call("enableClockAndBatteryInfoInReader");
}, },
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(),
_SwitchSetting(
title: "Show system status bar".tl,
settingKey: "showSystemStatusBar",
onChanged: () {
widget.onChanged?.call("showSystemStatusBar");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
SelectSetting( SelectSetting(
title: "Quick collect image".tl, title: "Quick collect image".tl,
@@ -137,6 +229,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
help: help:
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode" "On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
.tl, .tl,
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
_CallbackSetting( _CallbackSetting(
title: "Custom Image Processing".tl, title: "Custom Image Processing".tl,
@@ -149,7 +243,48 @@ class _ReaderSettingsState extends State<ReaderSettings> {
interval: 1, interval: 1,
min: 1, min: 1,
max: 16, max: 16,
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(), ).toSliver(),
_SwitchSetting(
title: "Show Page Number".tl,
settingKey: "showPageNumberInReader",
onChanged: () {
widget.onChanged?.call("showPageNumberInReader");
},
comicId: widget.comicId,
comicSource: widget.comicSource,
).toSliver(),
// reset button
SliverToBoxAdapter(
child: TextButton(
onPressed: () {
if (widget.comicId == null) {
appdata.settings.resetAllComicReaderSettings();
} else {
var keys = appdata
.settings['comicSpecificSettings']["${widget.comicId}@${widget.comicSource}"]
?.keys;
appdata.settings.resetComicReaderSettings(
widget.comicId!,
widget.comicSource!,
);
if (keys != null) {
setState(() {});
for (var key in keys) {
widget.onChanged?.call(key);
}
}
}
},
child: Text(
(widget.comicId == null
? "Clear specific reader settings for all comics"
: "Clear specific reader settings for this comic")
.tl,
),
),
),
], ],
); );
} }
@@ -194,7 +329,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
setState(() {}); setState(() {});
}, },
child: Text("Reset".tl), child: Text("Reset".tl),
) ),
], ],
), ),
body: Column( body: Column(
@@ -220,7 +355,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
), ),
), ),
), ),
) ),
], ],
), ),
); );

View File

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

View File

@@ -30,6 +30,7 @@ part 'local_favorites.dart';
part 'app.dart'; part 'app.dart';
part 'about.dart'; part 'about.dart';
part 'network.dart'; part 'network.dart';
part 'debug.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
const SettingsPage({this.initialPage = -1, super.key}); const SettingsPage({this.initialPage = -1, super.key});
@@ -55,6 +56,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
"APP", "APP",
"Network", "Network",
"About", "About",
"Debug"
]; ];
final icons = <IconData>[ final icons = <IconData>[
@@ -64,7 +66,8 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
Icons.collections_bookmark_rounded, Icons.collections_bookmark_rounded,
Icons.apps, Icons.apps,
Icons.public, Icons.public,
Icons.info Icons.info,
Icons.bug_report,
]; ];
double offset = 0; double offset = 0;
@@ -246,6 +249,9 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
} }
void handlePointerDown(PointerDownEvent event) { void handlePointerDown(PointerDownEvent event) {
if (!App.isIOS) {
return;
}
if (event.position.dx < 20) { if (event.position.dx < 20) {
gestureRecognizer.addPointer(event); gestureRecognizer.addPointer(event);
} }
@@ -350,6 +356,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
4 => const AppSettings(), 4 => const AppSettings(),
5 => const NetworkSettings(), 5 => const NetworkSettings(),
6 => const AboutSettings(), 6 => const AboutSettings(),
7 => const DebugPage(),
_ => throw UnimplementedError() _ => throw UnimplementedError()
}; };
} }

View File

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

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_7zip/flutter_7zip.dart'; import 'package:flutter_7zip/flutter_7zip.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -140,8 +141,7 @@ abstract class CBZ {
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)), FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
); );
dest.createSync(); dest.createSync();
coverFile.copyMem( coverFile.copyMem(FilePath.join(dest.path, 'cover.${coverFile.extension}'));
FilePath.join(dest.path, 'cover.${coverFile.extension}'));
if (metaData.chapters == null) { if (metaData.chapters == null) {
for (var i = 0; i < files.length; i++) { for (var i = 0; i < files.length; i++) {
var src = files[i]; var src = files[i];
@@ -176,7 +176,7 @@ abstract class CBZ {
tags: metaData.tags, tags: metaData.tags,
comicType: ComicType.local, comicType: ComicType.local,
directory: dest.name, directory: dest.name,
chapters: cpMap, chapters: ComicChapters.fromJsonOrNull(cpMap),
downloadedChapters: cpMap?.keys.toList() ?? [], downloadedChapters: cpMap?.keys.toList() ?? [],
cover: 'cover.${coverFile.extension}', cover: 'cover.${coverFile.extension}',
createdAt: DateTime.now(), createdAt: DateTime.now(),
@@ -232,17 +232,19 @@ abstract class CBZ {
} }
} }
var cover = comic.coverFile; var cover = comic.coverFile;
await cover await cover.copyMem(
.copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}')); FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString( final metaData = ComicMetaData(
jsonEncode(
ComicMetaData(
title: comic.title, title: comic.title,
author: comic.subtitle, author: comic.subtitle,
tags: comic.tags, tags: comic.tags,
chapters: chapters, chapters: chapters,
).toJson(), );
), await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
jsonEncode(metaData),
);
await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString(
_buildComicInfoXml(metaData),
); );
var cbz = File(outFilePath); var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync(); if (cbz.existsSync()) cbz.deleteSync();
@@ -251,7 +253,54 @@ abstract class CBZ {
return cbz; return cbz;
} }
static String _buildComicInfoXml(ComicMetaData data) {
final buffer = StringBuffer();
buffer.writeln('<?xml version="1.0" encoding="utf-8"?>');
buffer.writeln('<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">');
buffer.writeln(' <Title>${_escapeXml(data.title)}</Title>');
buffer.writeln(' <Series>${_escapeXml(data.title)}</Series>');
if (data.author.isNotEmpty) {
buffer.writeln(' <Writer>${_escapeXml(data.author)}</Writer>');
}
if (data.tags.isNotEmpty) {
var tags = data.tags;
if (tags.length > 5) {
tags = tags.sublist(0, 5);
}
buffer.writeln(' <Genre>${_escapeXml(tags.join(', '))}</Genre>');
}
if (data.chapters != null && data.chapters!.isNotEmpty) {
final chaptersInfo = data.chapters!.map((chapter) =>
'${_escapeXml(chapter.title)}: ${chapter.start}-${chapter.end}'
).join('; ');
buffer.writeln(' <Notes>Chapters: $chaptersInfo</Notes>');
}
buffer.writeln(' <Manga>Unknown</Manga>');
buffer.writeln(' <BlackAndWhite>Unknown</BlackAndWhite>');
final now = DateTime.now();
buffer.writeln(' <Year>${now.year}</Year>');
buffer.writeln('</ComicInfo>');
return buffer.toString();
}
static String _escapeXml(String text) {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
static _compress(String src, String dst) async { static _compress(String src, String dst) async {
await ZipFile.compressFolderAsync(src, dst, 4); await ZipFile.compressFolderAsync(src, dst, 4);
} }
} }

View File

@@ -0,0 +1,25 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/services.dart';
Future<void> writeImageToClipboard(Uint8List imageBytes) async {
const channel = MethodChannel("venera/clipboard");
if (Platform.isWindows || Platform.isLinux) {
var image = await instantiateImageCodec(imageBytes);
var frame = await image.getNextFrame();
var data = await frame.image.toByteData(format: ImageByteFormat.rawRgba);
await channel.invokeMethod("writeImageToClipboard", {
"width": frame.image.width,
"height": frame.image.height,
"data": Uint8List.view(data!.buffer)
});
image.dispose();
} else if (Platform.isMacOS) {
await channel.invokeMethod("writeImageToClipboard", {
"data": imageBytes,
});
} else {
throw UnsupportedError("Clipboard image is not supported on this platform");
}
}

View File

@@ -95,15 +95,17 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
} }
var comicSourceDir = FilePath.join(cacheDirPath, "comic_source"); var comicSourceDir = FilePath.join(cacheDirPath, "comic_source");
if (Directory(comicSourceDir).existsSync()) { if (Directory(comicSourceDir).existsSync()) {
Directory(FilePath.join(App.dataPath, "comic_source"))
.deleteIfExistsSync(recursive: true);
Directory(FilePath.join(App.dataPath, "comic_source")).createSync();
for (var file in Directory(comicSourceDir).listSync()) { for (var file in Directory(comicSourceDir).listSync()) {
if (file is File) { if (file is File) {
var targetFile = var targetFile =
FilePath.join(App.dataPath, "comic_source", file.name); FilePath.join(App.dataPath, "comic_source", file.name);
File(targetFile).deleteIfExistsSync();
await file.copy(targetFile); await file.copy(targetFile);
} }
} }
await ComicSource.reload(); await ComicSourceManager().reload();
} }
} finally { } finally {
cacheDir.deleteIgnoreError(recursive: true); cacheDir.deleteIgnoreError(recursive: true);

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