503 Commits

Author SHA1 Message Date
nyne
dbc2c27db0 Merge pull request #245 from venera-app/v1.3.2-dev
v1.3.2
2025-03-06 13:16:57 +08:00
fffb3dc973 Use rhttp to make webdav requests. 2025-03-05 21:47:28 +08:00
0ca8a28639 Update version code. 2025-03-05 17:46:07 +08:00
6426ebaf16 Add initial page setting. Close #240 2025-03-05 17:44:20 +08:00
316f61394d Try to fix #241 2025-03-04 22:17:21 +08:00
04ab75cf92 Fix WindowFrame on Android. 2025-03-04 21:50:22 +08:00
4828a57e1a Improve follow updates. Close #235 2025-03-04 19:30:24 +08:00
d089163220 Fix comment overflow. Close #237 2025-03-04 15:36:02 +08:00
7b5c13200d Improve init. Close #236 2025-03-04 15:30:40 +08:00
0f6874f8d7 Close reader when user click the close button on window frame. 2025-03-03 20:50:11 +08:00
4af15b9139 Improve fullscreen 2025-03-03 19:28:20 +08:00
9fe49217dc Add error status to data sync component. 2025-03-03 19:04:16 +08:00
76c56964a5 Fix archive download when using custom download path on Android. 2025-03-02 17:40:04 +08:00
e8afbca7b2 Fix empty reader page when current chapter is last chapter of the chapter group. 2025-03-01 09:29:04 +08:00
5843d7c919 Fix sidebar. 2025-03-01 09:20:33 +08:00
shenmo
de98dfaa1b Fix font problem on Linux ARM64 (#231) 2025-02-27 23:04:36 +08:00
AnxuNA
30cbfb54ef Fix Fullscreen switch (#229)
* Fix Fullscreen switch for windows

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

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

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

Without this fix, when opening the search page for the first time with aggregatedSearch enabled by default, entering an ID that matches a comic source does not trigger the "Open comic" suggestion. However, after toggling aggregatedSearch off and then back on, the same ID input correctly displays the suggestion.
2025-02-07 17:03:52 +08:00
58d6ccdde1 Fix an issue where an application turns to a white screen after finishing cloudflare verification. Close #169 2025-02-05 21:21:20 +08:00
23404b86f6 Record the last state of the favorite pane. 2025-02-05 20:40:14 +08:00
UjuiUjuMandan
965187e9de replace raw.githubusercontent.com 2025-02-05 20:21:15 +08:00
nyne
24155746f2 Merge pull request #166 from venera-app/dev
v1.2.3
2025-02-01 16:35:34 +08:00
340496da30 Fix cloudflare bypass 2025-02-01 16:24:43 +08:00
28a56b4612 Update version code 2025-02-01 15:56:57 +08:00
4e6f71ef36 Merge account page and comic source page. 2025-02-01 15:54:52 +08:00
739685f60f Fix crash when using cbz export on iOS and macOS.
Close #164
2025-02-01 10:11:34 +08:00
8c5dae1e59 Fix empty page.
Close #160
2025-01-31 13:27:22 +08:00
e2c69d882f Fix image order.
Close #159
2025-01-31 13:11:04 +08:00
0b9f0b7d35 Improve downloading message.
Close #165
2025-01-31 13:08:24 +08:00
9ea749a84a login with webview on windows and linux.
fix #162, fix #141
2025-01-31 11:53:06 +08:00
d675af3fb4 fix cloudflare verification 2025-01-31 10:46:24 +08:00
d99a30b7d8 Update desktop file 2025-01-30 17:49:01 +08:00
nyne
3c3c07b6fb fix #163 2025-01-28 17:04:13 +08:00
nyne
e688ab759a Merge pull request #161 from UjuiUjuMandan/debug
move out applicationVariants.all
2025-01-27 16:34:18 +08:00
UjuiUjuMandan
64a3ef352f move out applicationVariants.all 2025-01-27 07:04:15 +00:00
ef8dc9e8d4 fix #158 2025-01-26 18:36:35 +08:00
nyne
19af2d79dd Merge pull request #157 from venera-app/dev
v1.2.2
2025-01-26 14:29:13 +08:00
5a11168f98 fix #151 2025-01-26 14:04:24 +08:00
1564156e28 Improve download retries.
Close https://github.com/venera-app/venera-configs/issues/39
2025-01-26 13:29:40 +08:00
2534c55ffb Improve UI of empty Explore and Category pages. 2025-01-26 12:35:49 +08:00
ba4eff66db Update version code 2025-01-25 16:57:55 +08:00
b43d907763 fix #156 2025-01-25 16:55:06 +08:00
f5a814cfe4 Improve UI 2025-01-25 16:50:04 +08:00
24b9bcd86e fix #155 2025-01-25 16:26:24 +08:00
812b36d1e9 Add buttons for adding pages 2025-01-25 12:23:30 +08:00
bab2578b65 Fix mouse scroll 2025-01-25 11:19:36 +08:00
5cf2f9f33a Update theme 2025-01-25 11:10:00 +08:00
040a5d7ad2 Update flutter_qjs 2025-01-24 19:37:24 +08:00
69da66904a Add debug config 2025-01-24 19:21:56 +08:00
11e4d7a9f2 Fix pdf 2025-01-24 19:20:57 +08:00
7bd0c2b82a Reduce app size 2025-01-24 18:06:23 +08:00
6b0a5184b9 Remove text_scroll & Improve layout 2025-01-24 11:06:54 +08:00
864980079b Remove text_scroll & Improve layout 2025-01-24 11:06:26 +08:00
de51b66d39 Fix layout 2025-01-23 23:23:18 +08:00
23205c518d Improve thumbnail 2025-01-23 19:42:49 +08:00
3ae5c7c7f2 Improve thumbnail 2025-01-23 19:08:38 +08:00
312e991935 Importing data does not require restarting 2025-01-23 18:27:46 +08:00
5184130ff8 Improve ui 2025-01-23 18:21:42 +08:00
e555779419 support strong label in comments 2025-01-23 16:42:31 +08:00
5ef973cbfb improve downloading data 2025-01-22 22:03:46 +08:00
8e2520f8e8 improve code editor 2025-01-22 22:02:16 +08:00
87f0f5bb55 improve cache 2025-01-22 21:58:14 +08:00
nyne
578c06fdc1 v1.2.1 2025-01-21 16:02:01 +08:00
8645dda967 v1.2.1 2025-01-21 15:38:52 +08:00
ded9055363 Update flutter_qjs 2025-01-21 15:37:46 +08:00
ff42c726fa Fix network header 2025-01-21 15:15:11 +08:00
53b033258a Fix ios build 2025-01-20 21:31:17 +08:00
6ec4817dc1 Fix ios and macos build 2025-01-20 21:17:08 +08:00
283afbc6d4 Improve ui api 2025-01-20 21:06:45 +08:00
c3a09c8870 Update flutter_qjs 2025-01-20 20:48:16 +08:00
f2388c81e0 Lower iOS version requirements 2025-01-20 20:21:43 +08:00
c334e4fa05 Add a setting for comic source list url 2025-01-20 19:28:03 +08:00
nyne
cc8277d462 Update import_comic.md 2025-01-20 19:17:36 +08:00
e6b7f5b014 Move help to GitHub 2025-01-20 19:15:06 +08:00
1edf284709 Add doc 2025-01-20 19:06:20 +08:00
6033a3cde9 Add app api 2025-01-20 15:18:16 +08:00
27e7356721 Upgrade to flutter 3.27.2 2025-01-20 15:09:48 +08:00
d88ae57320 Add select dialog 2025-01-20 15:02:36 +08:00
7b7710b441 Update flutter_qjs 2025-01-19 22:35:00 +08:00
63346396e0 Add input dialog 2025-01-19 20:55:53 +08:00
51b7df02e7 Improve ui api 2025-01-19 20:36:17 +08:00
nyne
811fbb04dc Merge pull request #147 from UjuiUjuMandan/rust
rustup default stable
2025-01-19 16:40:47 +08:00
UjuiUjuMandan
eaf94363ae rustup default stable 2025-01-19 08:17:27 +00:00
5e3ff48d35 fix explore page 2025-01-19 10:06:52 +08:00
c6ec38632f fix data sync 2025-01-19 10:05:08 +08:00
1c1f418019 fix UI api 2025-01-18 22:55:34 +08:00
nyne
b6e5035509 v1.2.0
v1.2.0
2025-01-18 18:27:08 +08:00
52410bac03 update windows build script 2025-01-18 17:23:22 +08:00
0a187cca2e fix #144 2025-01-18 17:13:20 +08:00
dda8d98e85 Add UI api 2025-01-18 16:53:05 +08:00
1abf9c151e Fix setTimeout 2025-01-18 16:24:46 +08:00
d9084272e5 Add callback setting 2025-01-18 16:07:16 +08:00
16512f2711 Improve config updates check 2025-01-18 15:43:22 +08:00
481bb97301 fix #143 2025-01-18 12:26:20 +08:00
950690df48 update flutter_7zip 2025-01-18 11:57:12 +08:00
825ef39605 fix #142 2025-01-18 11:43:49 +08:00
5f36ef6ea3 support 7z;
fix #137
2025-01-17 22:30:25 +08:00
bfd115046d fix #140 2025-01-16 19:17:18 +08:00
4c6e4373e9 Improve cache 2025-01-16 18:28:49 +08:00
6467a46e5c Add fetch 2025-01-16 17:58:47 +08:00
0011738820 Update version code 2025-01-16 17:52:30 +08:00
c640e6bfbf Improve image loading 2025-01-16 17:51:43 +08:00
5d1d62e157 fix history database 2025-01-15 18:31:15 +08:00
399b9abaee Improve UI 2025-01-15 18:24:38 +08:00
luckyray
d874920c88 Feat: Image favorites (#126)
* feat: 增加图片收藏

* feat: 主体图片收藏页面实现

* feat: 点击打开大图浏览

* feat: 数据结构变更

* feat: 基本完成

* feat: 翻译与bug修复

* feat: 实机测试和问题修复

* feat: jm导入, pica历史记录nhentai有问题, 一键反转

* fix: 大小写不一致, 一个htManga, 一个htmanga

* feat: 拉取收藏优化

* feat: 改成以ep为准

* feat: 兜底一些可能报错场景

* chore: 没有用到

* feat: 尽量保证和网络收藏顺序一致

* feat: 支持显示热点tag

* feat: 支持双击收藏, 不过此时禁止放大图片

* fix: 自动塞封面逻辑完善, 切换快速收藏图片立刻生效

* Refactor

* fix updateValue

* feat: 双击功能提示

* fix: 被确定取消收藏的才删除

* Refactor ImageFavoritesPage

* translate author

* feat: 功能提示改到dialog中

* fix text editing

* fix text editing

* feat: 功能提示放到邮件或长按菜单中

* fix: 修复tag过滤不生效问题

* Improve image loading

* The default value of quickCollectImage should be false.

* Refactor DragListener

* Refactor ImageFavoriteItem & ImageFavoritePhotoView

* Refactor

* Fix `ImageFavoriteManager.has`

* Fix UI

* Improve UI

---------

Co-authored-by: nyne <me@nyne.dev>
2025-01-15 16:07:08 +08:00
Pacalini
213c225e1e fix #131 (#136) 2025-01-13 13:59:42 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
d55c0aa325 Add Get it on F-Droid badge (#101) 2025-01-11 19:28:41 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
2d6e76a5a6 clean up full_description.txt (#133) 2025-01-11 17:52:42 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
2968f1fa29 Phone screenshots (#132)
* clean up full_description.txt

* ratio up to 2.3

* add 4 screenshots

* add 3 missing screenshots
2025-01-11 13:55:10 +08:00
72228515f6 validate params 2025-01-08 16:32:18 +08:00
nyne
b56f8d7398 Merge pull request #129 from UjuiUjuMandan/patch-2
Setting `checkUpdateOnStart` to false by default
2025-01-06 22:44:22 +08:00
nyne
8375fb721e Merge pull request #130 from venera-app/dev
v1.1.4
2025-01-06 22:42:09 +08:00
9876da85da Add "Check for updates on startup" setting. #129 2025-01-06 22:38:05 +08:00
4b19ab57d2 fix menu overflow 2025-01-06 22:10:01 +08:00
91ee48cc6c Improve comic display 2025-01-06 21:41:52 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
7495c11944 Setting checkUpdateOnStart to false by default 2025-01-06 19:50:22 +08:00
08e8a45236 Update version code 2025-01-06 11:06:02 +08:00
fb1b017bc9 fix #127 2025-01-06 11:05:20 +08:00
99a3788f4a fix #128 2025-01-06 10:55:26 +08:00
a747179cc4 sni 2025-01-06 10:15:23 +08:00
1ca8da1c83 improve editor 2024-12-31 15:50:28 +08:00
8eddab5e13 line numbers in editor 2024-12-31 15:05:11 +08:00
030007159d fix editor font 2024-12-31 13:56:11 +08:00
43a054c12a code highlight 2024-12-31 13:36:00 +08:00
51a6456dad Use the built-in editor to edit the config file if vscode is not installed. 2024-12-31 12:36:47 +08:00
3a320feda9 fix #107 2024-12-31 12:05:56 +08:00
nyne
a88bbe9ea6 Merge pull request #123 from Pacalini/cert
revert #46
2024-12-31 09:27:54 +08:00
Pacalini
5be2dbcfd7 revert #46 2024-12-31 08:54:01 +08:00
68a203a1c1 Sync data after changing settings 2024-12-30 23:12:46 +08:00
c06709aeb7 DNS overrides 2024-12-30 22:55:01 +08:00
95649ca9fe upgrade gradle 2024-12-30 21:59:24 +08:00
1e09d69507 improve cbz export 2024-12-30 21:58:23 +08:00
nyne
a5c745f40d Merge pull request #119 from venera-app/dev
v1.1.3
2024-12-26 19:31:21 +08:00
d27efb180a V1.1.3 2024-12-26 19:19:28 +08:00
1f5382ff8c Fixed the issue of not storing maxPage.
fix #112
2024-12-26 14:12:13 +08:00
2238fcc68f continue reading 2024-12-26 13:59:54 +08:00
df42cf320c add scrollbar to local favorites page 2024-12-26 13:43:28 +08:00
eb14f973e4 add "View Detail" option 2024-12-26 12:06:05 +08:00
99454041d3 fix pinch to zoom 2024-12-26 10:55:31 +08:00
1ae33c43b1 fix #115 2024-12-26 10:32:15 +08:00
bed30d3cea fix #116 2024-12-26 10:03:40 +08:00
06f953c1bc fix history length 2024-12-23 22:52:03 +08:00
0b96d01afb Improve local comics page 2024-12-22 18:07:13 +08:00
6023e462d7 Improve init 2024-12-22 11:31:57 +08:00
0e22574002 fix #111 2024-12-22 11:22:59 +08:00
e1b2f83c48 fix selecting image 2024-12-22 11:14:53 +08:00
e77424e00e fix #109 2024-12-21 18:08:32 +08:00
9f67cd0d07 fix #110 2024-12-21 17:51:54 +08:00
6a79f68909 fix color 2024-12-21 17:50:05 +08:00
aa66111f2c fix updating comic info 2024-12-21 17:38:59 +08:00
ddeaaf0856 fix desktop file 2024-12-21 17:27:17 +08:00
18f450a0db Update README.md 2024-12-19 10:46:15 +08:00
a217b86c08 typo 2024-12-19 10:11:42 +08:00
nyne
79d2c91723 Create analyse.dart 2024-12-19 10:09:20 +08:00
nyne
731510e11d Merge pull request #108 from UjuiUjuMandan/evil
Remove DependencyInfoBlock
2024-12-18 23:18:27 +08:00
UjuiUjuMandan
b3d3c141f9 Remove DependencyInfoBlock 2024-12-18 13:53:23 +00:00
nyne
bea861a83c Merge pull request #105 from UjuiUjuMandan/patch-2
Fix scope
2024-12-18 20:26:01 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
4a595a8aca Update build.gradle 2024-12-18 20:24:21 +08:00
nyne
bf634f8654 Merge pull request #104 from venera-app/dev
v1.1.2
2024-12-18 20:11:11 +08:00
nyne
bda215ebb7 Merge branch 'master' into dev 2024-12-18 20:10:41 +08:00
a70b690d3c Run dart fix 2024-12-18 20:07:35 +08:00
0b8ae2d377 Update version code 2024-12-18 20:05:59 +08:00
24c5a1bb01 Improve local comics page 2024-12-18 20:04:45 +08:00
ea973a2787 fix #92 2024-12-18 19:36:54 +08:00
nyne
17bce96143 Merge pull request #103 from UjuiUjuMandan/abivercode
Add abiVersionCode & Remove x86
2024-12-18 19:17:58 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
909c0014ac * 10 for universal 2024-12-18 19:15:45 +08:00
eb1abfc02a Fixed the issue where the images of multi-chapter comic are downloaded to invalid folder. 2024-12-18 19:13:35 +08:00
UjuiUjuMandan
788e41f584 Add abiVersionCode & Remove x86 2024-12-18 10:50:19 +00:00
929ec88e84 Fixed issue where deleting a download caused favourites to be deleted. 2024-12-18 17:58:18 +08:00
abaeaf4f77 improve mouse hover effects on click areas 2024-12-18 17:37:03 +08:00
nyne
a614e83470 Merge pull request #102 from UjuiUjuMandan/rb
update dependencies again
2024-12-18 17:22:30 +08:00
8b9fd0d03d improve pop_up_widget and side_bar in dark mode 2024-12-18 17:19:34 +08:00
UjuiUjuMandan
1964c4c0d5 update dependencies again 2024-12-18 09:16:28 +00:00
43d724dd27 fix #97 2024-12-18 17:08:03 +08:00
f9c42aef4b fix #98 2024-12-18 16:51:57 +08:00
06a6e5156a Fix minimum support platform version 2024-12-18 15:48:35 +08:00
deltamaya
be45a06981 update minimum support platform version 2024-12-18 15:25:32 +08:00
4763b9c7b4 test zip_flutter 2024-12-18 15:14:27 +08:00
7e608be70f test zip_flutter 2024-12-18 14:15:08 +08:00
211e6ab8c8 update dependencies 2024-12-18 13:29:43 +08:00
nyne
100dc6458b Merge pull request #100 from UjuiUjuMandan/master
F-Droid
2024-12-17 22:56:39 +08:00
UjuiUjuMandan
8dab5f9e88 test fastlane
add icon.png

add icon.png

scale to 512x512

metadata for zh-CN

Revert "metadata for zh-CN"

This reverts commit 77b30b9209dd1b082f050c55fa175fa96afbfcf6.
2024-12-17 14:18:41 +00:00
d08383e14b disable Impeller 2024-12-17 20:11:18 +08:00
a55e4eff67 Update to flutter 3.27.1 & Fix android build 2024-12-17 17:21:10 +08:00
ab3953292b fix https://github.com/venera-app/venera-configs/issues/28 2024-12-17 13:01:44 +08:00
b49e0974ab improve zip 2024-12-17 12:10:57 +08:00
nyne
b6cccb7749 update version code 2024-12-13 09:34:37 +08:00
nyne
dac07cfac4 Fix windows build script 2024-12-13 09:30:28 +08:00
nyne
da12b3bcca Fix favorites_page 2024-12-13 09:27:53 +08:00
nyne
017f964705 [Android] Disable Impeller 2024-12-13 09:25:09 +08:00
nyne
bed0f78e81 Merge pull request #96 from venera-app/dev
v1.1.0-patch
2024-12-12 23:30:44 +08:00
nyne
092eb59c10 fix #94 2024-12-12 23:28:54 +08:00
nyne
a5d3d160c8 fix #95 2024-12-12 23:22:19 +08:00
nyne
d3c3748ce5 Update app.dart 2024-12-12 22:07:42 +08:00
nyne
586874de15 Merge pull request #93 from venera-app/dev
v1.1.0
2024-12-12 21:22:26 +08:00
bda2c6c2e1 Merge remote-tracking branch 'origin/dev' into dev 2024-12-12 21:17:54 +08:00
e9aa6fcf30 download comics in local favorites page 2024-12-12 21:17:30 +08:00
60c6be08c5 fix #87: Add translated_tags field to all local favorite table. 2024-12-12 21:17:30 +08:00
e4e2d264f5 update flutter to 3.27.0 & update packages 2024-12-12 21:17:30 +08:00
c2cfd066f6 update flutter_memory_info 2024-12-12 21:17:30 +08:00
d7b91f6a50 fix #91 2024-12-12 21:17:30 +08:00
da025b16ff improve performance & ui 2024-12-12 21:17:30 +08:00
08e0082186 improve explore page loading 2024-12-12 21:17:30 +08:00
463805f5ed Improve TabBar 2024-12-12 21:17:30 +08:00
72b146a9bf Use PageStorage to store state 2024-12-12 21:17:30 +08:00
1104d28f14 improve ui 2024-12-12 21:17:30 +08:00
cf7be85f29 aggregated search 2024-12-12 21:17:30 +08:00
cab66619df fix #61 2024-12-12 21:17:30 +08:00
bdd0724788 delete cache 2024-12-12 21:17:30 +08:00
617c452e07 fix #90: export comic as epub 2024-12-12 21:17:30 +08:00
c8e6e1311c download comics in local favorites page 2024-12-12 18:00:58 +08:00
0bdb1299ca fix #87: Add translated_tags field to all local favorite table. 2024-12-12 17:14:36 +08:00
af9835eb8f update flutter to 3.27.0 & update packages 2024-12-12 16:41:42 +08:00
4801457e0e update flutter_memory_info 2024-12-12 14:24:11 +08:00
0c9f7126a2 fix #91 2024-12-11 13:41:34 +08:00
3cf9228e2a improve performance & ui 2024-12-10 16:01:06 +08:00
07f8cd2455 improve explore page loading 2024-12-10 14:45:48 +08:00
659b211038 Improve TabBar 2024-12-09 20:07:08 +08:00
4e121748cd Use PageStorage to store state 2024-12-09 19:56:43 +08:00
14fe901144 improve ui 2024-12-09 18:06:35 +08:00
835b40860d aggregated search 2024-12-09 17:56:44 +08:00
ef435dcaa5 fix #61 2024-12-08 17:56:30 +08:00
e999652a3e delete cache 2024-12-07 20:11:11 +08:00
425cbed8a1 fix #90: export comic as epub 2024-12-07 20:04:22 +08:00
nyne
488299bcfb Update main.yml 2024-12-02 21:50:16 +08:00
b8bdda16c6 fix cbz import 2024-12-02 21:00:06 +08:00
1a50b8bc27 fix TabBar 2024-12-02 21:00:06 +08:00
546f619063 comment button 2024-12-02 21:00:06 +08:00
Naomi
0e831468ee feat: 漫画列表页本地收藏自动选择默认收藏夹 (#84)
Signed-off-by: Naomi <33375791+Henvy-Mango@users.noreply.github.com>
2024-12-02 21:00:06 +08:00
a4cc0a3af2 Update saf 2024-12-02 21:00:06 +08:00
80811bf12d rollback android storage setting 2024-12-02 21:00:06 +08:00
21bf9d72c0 Add HistoryImageProvider 2024-12-02 21:00:06 +08:00
035a84380c fix copyDirectoryIsolate 2024-12-02 21:00:06 +08:00
5ddb6f47ca add telegram link 2024-12-02 21:00:06 +08:00
c1672d01f8 update reader 2024-12-02 21:00:06 +08:00
buste
66ebdb03b1 Feat 为画廊模式添加每页显示图片数量的配置 (#82)
* Feat: Add dynamic image-per-page configuration for gallery mode

- Implemented a slider to configure the number of images displayed per page (1-5) in gallery mode.
- Updated the reader to dynamically reflect changes in the `imagesPerPage` setting without requiring a mode switch or reopening.
- Ensured compatibility with existing continuous reading mode.

* fix currentImagesPerPage

* fix Continuous mode

* improve readerScreenPicNumber setting disable view

* improve PhotoViewController
2024-12-02 21:00:06 +08:00
df2ba6efd1 update version code 2024-12-02 21:00:06 +08:00
705c448cfe export comic as pdf 2024-12-02 21:00:06 +08:00
a711335012 import pica data 2024-12-02 21:00:06 +08:00
305ef9263d fix selecting file on Android 2024-12-02 21:00:06 +08:00
f8b8811aaa fix #52 2024-12-02 21:00:06 +08:00
a868fe3fff prevent too many image loading at save time 2024-12-02 21:00:06 +08:00
873cbd779e fix #73 2024-12-02 21:00:06 +08:00
d56e3fd59f fix #76 2024-12-02 21:00:06 +08:00
d96b36414d fix subtitle 2024-12-02 21:00:06 +08:00
b30bd11d1a fix #77 2024-12-02 21:00:06 +08:00
nyne
72507d907a Feat/saf (#81)
* [Android] Use SAF to change local path

* Use IOOverrides to replace openDirectoryPlatform and openFilePlatform

* fix io
2024-12-02 21:00:06 +08:00
9b821f1b46 fix cbz import 2024-12-02 20:55:47 +08:00
867b2a4b64 fix TabBar 2024-12-02 17:45:26 +08:00
8f07c8a2bb comment button 2024-12-02 16:47:13 +08:00
Naomi
7aed61a65e feat: 漫画列表页本地收藏自动选择默认收藏夹 (#84)
Signed-off-by: Naomi <33375791+Henvy-Mango@users.noreply.github.com>
2024-12-02 16:27:40 +08:00
674b5c9636 Update saf 2024-12-02 15:30:59 +08:00
153f1a9dfe rollback android storage setting 2024-12-02 11:39:28 +08:00
6c5df47663 Add HistoryImageProvider 2024-12-02 11:19:06 +08:00
24188b51c0 fix copyDirectoryIsolate 2024-12-01 21:10:51 +08:00
070c803f97 add telegram link 2024-12-01 20:25:32 +08:00
b425eec561 update reader 2024-12-01 20:22:33 +08:00
buste
95c98eeaed Feat 为画廊模式添加每页显示图片数量的配置 (#82)
* Feat: Add dynamic image-per-page configuration for gallery mode

- Implemented a slider to configure the number of images displayed per page (1-5) in gallery mode.
- Updated the reader to dynamically reflect changes in the `imagesPerPage` setting without requiring a mode switch or reopening.
- Ensured compatibility with existing continuous reading mode.

* fix currentImagesPerPage

* fix Continuous mode

* improve readerScreenPicNumber setting disable view

* improve PhotoViewController
2024-12-01 19:56:38 +08:00
60f7b4d3b0 update version code 2024-12-01 18:57:35 +08:00
2ee2a01550 export comic as pdf 2024-12-01 18:54:17 +08:00
a2f628001a import pica data 2024-12-01 18:06:19 +08:00
de4503a2de fix selecting file on Android 2024-12-01 18:05:59 +08:00
30b2aa2f99 fix #52 2024-11-30 21:36:23 +08:00
2f4927f719 prevent too many image loading at save time 2024-11-30 21:30:39 +08:00
9fb3482474 fix #73 2024-11-30 21:05:35 +08:00
2063eee82b fix #76 2024-11-30 20:52:55 +08:00
91b765ffba fix subtitle 2024-11-30 13:50:07 +08:00
bbfe87fff2 fix #77 2024-11-30 10:07:03 +08:00
nyne
430b6eeb3a Feat/saf (#81)
* [Android] Use SAF to change local path

* Use IOOverrides to replace openDirectoryPlatform and openFilePlatform

* fix io
2024-11-29 21:33:28 +08:00
onlytheworld
06094fc5fc 自动发布 (#80)
* change workflow

* Update main.yml
2024-11-29 17:08:01 +08:00
nyne
ce48a89cc1 Merge pull request #71 from venera-app/dev
v1.0.7
2024-11-24 15:56:32 +08:00
f155bed694 update flutter_saf 2024-11-24 15:21:22 +08:00
1500d2a1d2 fix getImages 2024-11-24 13:12:06 +08:00
2408096a7c fix cbz export 2024-11-24 12:53:06 +08:00
bf1930cea2 show comment action button if comic.comments is empty 2024-11-24 12:48:37 +08:00
5d99b6ed99 fix download 2024-11-24 12:47:08 +08:00
e2aceb857d handle invalid local path 2024-11-24 12:03:12 +08:00
4b32165aae update version code 2024-11-24 11:08:21 +08:00
5bc3ddaf26 fix the issue of opening a local comic in history page 2024-11-24 10:43:53 +08:00
904e4f1186 fix the issue of hiding UI 2024-11-24 10:37:17 +08:00
511a9fdc09 hide "Copy to app local path" option on iOS and macOS 2024-11-23 18:45:10 +08:00
c2b8760d86 Add AppbarStyle.shadow;
Improve favorites page ui.
2024-11-23 12:12:52 +08:00
pkuislm
a1474ca9c3 更改安卓端的文件访问方式,优化导入逻辑 (#64)
* Refactor import function & Allow import local comics without copying them to local path.

* android: use file_picker instead, support directory access for android 10

* Improve import logic

* Fix sql query.

* Add ability to remove invalid favorite items.

* Perform sort before choosing cover

* Revert changes of "use file_picker instead".

* Try catch on "check update"

* Added module 'flutter_saf'

* gitignore

* remove unsupported arch in build.gradle

* Use flutter_saf to handle android's directory and files, improve import logic.

* revert changes of 'requestLegacyExternalStorage'

* fix cbz import

* openDirectoryPlatform

* Remove double check on source folder

* use openFilePlatform

* remove unused import

* improve local comic's path handling

* bump version

* fix pubspec format

* return null when comic folder is empty
2024-11-23 11:05:00 +08:00
boa
c3474b1dff change iOS default local path to Documents (#68) 2024-11-23 00:25:38 +08:00
Pacalini
2f290f0c86 universal: style improvements (#67) 2024-11-22 16:47:50 +08:00
AnxuNA
8b1f13cd33 Add Favorite multiple selections (#66) 2024-11-22 12:21:22 +08:00
f3aa0e9f27 fix comment 2024-11-22 10:24:31 +08:00
f4b9cb5abe limitImageWidth should only be enabled with ReaderMode.continuousTopToBottom 2024-11-21 21:38:41 +08:00
4d55e6a72f move checkUpdates to main_page 2024-11-21 21:36:08 +08:00
ad3f2fab45 add archive download 2024-11-21 21:29:45 +08:00
b1cdcc2a91 fix copyDirectories 2024-11-20 18:12:27 +08:00
7fcb63c0cb show comments in comic details page 2024-11-20 18:04:22 +08:00
454497fd65 fix mime 2024-11-20 13:25:56 +08:00
AnxuNA
c4aab2369f Fix _buildBriefMode display (#58) 2024-11-20 09:33:33 +08:00
ce175a2135 improve importing comic 2024-11-19 20:52:13 +08:00
6aeaeadb10 fix & improve importing comic 2024-11-19 18:44:52 +08:00
Pacalini
8402c1c9f3 authorize: auto-raise & skip on import (#56) 2024-11-19 16:01:35 +08:00
ed67bc80ea fix windows webview 2024-11-18 22:22:10 +08:00
AnxuNA
eb3a7f9d52 add Chapter && Page translate (#54)
add Chapter && Page translate
2024-11-18 21:27:47 +08:00
nyne
0d77803e8c Merge pull request #53 from venera-app/dev
v1.0.6
2024-11-18 18:20:39 +08:00
8db52c9db1 update version code 2024-11-18 17:57:35 +08:00
ce6f65f912 fix auto link 2024-11-18 17:56:27 +08:00
689700f52a improve tab bar 2024-11-18 17:42:20 +08:00
250f458029 improve word segmentation 2024-11-18 17:22:25 +08:00
1489e6c86d add appVersion to JsEngine 2024-11-18 17:02:07 +08:00
b4921c8e14 support rich text comment 2024-11-18 16:59:54 +08:00
800b67fb28 fix network issue 2024-11-18 10:56:19 +08:00
AnxuNA
036474a5d2 Optimization _buildBriefMode (#51)
更改_buildBriefMode样式
2024-11-17 22:55:04 +08:00
a1d1f504bd fix windows build 2024-11-17 21:22:55 +08:00
458bc261f3 update workflow 2024-11-17 20:57:39 +08:00
00af5f1989 update workflow 2024-11-17 20:50:32 +08:00
9988e76149 update workflow 2024-11-17 18:43:29 +08:00
213179b8c2 update workflow 2024-11-17 18:25:18 +08:00
708cf83a32 improve ui 2024-11-17 17:23:43 +08:00
0ee99a8760 fix android method channel 2024-11-16 19:13:19 +08:00
30a1c806cd Convert network folder to local 2024-11-16 16:51:56 +08:00
Pacalini
7bc0aeb4af tool bar: RtL slider & button swap (#50) 2024-11-16 16:07:39 +08:00
8513a739ec When AppLifecycleState is changed to resumed, check for data updates. 2024-11-15 22:14:53 +08:00
AnxuNA
d749e7421e Open in Browser Translation (#49)
Open in Browser Translation
2024-11-15 21:16:17 +08:00
165e5f2850 add authorization 2024-11-15 18:27:59 +08:00
edff9c7a0c fix config update issue 2024-11-15 17:03:41 +08:00
boa
65b41b2873 add option to ignore certificate errors (#46)
add option to ignore certificate errors
2024-11-14 20:40:28 +08:00
AnxuNA
f912e57bfd Change the style of _buildBriefMode. (#44)
Change the style of _buildBriefMode
2024-11-14 19:29:46 +08:00
nyne
2ef03ad7ae Merge pull request #42 from Pacalini/dev
reader: fix start/end flipping
2024-11-14 18:24:26 +08:00
Pacalini
47eb597d96 reader: fix start/end flipping 2024-11-14 18:10:47 +08:00
0ac9ee7061 fix #37 2024-11-14 15:28:57 +08:00
dd7154830b fix potential network issue 2024-11-13 19:28:47 +08:00
nyne
194abb82de Merge pull request #36 from venera-app/dev
v1.0.5-patch
2024-11-13 18:57:10 +08:00
a8bc097541 Update windows build script 2024-11-13 18:56:22 +08:00
d34c7c3806 fix importing data on Android 2024-11-13 18:55:25 +08:00
nyne
926437b967 Merge pull request #34 from venera-app/dev
v1.0.5
2024-11-13 16:27:41 +08:00
nyne
856ad82c55 Merge branch 'master' into dev 2024-11-13 16:27:20 +08:00
81baf53ad4 Update version code 2024-11-13 16:21:13 +08:00
71b03d744a Add feature to download all comics in a folder 2024-11-13 16:20:42 +08:00
6f2bac52e4 Improve sharing image & saving image 2024-11-13 16:08:28 +08:00
9fcc306ee0 fix HtmlElement.parent 2024-11-13 13:12:04 +08:00
5d4e8f5b84 Add sorting folders feature 2024-11-13 12:44:51 +08:00
9bdcba1270 improve ui 2024-11-13 12:21:57 +08:00
8e99e94620 Add the feature for updating local favorites info 2024-11-13 08:57:37 +08:00
nyne
00bcbaa2eb Merge pull request #32 from pkuislm/dev
EhViewer数据导入&本地下载选择优化
2024-11-12 23:13:37 +08:00
pkuislm
acb9c47657 Improve selection button display on small screen devices. 2024-11-12 23:09:53 +08:00
1636c959d0 fix #33 2024-11-12 22:37:46 +08:00
pkuislm
4ff1140bf6 Add cancellation to ehviewer import. 2024-11-12 21:28:07 +08:00
pkuislm
057d6a2f54 Update translation. 2024-11-12 19:54:47 +08:00
pkuislm
601ef68ad3 Improve local comics selection logic. 2024-11-12 19:54:34 +08:00
pkuislm
c94438d7c4 Add EhViewer database import support. 2024-11-12 19:52:34 +08:00
pkuislm
5825f88e78 Allow custom creation time of favorite items, add LocalFavoritesManager.existsFolder function. 2024-11-12 19:50:53 +08:00
pkuislm
389403c11d Ignore files starting with a dot when fetching local comic images, and improve local comic delete logic. 2024-11-12 19:48:15 +08:00
pkuislm
abd9afad6b Fix local comic cover display logic. 2024-11-12 19:45:27 +08:00
pkuislm
5119beb1fe Fix battery forground color. 2024-11-12 19:44:05 +08:00
9b98075153 fix multiple setting pages and search pages 2024-11-12 17:51:20 +08:00
775ab471f5 fix subtitle 2024-11-12 17:49:02 +08:00
293040f374 fix subtitle 2024-11-12 17:43:37 +08:00
a427bcdf84 fix search action 2024-11-12 17:37:29 +08:00
c4f531a463 Exported data should contain cookies 2024-11-12 16:36:02 +08:00
nyne
6c076bfc7a Merge pull request #31 from pkuislm/dev
给阅读界面加个时钟和电池信息
2024-11-11 22:47:55 +08:00
pkuislm
93bf99daa5 Add option to hide time and battery info. 2024-11-11 22:40:46 +08:00
pkuislm
b3e95d7162 Fix widget blinking caused by future builder. 2024-11-11 22:13:03 +08:00
pkuislm
c35bf9fb7f Merge branch 'dev' of https://github.com/pkuislm/venera into dev 2024-11-11 22:10:32 +08:00
pkuislm
189dfe5a43 Fix battery update issue. 2024-11-11 22:08:13 +08:00
pkuislm
53b9bc79dd Fix battery update issue. 2024-11-11 21:58:44 +08:00
pkuislm
bc4e0f79a5 Added clock & battery widgets in reader. 2024-11-11 21:27:40 +08:00
05bbef0b8a fix #30 2024-11-11 18:43:32 +08:00
e1df69e785 [import data] proxy settings should be kept 2024-11-11 17:46:11 +08:00
a0e3cc720a add ImageLoadingConfig constructor 2024-11-11 17:36:42 +08:00
6ae3e50a5b improve network request 2024-11-11 17:18:56 +08:00
nyne
7cf55fcb8e add onLoadFailed to imageLoadingConfig 2024-11-11 15:01:31 +08:00
nyne
d875681c4b update gitignore 2024-11-11 14:23:24 +08:00
193ecdb765 improve data sync 2024-11-11 11:52:36 +08:00
ea3cc8cc58 [windows] prevent multiple instances 2024-11-11 10:58:48 +08:00
f8eace4c31 fix an issue where a deleted comic could not be displayed in a favorite folder. 2024-11-11 10:40:56 +08:00
db2c2395de fix importing data on windows 2024-11-11 10:35:21 +08:00
nyne
fe266dcade Merge pull request #26 from boa-z/translation-typo-fix
fix: translation typo
2024-11-11 00:06:19 +08:00
boa-z
ecb657d20d fix: translation typo 2024-11-10 23:26:21 +08:00
b8492b3adc remove permission_handler 2024-11-10 18:10:30 +08:00
nyne
0f37feb318 Merge pull request #25 from venera-app/dev
v1.0.4
2024-11-10 17:59:58 +08:00
6e2c5c6e07 update version number 2024-11-10 17:59:06 +08:00
64d8bcba9a [Android] Turn page by volume keys 2024-11-10 17:50:20 +08:00
160d0df935 fix setting new download path 2024-11-10 17:48:12 +08:00
6a60194ffb support setting new download path on android 2024-11-10 17:27:27 +08:00
93193bddc0 Merge branch 'refs/heads/master' into dev 2024-11-10 16:01:45 +08:00
aa415f201e quick favorite 2024-11-10 15:57:52 +08:00
4f4411fcc3 sync data using webdav 2024-11-10 10:38:46 +08:00
nyne
afd690ed07 Merge pull request #24 from boa-z/master
Experimental Support for Setting New Storage Path on iOS
2024-11-09 17:16:52 +08:00
nyne
a3936f64da Delete tg.yaml 2024-11-09 17:09:02 +08:00
boa-z
7bf8cf569f experimental support for set new storage path on iOS 2024-11-09 11:04:34 +08:00
boa-z
856ec23586 add copy storage path button 2024-11-08 23:32:18 +08:00
boa-z
d910b8a35d add multiSelect for local_comics_page 2024-11-07 23:30:01 +08:00
nyne
234bf218a9 Merge pull request #23 from venera-app/telegram
Create tg.yaml
2024-11-07 18:33:19 +08:00
nyne
0226477256 Create tg.yaml 2024-11-07 18:33:06 +08:00
175 changed files with 22976 additions and 6701 deletions

View File

@@ -7,6 +7,32 @@ body:
attributes: attributes:
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.**
**请不要报告与配置文件相关的任何问题。**
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:
@@ -19,7 +45,8 @@ body:
attributes: attributes:
label: Version label: Version
description: | description: |
App version App version.
Please try to update if it is not the latest version Please try to update if it is not the latest version
validations: validations:
required: true required: true

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

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

View File

@@ -7,6 +7,16 @@ body:
attributes: 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

19
.github/workflows/analyze.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: "analyze"
on:
pull_request:
push:
branches:
- master
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- run: flutter pub get
- uses: invertase/github-action-dart-analyzer@v1

16
.github/workflows/fastlane.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Validate Fastlane metadata
on:
workflow_dispatch:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate Fastlane Supply Metadata
uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2.1.0

View File

@@ -1,33 +0,0 @@
name: Build Linux
run-name: Build Linux
on:
workflow_dispatch: {}
jobs:
Build_Linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version-file: pubspec.yaml
architecture: x64
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py
- run: dart run flutter_to_arch
- run: |
sudo rm -rf build/linux/arch/app.tar.gz
sudo rm -rf build/linux/arch/pkg
sudo rm -rf build/linux/arch/src
sudo rm -rf build/linux/arch/PKGBUILD
- uses: actions/upload-artifact@v4
with:
name: deb_build
path: build/linux/x64/release/debian
- uses: actions/upload-artifact@v4
with:
name: arch_build
path: build/linux/arch/

View File

@@ -1,7 +1,10 @@
name: Build IOS name: Build ALL
run-name: Build IOS run-name: Build ALL
on: on:
workflow_dispatch: {} workflow_dispatch: {}
release:
types: [published]
jobs: jobs:
Build_MacOS: Build_MacOS:
runs-on: macos-15 runs-on: macos-15
@@ -36,12 +39,18 @@ jobs:
ln -s /Applications dist/dmg_contents/Applications ln -s /Applications dist/dmg_contents/Applications
hdiutil create -volname "venera" -srcfolder dist/dmg_contents -ov -format UDZO "dist/venera.dmg" hdiutil create -volname "venera" -srcfolder dist/dmg_contents -ov -format UDZO "dist/venera.dmg"
- name: Add version to filename
run: |
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
mkdir -p result
mv dist/venera.dmg result/venera-$APP_VERSION.dmg
# Step 4: Attach and upload artifacts (optional) # Step 4: Attach and upload artifacts (optional)
- name: Upload DMG - name: Upload DMG
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: venera.dmg name: macos_build
path: dist/venera.dmg path: result/
Build_IOS: Build_IOS:
runs-on: macos-15 runs-on: macos-15
steps: steps:
@@ -59,7 +68,243 @@ jobs:
mv /Users/runner/work/venera/venera/build/ios/iphoneos/Runner.app /Users/runner/work/venera/venera/build/ios/iphoneos/Payload mv /Users/runner/work/venera/venera/build/ios/iphoneos/Runner.app /Users/runner/work/venera/venera/build/ios/iphoneos/Payload
cd /Users/runner/work/venera/venera/build/ios/iphoneos/ cd /Users/runner/work/venera/venera/build/ios/iphoneos/
zip -r venera-ios.ipa Payload zip -r venera-ios.ipa Payload
- name: Add version to filename
run: |
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
mkdir -p result
mv build/ios/iphoneos/venera-ios.ipa result/venera-ios-$APP_VERSION.ipa
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: app-ios.ipa name: ios_build
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa path: result/
Build_Android:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- name: Decode and install certificate
env:
STORE_FILE: ${{ secrets.ANDROID_KEYSTORE }}
PROPERTY_FILE: ${{ secrets.ANDROID_KEY_PROPERTIES }}
run: |
echo "$STORE_FILE" | base64 --decode > android/keystore.jks
echo "$PROPERTY_FILE" > android/key.properties
- uses: actions/setup-java@v4
with:
distribution: 'oracle'
java-version: '17'
- name: Setup Rust
run: |
rustup update
rustup default stable
- run: flutter pub get
- run: flutter build apk --release
- uses: actions/upload-artifact@v4
with:
name: apks
path: build/app/outputs/apk/release
Build_Windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: install dependencies
run: |
choco install yq -y
pip install httpx
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- name: build
run: |
flutter pub get
python windows/build.py
- uses: actions/upload-artifact@v4
with:
name: windows_build
path: build/windows/Venera-*
Build_Linux:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version-file: pubspec.yaml
architecture: x64
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py x64
- run: dart run flutter_to_arch
- run: |
sudo rm -rf build/linux/arch/app.tar.gz
sudo rm -rf build/linux/arch/pkg
sudo rm -rf build/linux/arch/src
sudo rm -rf build/linux/arch/PKGBUILD
- name: Build AppImage
run: |
sudo apt-get install -y libfuse2
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x appimagetool
mkdir -p Venera.AppDir
cp -r build/linux/x64/release/bundle/* Venera.AppDir/
cat > Venera.AppDir/venera.desktop << EOF
[Desktop Entry]
Name=Venera
Exec=venera
Icon=venera
Type=Application
Categories=Utility;
EOF
cp assets/app_icon.png Venera.AppDir/venera.png
cat > Venera.AppDir/AppRun << EOF
#!/bin/sh
HERE=\$(dirname \$(readlink -f "\${0}"))
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
exec "\${HERE}"/venera "\$@"
EOF
chmod +x Venera.AppDir/AppRun
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
./appimagetool Venera.AppDir Venera-${APP_VERSION}-x86_64.AppImage
mkdir -p build/linux/appimage
mv Venera-${APP_VERSION}-x86_64.AppImage build/linux/appimage/
- uses: actions/upload-artifact@v4
with:
name: appimage_build
path: build/linux/appimage
- uses: actions/upload-artifact@v4
with:
name: deb_build
path: build/linux/x64/release/debian
- uses: actions/upload-artifact@v4
with:
name: arch_build
path: build/linux/arch/
Build_Linux_ARM64:
runs-on: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'master'
flutter-version-file: pubspec.yaml
- run: |
flutter pub get
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py arm64
- name: Build AppImage
run: |
sudo apt-get install -y libfuse2
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage"
chmod +x appimagetool
mkdir -p Venera.AppDir
cp -r build/linux/arm64/release/bundle/* Venera.AppDir/
cat > Venera.AppDir/venera.desktop << EOF
[Desktop Entry]
Name=Venera
Exec=venera
Icon=venera
Type=Application
Categories=Utility;
EOF
cp assets/app_icon.png Venera.AppDir/venera.png
cat > Venera.AppDir/AppRun << EOF
#!/bin/sh
HERE=\$(dirname \$(readlink -f "\${0}"))
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
exec "\${HERE}"/venera "\$@"
EOF
chmod +x Venera.AppDir/AppRun
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
./appimagetool Venera.AppDir Venera-${APP_VERSION}-aarch64.AppImage
mkdir -p build/linux/appimage
mv Venera-${APP_VERSION}-aarch64.AppImage build/linux/appimage/
- uses: actions/upload-artifact@v4
with:
name: appimage_arm64_build
path: build/linux/appimage
- uses: actions/upload-artifact@v4
with:
name: deb_arm64_build
path: build/linux/x64/release/debian # This is a bug related to flutter_to_debian, but it's not a big deal.
Release:
runs-on: ubuntu-22.04
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux, Build_Linux_ARM64]
if: github.event_name == 'release' # 仅在 push 事件时执行
steps:
- uses: actions/download-artifact@v4
with:
name: macos_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: ios_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: apks
path: outputs
- uses: actions/download-artifact@v4
with:
name: windows_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: deb_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: arch_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: deb_arm64_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: appimage_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: appimage_arm64_build
path: outputs
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
outputs/*.ipa
outputs/*.dmg
outputs/*.apk
outputs/*.zip
outputs/*.exe
outputs/*.deb
outputs/*.zst
outputs/*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.ACTION_GITHUB_TOKEN }}

4
.gitignore vendored
View File

@@ -15,6 +15,7 @@ migrate_working_dir/
*.ipr *.ipr
*.iws *.iws
.idea/ .idea/
.vscode/
# The .vscode folder contains launch configuration and tasks you configure in # The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line # VS Code which you may wish to be included in version control, so this line
@@ -43,3 +44,6 @@ app.*.map.json
/android/app/release /android/app/release
add_translation.py add_translation.py
*/*/generated_*
*/*/Generated*

View File

@@ -1,15 +1,17 @@
# venera # venera
[![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/)
[![flutter](https://img.shields.io/badge/flutter-3.24.4-blue)](https://flutter.dev/)
[![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)](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/+Ws-IpmUutzkxMjhl)
A comic reader that support reading local and network comics. A comic reader that support reading local and network comics.
## Features [<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
## Features
- Read local comics - Read local comics
- Use javascript to create comic sources - Use javascript to create comic sources
- Read comics from network sources - Read comics from network sources
@@ -19,15 +21,13 @@ A comic reader that support reading local and network comics.
- Login to comment, rate, and other operations if the source supports - Login to comment, rate, and other operations if the source supports
## Build from source ## Build from source
1. Clone the repository 1. Clone the repository
2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install) 2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install)
3. Install rust, see [rustup.rs](https://rustup.rs/) 3. Install rust, see [rustup.rs](https://rustup.rs/)
4. Build for your platform: e.g. `flutter build apk` 4. Build for your platform: e.g. `flutter build apk`
## Create a new comic source ## Create a new comic source
See [Comic Source](doc/comic_source.md)
See [venera-configs](https://github.com/venera-app/venera-configs)
## Thanks ## Thanks

1
android/.gitignore vendored
View File

@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
/app/.cxx/

View File

@@ -5,6 +5,8 @@ plugins {
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
} }
ext.abiCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86_64": 3]
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties") def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@@ -34,6 +36,8 @@ android {
splits{ splits{
abi { abi {
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
enable true enable true
universalApk true universalApk true
} }
@@ -76,21 +80,44 @@ android {
buildTypes { buildTypes {
release { release {
ndk { ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
} }
signingConfig signingConfigs.release signingConfig signingConfigs.release
applicationVariants.all { variant -> }
variant.outputs.all { output -> debug {
def abi = output.getFilter(com.android.build.OutputFile.ABI) ndk {
if (abi != null) { abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
outputFileName = "venera-${variant.versionName}-${abi}.apk" }
} else { signingConfig signingConfigs.debug
outputFileName = "venera-${variant.versionName}.apk" }
}
applicationVariants.all { variant ->
variant.outputs.all { output ->
def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (variant.buildType.name == "release") {
if (abi != null) {
outputFileName = "venera-${variant.versionName}-${abi}.apk"
def abiVersionCode = project.ext.abiCodes.get(abi)
if (abiVersionCode != null) {
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
} }
} else {
outputFileName = "venera-${variant.versionName}.apk"
versionCodeOverride = variant.versionCode * 10
} }
} else if (variant.buildType.name == "debug") {
versionCodeOverride = variant.versionCode * 10 + 4
} }
} }
} }
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
} }
flutter { flutter {

View File

@@ -1,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<application <application
android:label="venera" android:label="venera"
android:name="${applicationName}" android:name="${applicationName}"
@@ -49,6 +53,8 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- [flutter 3.27.1] Impeller is still worse than skia, disable it -->
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false"/>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -1,49 +1,69 @@
package com.github.wgh136.venera package com.github.wgh136.venera
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.android.FlutterActivity import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import dev.flutter.packages.file_selector_android.FileUtils
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant import io.flutter.plugins.GeneratedPluginRegistrant
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.lang.Exception import java.util.concurrent.atomic.AtomicInteger
class MainActivity : FlutterActivity() { class MainActivity : FlutterFragmentActivity() {
var volumeListen = VolumeListen() var volumeListen = VolumeListen()
var listening = false var listening = false
private val pickDirectoryCode = 1 private val storageRequestCode = 0x10
private var storagePermissionRequest: ((Boolean) -> Unit)? = null
private lateinit var result: MethodChannel.Result private val nextLocalRequestCode = AtomicInteger()
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { private fun <I, O> startContractForResult(
super.onActivityResult(requestCode, resultCode, data) contract: ActivityResultContract<I, O>,
if (requestCode == pickDirectoryCode) { input: I,
if(resultCode != Activity.RESULT_OK) { callback: ActivityResultCallback<O>
result.success(null) ) {
return val key = "activity_rq_for_result#${nextLocalRequestCode.getAndIncrement()}"
} val registry = activityResultRegistry
val pickedDirectoryUri = data?.data var launcher: ActivityResultLauncher<I>? = null
if (pickedDirectoryUri == null) { val observer = object : LifecycleEventObserver {
result.success(null) override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
return if (Lifecycle.Event.ON_DESTROY == event) {
} launcher?.unregister()
Thread { lifecycle.removeObserver(this)
try {
result.success(onPickedDirectory(pickedDirectoryUri))
} }
catch (e: Exception) { }
result.error("Failed to Copy Files", e.toString(), null)
}
}.start()
} }
lifecycle.addObserver(observer)
val newCallback = ActivityResultCallback<O> {
launcher?.unregister()
lifecycle.removeObserver(observer)
callback.onActivityResult(it)
}
launcher = registry.register(key, contract, newCallback)
launcher.launch(input)
} }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -63,12 +83,23 @@ class MainActivity : FlutterActivity() {
} }
res.success(null) res.success(null)
} }
"getDirectoryPath" -> { "getDirectoryPath" -> {
this.result = res
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
startActivityForResult(intent, pickDirectoryCode) startContractForResult(ActivityResultContracts.StartActivityForResult(), intent) { activityResult ->
if (activityResult.resultCode != Activity.RESULT_OK) {
res.success(null)
return@startContractForResult
}
val pickedDirectoryUri = activityResult.data?.data
if (pickedDirectoryUri == null)
res.success(null)
else
onPickedDirectory(pickedDirectoryUri, res)
}
} }
else -> res.notImplemented() else -> res.notImplemented()
} }
} }
@@ -85,10 +116,24 @@ class MainActivity : FlutterActivity() {
events.success(2) events.success(2)
} }
} }
override fun onCancel(arguments: Any?) { override fun onCancel(arguments: Any?) {
listening = false listening = false
} }
}) })
val storageChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/storage")
storageChannel.setMethodCallHandler { _, res ->
requestStoragePermission { result ->
res.success(result)
}
}
val selectFileChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/select_file")
selectFileChannel.setMethodCallHandler { req, res ->
val mimeType = req.arguments<String>()
openFile(res, mimeType!!)
}
} }
private fun getProxy(): String { private fun getProxy(): String {
@@ -102,12 +147,13 @@ class MainActivity : FlutterActivity() {
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if(listening){ if (listening) {
when (keyCode) { when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> { KeyEvent.KEYCODE_VOLUME_DOWN -> {
volumeListen.down() volumeListen.down()
return true return true
} }
KeyEvent.KEYCODE_VOLUME_UP -> { KeyEvent.KEYCODE_VOLUME_UP -> {
volumeListen.up() volumeListen.up()
return true return true
@@ -117,43 +163,199 @@ class MainActivity : FlutterActivity() {
return super.onKeyDown(keyCode, event) return super.onKeyDown(keyCode, event)
} }
/// copy the directory to tmp directory, return copied directory /// Ensure that the directory is accessible by dart:io
private fun onPickedDirectory(uri: Uri): String { private fun onPickedDirectory(uri: Uri, result: MethodChannel.Result) {
val contentResolver = context.contentResolver if (hasStoragePermission()) {
var tmp = context.cacheDir var plain = uri.toString()
tmp = File(tmp, "getDirectoryPathTemp") if(plain.contains("%3A")) {
plain = Uri.decode(plain)
}
val externalStoragePrefix = "content://com.android.externalstorage.documents/tree/primary:";
if(plain.startsWith(externalStoragePrefix)) {
val path = plain.substring(externalStoragePrefix.length)
result.success(Environment.getExternalStorageDirectory().absolutePath + "/" + path)
}
// The uri cannot be parsed to plain path, use copy method
}
// dart:io cannot access the directory without permission.
// so we need to copy the directory to cache directory
val contentResolver = contentResolver
var tmp = cacheDir
var dirName = DocumentFile.fromTreeUri(this, uri)?.name
tmp = File(tmp, dirName!!)
if(tmp.exists()) {
tmp.deleteRecursively()
}
tmp.mkdir() tmp.mkdir()
copyDirectory(contentResolver, uri, tmp) Thread {
try {
copyDirectory(contentResolver, uri, tmp)
result.success(tmp.absolutePath)
}
catch (e: Exception) {
result.error("copy error", e.message, null)
}
}.start()
return tmp.absolutePath
} }
private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) { private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) {
val src = DocumentFile.fromTreeUri(context, srcUri) ?: return val src = DocumentFile.fromTreeUri(this, srcUri) ?: return
for (file in src.listFiles()) { for (file in src.listFiles()) {
if(file.isDirectory) { if (file.isDirectory) {
val newDir = File(destDir, file.name!!) val newDir = File(destDir, file.name!!)
newDir.mkdir() newDir.mkdir()
copyDirectory(resolver, file.uri, newDir) copyDirectory(resolver, file.uri, newDir)
} else { } else {
val newFile = File(destDir, file.name!!) val newFile = File(destDir, file.name!!)
val inputStream = resolver.openInputStream(file.uri) ?: return resolver.openInputStream(file.uri)?.use { input ->
val outputStream = FileOutputStream(newFile) FileOutputStream(newFile).use { output ->
inputStream.copyTo(outputStream) input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE)
inputStream.close() output.flush()
outputStream.close() }
}
} }
} }
} }
private fun hasStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
} else {
Environment.isExternalStorageManager()
}
}
private fun requestStoragePermission(result: (Boolean) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
val readPermission = ContextCompat.checkSelfPermission(
this,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
val writePermission = ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
if (!readPermission || !writePermission) {
storagePermissionRequest = result
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
storageRequestCode
)
} else {
result(true)
}
} else {
if (!Environment.isExternalStorageManager()) {
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.addCategory("android.intent.category.DEFAULT")
intent.data = Uri.parse("package:$packageName")
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ _ ->
result(Environment.isExternalStorageManager())
}
} catch (e: Exception) {
result(false)
}
} else {
result(true)
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == storageRequestCode) {
storagePermissionRequest?.invoke(grantResults.all {
it == PackageManager.PERMISSION_GRANTED
})
storagePermissionRequest = null
}
}
private fun openFile(result: MethodChannel.Result, mimeType: String) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = mimeType
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ activityResult ->
if (activityResult.resultCode != Activity.RESULT_OK) {
result.success(null)
return@startContractForResult
}
val uri = activityResult.data?.data
if (uri == null) {
result.success(null)
return@startContractForResult
}
val contentResolver = contentResolver
val file = DocumentFile.fromSingleUri(this, uri)
if (file == null) {
result.success(null)
return@startContractForResult
}
val fileName = file.name
if (fileName == null) {
result.success(null)
return@startContractForResult
}
if(hasStoragePermission()) {
try {
val filePath = FileUtils.getPathFromUri(this, uri)
result.success(filePath)
return@startContractForResult
}
catch (e: Exception) {
// ignore
}
}
// use copy method
val tmp = File(cacheDir, fileName)
if(tmp.exists()) {
tmp.delete()
}
Log.i("Venera", "copy file (${fileName}) to ${tmp.absolutePath}")
Thread {
try {
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tmp).use { output ->
input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE)
output.flush()
}
}
result.success(tmp.absolutePath)
}
catch (e: Exception) {
result.error("copy error", e.message, null)
}
}.start()
}
}
} }
class VolumeListen{ class VolumeListen {
var onUp = fun() {} var onUp = fun() {}
var onDown = fun() {} var onDown = fun() {}
fun up(){ fun up() {
onUp() onUp()
} }
fun down(){
fun down() {
onDown() onDown()
} }
} }

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.2-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-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.2.1' apply false id "com.android.application" version '8.3.2' 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

@@ -4,6 +4,13 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app. This library provides a set of APIs for interacting with the Venera app.
*/ */
function setTimeout(callback, delay) {
sendMessage({
method: 'delay',
time: delay,
}).then(callback);
}
/// encode, decode, hash, decrypt /// encode, decode, hash, decrypt
let Convert = { let Convert = {
/** /**
@@ -486,6 +493,37 @@ let Network = {
}, },
}; };
/**
* [fetch] function for sending HTTP requests. Same api as the browser fetch.
* @param url {string}
* @param [options] {{method?: string, headers?: Object, body?: any}}
* @returns {Promise<{ok: boolean, status: number, statusText: string, headers: {}, arrayBuffer: (function(): Promise<ArrayBuffer>), text: (function(): Promise<string>), json: (function(): Promise<any>)}>}
* @since 1.2.0
*/
async function fetch(url, options) {
let method = 'GET';
let headers = {};
let data = null;
if (options) {
method = options.method || method;
headers = options.headers || headers;
data = options.body || data;
}
let result = await Network.fetchBytes(method, url, headers, data);
return {
ok: result.status >= 200 && result.status < 300,
status: result.status,
statusText: '',
headers: result.headers,
arrayBuffer: async () => result.body,
text: async () => Convert.decodeUtf8(result.body),
json: async () => JSON.parse(Convert.decodeUtf8(result.body)),
}
}
/** /**
* HtmlDocument class for parsing HTML and querying elements. * HtmlDocument class for parsing HTML and querying elements.
*/ */
@@ -699,7 +737,7 @@ class HtmlElement {
doc: this.doc, doc: this.doc,
}) })
if(k == null) return null; if(k == null) return null;
return new HtmlElement(k); return new HtmlElement(k, this.doc);
} }
/** /**
@@ -850,6 +888,7 @@ let console = {
* @param id {string} * @param id {string}
* @param title {string} * @param title {string}
* @param subtitle {string} * @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string} * @param cover {string}
* @param tags {string[]} * @param tags {string[]}
* @param description {string} * @param description {string}
@@ -859,10 +898,11 @@ let console = {
* @param stars {number?} - 0-5, double * @param stars {number?} - 0-5, double
* @constructor * @constructor
*/ */
function Comic({id, title, subtitle, cover, tags, description, maxPage, language, favoriteId, stars}) { function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage, language, favoriteId, stars}) {
this.id = id; this.id = id;
this.title = title; this.title = title;
this.subtitle = subtitle; this.subtitle = subtitle;
this.subTitle = subTitle;
this.cover = cover; this.cover = cover;
this.tags = tags; this.tags = tags;
this.description = description; this.description = description;
@@ -875,11 +915,13 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language
/** /**
* Create a comic details object * Create a comic details object
* @param title {string} * @param title {string}
* @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string} * @param cover {string}
* @param description {string?} * @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined} * @param tags {Map<string, string[]> | {} | null | undefined}
* @param chapters {Map<string, string> | {} | null | undefined}} - key: chapter id, value: chapter title * @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null | undefined}} - favorite status. If the comic source supports multiple folders, this field should be null * @param isFavorite {boolean | null | undefined} - favorite status.
* @param subId {string?} - a param which is passed to comments api * @param subId {string?} - a param which is passed to comments api
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails * @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[]?} - related comics * @param recommend {Comic[]?} - related comics
@@ -892,10 +934,12 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language
* @param url {string?} * @param url {string?}
* @param stars {number?} - 0-5, double * @param stars {number?} - 0-5, double
* @param maxPage {number?} * @param maxPage {number?}
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
* @constructor * @constructor
*/ */
function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage}) { function ComicDetails({title, subtitle, subTitle, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
this.title = title; this.title = title;
this.subtitle = subtitle ?? subTitle;
this.cover = cover; this.cover = cover;
this.description = description; this.description = description;
this.tags = tags; this.tags = tags;
@@ -913,6 +957,7 @@ function ComicDetails({title, cover, description, tags, chapters, isFavorite, su
this.url = url; this.url = url;
this.stars = stars; this.stars = stars;
this.maxPage = maxPage; this.maxPage = maxPage;
this.comments = comments;
} }
/** /**
@@ -940,6 +985,33 @@ function Comment({userName, avatar, content, time, replyCount, id, isLiked, scor
this.voteStatus = voteStatus; this.voteStatus = voteStatus;
} }
/**
* Create image loading config
* @param url {string?}
* @param method {string?} - http method, uppercase
* @param data {any} - request data, may be null
* @param headers {Object?} - request headers
* @param onResponse {((ArrayBuffer) => ArrayBuffer)?} - modify response data
* @param modifyImage {string?}
* A js script string.
* The script will be executed in a new Isolate.
* A function named `modifyImage` should be defined in the script, which receives an [Image] as the only argument, and returns an [Image]..
* @param onLoadFailed {(() => ImageLoadingConfig)?} - called when the image loading failed
* @constructor
* @since 1.0.5
*
* To keep the compatibility with the old version, do not use the constructor. Consider creating a new object with the properties directly.
*/
function ImageLoadingConfig({url, method, data, headers, onResponse, modifyImage, onLoadFailed}) {
this.url = url;
this.method = method;
this.data = data;
this.headers = headers;
this.onResponse = onResponse;
this.modifyImage = modifyImage;
this.onLoadFailed = onLoadFailed;
}
class ComicSource { class ComicSource {
name = "" name = ""
@@ -1014,6 +1086,19 @@ class ComicSource {
}); });
} }
translation = {}
/**
* Translate given string with the current locale using the translation object.
* @param key {string}
* @returns {string}
* @since 1.2.5
*/
translate(key) {
let locale = APP.locale;
return this.translation[locale]?.[key] ?? key;
}
init() { } init() { }
static sources = {} static sources = {}
@@ -1132,3 +1217,144 @@ class Image {
return new Image(key); return new Image(key);
} }
} }
/**
* UI related apis
* @since 1.2.0
*/
let UI = {
/**
* Show a message
* @param message {string}
*/
showMessage: (message) => {
sendMessage({
method: 'UI',
function: 'showMessage',
message: message,
})
},
/**
* Show a dialog. Any action will close the dialog.
* @param title {string}
* @param content {string}
* @param actions {{text:string, callback: () => void | Promise<void>, style: "text"|"filled"|"danger"}[]} - If callback returns a promise, the button will show a loading indicator until the promise is resolved.
* @returns {Promise<void>} - Resolved when the dialog is closed.
* @since 1.2.1
*/
showDialog: (title, content, actions) => {
sendMessage({
method: 'UI',
function: 'showDialog',
title: title,
content: content,
actions: actions,
})
},
/**
* Open [url] in external browser
* @param url {string}
*/
launchUrl: (url) => {
sendMessage({
method: 'UI',
function: 'launchUrl',
url: url,
})
},
/**
* Show a loading dialog.
* @param onCancel {() => void | null | undefined} - Called when the loading dialog is canceled. If [onCancel] is null, the dialog cannot be canceled by the user.
* @returns {number} - A number that can be used to cancel the loading dialog.
* @since 1.2.1
*/
showLoading: (onCancel) => {
return sendMessage({
method: 'UI',
function: 'showLoading',
onCancel: onCancel
})
},
/**
* Cancel a loading dialog.
* @param id {number} - returned by [showLoading]
* @since 1.2.1
*/
cancelLoading: (id) => {
sendMessage({
method: 'UI',
function: 'cancelLoading',
id: id
})
},
/**
* Show an input dialog
* @param title {string}
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
*/
showInputDialog: (title, validator) => {
return sendMessage({
method: 'UI',
function: 'showInputDialog',
title: title,
validator: validator
})
},
/**
* Show a select dialog
* @param title {string}
* @param options {string[]}
* @param initialIndex {number?}
* @returns {Promise<number | null>} - The selected index. If the dialog is canceled, return null.
*/
showSelectDialog: (title, options, initialIndex) => {
return sendMessage({
method: 'UI',
function: 'showSelectDialog',
title: title,
options: options,
initialIndex: initialIndex
})
}
}
/**
* App related apis
* @since 1.2.1
*/
let APP = {
/**
* Get the app version
* @returns {string} - The app version
*/
get version() {
return appVersion // defined in the engine
},
/**
* Get current app locale
* @returns {string} - The app locale, in the format of [languageCode]_[countryCode]
*/
get locale() {
return sendMessage({
method: 'getLocale'
})
},
/**
* Get current running platform
* @returns {string} - The platform name, "android", "ios", "windows", "macos", "linux"
*/
get platform() {
return sendMessage({
method: 'getPlatform'
})
}
}

View File

@@ -17,7 +17,8 @@
"Multiple Comics": "多个漫画", "Multiple Comics": "多个漫画",
"help": "帮助", "help": "帮助",
"Select": "选择", "Select": "选择",
"Imported @a comics": "已导入 @a 部漫画", "Selected @a comics": "已选择 @a 部漫画",
"Imported @a comics, loaded @b pages, received @c comics": "已导入 @a 部漫画, 加载 @b 页, 接收到 @c 部漫画",
"Downloading": "下载中", "Downloading": "下载中",
"Back": "后退", "Back": "后退",
"Delete": "删除", "Delete": "删除",
@@ -40,11 +41,19 @@
"Select a folder": "选择一个文件夹", "Select a folder": "选择一个文件夹",
"Folder": "文件夹", "Folder": "文件夹",
"Confirm": "确认", "Confirm": "确认",
"Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?", "Reversed successfully": "反转成功",
"Add comic source": "添加漫画来源", "Remove comic from favorite?": "从收藏中移除漫画?",
"Move": "移动",
"Move to folder": "移动到文件夹",
"Copy to folder": "复制到文件夹",
"Delete Comic": "删除漫画",
"Delete @c comics?": "删除 @c 本漫画?",
"Add comic source": "添加漫画源",
"Delete comic source '@n' ?": "删除漫画源 '@n' ",
"Select file": "选择文件", "Select file": "选择文件",
"View list": "查看列表", "View list": "查看列表",
"Open help": "打开帮助", "Open help": "打开帮助",
"Open in Browser": "打开网页",
"Check updates": "检查更新", "Check updates": "检查更新",
"Edit": "编辑", "Edit": "编辑",
"Update": "更新", "Update": "更新",
@@ -97,10 +106,12 @@
"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 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": "红色",
"Pink": "粉色", "Pink": "粉色",
@@ -129,7 +140,8 @@
"Block": "屏蔽", "Block": "屏蔽",
"Add new favorite to": "添加新收藏到", "Add new favorite to": "添加新收藏到",
"Move favorite after reading": "阅读后移动收藏", "Move favorite after reading": "阅读后移动收藏",
"Are you sure you want to delete this folder?" : "确定要删除这个收藏夹吗?", "Delete folder?" : "删除文件夹?",
"Delete folder '@f' ?" : "删除文件夹 '@f' ?",
"Import from file": "从文件导入", "Import from file": "从文件导入",
"Failed to import": "导入失败", "Failed to import": "导入失败",
"Cache Limit": "缓存限制", "Cache Limit": "缓存限制",
@@ -137,14 +149,9 @@
"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": "帮助",
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有当目录满足以下条件之一时,才被视为漫画:",
"1. The directory only contains image files." : "1. 目录只包含图片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。",
"Export as cbz": "导出为cbz", "Export as cbz": "导出为cbz",
"Select a cbz file." : "选择一个cbz文件", "Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
"A cbz file" : "一个cbz文件", "An archive file" : "一个归档文件",
"Fullscreen": "全屏", "Fullscreen": "全屏",
"Exit": "退出", "Exit": "退出",
"View more": "查看更多", "View more": "查看更多",
@@ -179,7 +186,188 @@
"Move To First": "移动到最前", "Move To First": "移动到最前",
"Cancel": "取消", "Cancel": "取消",
"Paused": "已暂停", "Paused": "已暂停",
"Pause": "暂停" "Pause": "暂停",
"Operation": "操作",
"Upload": "上传",
"Saved": "已保存",
"Saved Failed": "保存失败",
"Sync Data": "同步数据",
"Syncing Data": "正在同步数据",
"Data Sync": "数据同步",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
"Added": "已添加",
"Turn page by volume keys": "使用音量键翻页",
"Display time & battery info in reader":"在阅读器中显示时间和电量信息",
"EhViewer downloads":"EhViewer下载",
"Select an EhViewer database and a download folder.":"选择EhViewer的下载数据导出的db文件与存放下载内容的目录",
"(EhViewer)Default": "(EhViewer)默认",
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若通过EhViewer数据库导入漫画程序将会按其中的下载标签自动创建收藏文件夹。",
"Multi-Select": "进入多选模式",
"Exit Multi-Select": "退出多选模式",
"Selected @c comics": "已选择 @c 本漫画",
"Select All": "全选",
"Deselect": "取消选择",
"Invert Selection": "反选",
"Select in range": "区间选择",
"Finished": "已完成",
"Updating": "更新中",
"Update Comics Info": "更新漫画信息",
"Create Folder": "新建文件夹",
"Select an image on screen": "选择屏幕上的图片",
"Added @count comics to download queue.": "已添加 @count 本漫画到下载队列",
"Authorization Required": "需要身份验证",
"Sync": "同步",
"The folder is Linked to @source": "文件夹已关联到 @source",
"Source Folder": "源文件夹",
"Use a config file": "使用配置文件",
"Comic Source list": "漫画源列表",
"View": "查看",
"Copy": "复制",
"Copied": "已复制",
"Search History": "搜索历史",
"Clear Search History": "清除搜索历史",
"Search in": "搜索于",
"Clear History": "清除历史",
"Are you sure you want to clear your history?": "确定要清除您的历史记录吗?",
"No Explore Pages": "没有探索页面",
"Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 页",
"Also remove files on disk": "同时删除磁盘上的文件",
"Copy to app local path": "将漫画复制到本地存储目录中",
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
"Deleted @a favorite items.": "已删除 @a 条无效收藏",
"New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
"No new version available": "没有新版本可用",
"Export as pdf": "导出为pdf",
"Export as epub": "导出为epub",
"Aggregated Search": "聚合搜索",
"Local comic collection is not supported at present": "本地收藏暂不支持",
"The cover cannot be uncollected here": "封面不能在此取消收藏",
"Uncollected the image": "取消收藏图片",
"Successfully collected": "收藏成功",
"Collect the image": "收藏图片",
"Quick collect image": "快速收藏图片",
"Not enable": "不启用",
"Double Tap": "双击",
"Swipe": "滑动",
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在图片浏览页面, 你可以根据你的阅读模式横滑或者竖滑快速收藏图片",
"Calculate your favorite from @a comics and @b images": "从 @a 本漫画和 @b 张图片中, 计算你最喜欢的",
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括号后是图片数量或图片数比漫画页数",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫画的章节顺序可能发生了变化, 暂不支持收藏此章节",
"Author: ": "作者: ",
"Tags: ": "标签: ",
"Comics(number): ": "漫画(数量): ",
"Comics(percentage): ": "漫画(比例): ",
"Time Filter": "时间筛选",
"Image Favorites Greater Than": "图片收藏数大于",
"Collection time": "收藏时间",
"favoritesCompareComicPages": "收藏数与漫画页数比较",
"Cover": "封面",
"Page @a": "第 @a 页",
"Time Asc": "时间升序",
"Time Desc": "时间降序",
"Favorite Num": "收藏数",
"Favorite Num Compare Comic Pages": "收藏数比漫画页数",
"All": "全部",
"Last Week": "上周",
"Last Month": "上月",
"Last Half Year": "半年",
"Last Year": "一年",
"Filter": "筛选",
"Image Favorites": "图片收藏",
"Title": "标题",
"@a Cover": "@a 封面",
"Photo View": "图片浏览",
"Delete @a images": "删除 @a 张图片",
"Update the page number by the latest collection": "按最新收藏更新页数",
"Copy the title successfully": "复制标题成功",
"The comic is invalid, please long press to delete, you can double click the title to copy": "该漫画已失效, 请长按删除, 可以双击标题进行复制",
"No search results found": "未找到搜索结果",
"Added @c comics to download queue." : "已添加 @c 本漫画到下载队列",
"Download started": "下载已开始",
"Click favorite": "点击收藏",
"End": "末尾",
"None": "无",
"View Detail": "查看详情",
"Select a directory which contains multiple archive files." : "选择一个包含多个归档文件的目录",
"Multiple archive files" : "多个归档文件",
"No valid comics found" : "未找到有效的漫画",
"Enable DNS Overrides": "启用DNS覆写",
"DNS Overrides": "DNS覆写",
"Custom Image Processing": "自定义图片处理",
"Enable": "启用",
"Aggregated": "聚合",
"Default Search Target": "默认搜索目标",
"Auto Language Filters": "自动语言筛选",
"Check for updates on startup": "启动时检查更新",
"Start Time": "开始时间",
"End Time": "结束时间",
"Custom": "自定义",
"Reset": "重置",
"Tags": "标签",
"Authors": "作者",
"Comics": "漫画",
"Imported @a comics": "已导入 @a 本漫画",
"New Version": "新版本",
"@c updates": "@c 项更新",
"No updates": "无更新",
"Set comic source list url": "设置漫画源列表URL",
"Deselect All": "取消全选",
"Add keyword": "添加关键词",
"Keyword": "关键词",
"Manage": "管理",
"Verify": "验证",
"Cloudflare verification required": "需要Cloudflare验证",
"Success": "成功",
"Compressing": "压缩中",
"Exporting": "导出中",
"Search Sources": "搜索源",
"Removed": "已移除",
"Added to favorites": "已添加到收藏",
"Not added": "未添加",
"Create a folder": "新建收藏夹",
"Created successfully": "创建成功",
"name": "名称",
"Reverse tap to turn Pages": "反转点击翻页",
"Show all": "显示全部",
"Number of images preloaded": "预加载图片数量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading": "上次阅读",
"Replies": "回复",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates." : "选择一个文件夹以追更",
"Choose Folder": "选择文件夹",
"No folders available": "没有可用的文件夹",
"Updating comics...": "更新漫画中...",
"Automatic update checking enabled." : "已启用自动更新检查",
"The app will check for updates at most once a day." : "APP将每天最多检查一次更新",
"Change Folder": "更改文件夹",
"Check Now": "立即检查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫画",
"The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新",
"Disable": "禁用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功后, APP将自动与服务器同步数据",
"Cache cleared": "缓存已清除",
"Disabled": "已禁用",
"WebDAV Auto Sync": "WebDAV 自动同步",
"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": "分类页面"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -189,7 +377,7 @@
"Settings": "設定", "Settings": "設定",
"Search": "搜尋", "Search": "搜尋",
"History": "歷史", "History": "歷史",
"Local": "本", "Local": "本",
"Import": "匯入", "Import": "匯入",
"Comic Source": "漫畫源", "Comic Source": "漫畫源",
"Accounts": "帳戶", "Accounts": "帳戶",
@@ -200,14 +388,15 @@
"Multiple Comics": "多部漫畫", "Multiple Comics": "多部漫畫",
"help": "幫助", "help": "幫助",
"Select": "選擇", "Select": "選擇",
"Imported @a comics": "已匯入 @a 部漫畫", "Selected @a comics": "已選擇 @a 部漫畫",
"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": "描述",
@@ -215,60 +404,67 @@
"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": "確認",
"Are you sure you want to delete this comic?": "您確定要刪除這部漫畫", "Remove comic from favorite?": "從收藏中移除漫畫?",
"Add comic source": "添加漫畫來源", "Move": "移動",
"Move to folder": "移動到資料夾",
"Copy to folder": "複製到資料夾",
"Delete Comic": "刪除漫畫",
"Delete @c comics?": "刪除 @c 本漫畫?",
"Add comic source": "添加漫畫源",
"Delete comic source '@n' ?": "刪除漫畫源 '@n' ",
"Select file": "選擇文件", "Select file": "選擇文件",
"View list": "查看列表", "View list": "查看列表",
"Open help": "打開幫助", "Open help": "打開幫助",
"Open in Browser": "打開網頁",
"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": "閱讀模式",
@@ -279,10 +475,12 @@
"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 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": "紅色",
"Pink": "粉色", "Pink": "粉色",
@@ -291,42 +489,38 @@
"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": "閱讀後移動收藏",
"Are you sure you want to delete this folder?" : "確定要刪除這個收藏夾嗎", "Delete folder?" : "刪除資料夾",
"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": "幫助",
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有當目錄滿足以下條件之一時,才被視為漫畫:",
"1. The directory only contains image files." : "1. 目錄只包含圖片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。",
"Export as cbz": "匯出為cbz", "Export as cbz": "匯出為cbz",
"Select a cbz file." : "選擇一個cbz文件", "Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"A cbz file" : "一個cbz文件", "An archive file" : "一個歸檔文件",
"Fullscreen": "全螢幕", "Fullscreen": "全螢幕",
"Exit": "退出", "Exit": "退出",
"View more": "查看更多", "View more": "查看更多",
@@ -335,15 +529,16 @@
"Date": "日期", "Date": "日期",
"Date Desc": "日期降序", "Date Desc": "日期降序",
"Start": "開始", "Start": "開始",
"Export App Data": "匯出應用數據", "Reversed successfully": "反轉成功",
"Import App Data": "匯應用數據", "Export 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": "下載全部",
@@ -354,13 +549,194 @@
"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": "取消",
"Paused": "已暫停", "Paused": "已暫停",
"Pause": "暫停" "Pause": "暫停",
"Operation": "操作",
"Upload": "上傳",
"Saved": "已儲存",
"Saved Failed": "儲存失敗",
"Sync Data": "同步資料",
"Syncing Data": "正在同步資料",
"Data Sync": "資料同步",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
"Added": "已添加",
"Turn page by volume keys": "使用音量鍵翻頁",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量資訊",
"EhViewer downloads": "EhViewer下載",
"Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料匯出的db檔案與存放下載內容的目錄",
"(EhViewer)Default": "(EhViewer)預設",
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若透過EhViewer資料庫匯入漫畫程式將會按其中的下載標籤自動建立收藏資料夾。",
"Multi-Select": "進入多選模式",
"Exit Multi-Select": "退出多選模式",
"Selected @c comics": "已選擇 @c 本漫畫",
"Select All": "全選",
"Deselect": "取消選擇",
"Invert Selection": "反選",
"Select in range": "區間選擇",
"Finished": "已完成",
"Updating": "更新中",
"Update Comics Info": "更新漫畫資訊",
"Create Folder": "建立資料夾",
"Select an image on screen": "選擇螢幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載佇列",
"Authorization Required": "需要身份驗證",
"Sync": "同步",
"The folder is Linked to @source": "資料夾已關聯到 @source",
"Source Folder": "來源資料夾",
"Use a config file": "使用設定檔",
"Comic Source list": "漫畫源列表",
"View": "查看",
"Copy": "複製",
"Copied": "已複製",
"Search History": "搜尋歷史",
"Clear Search History": "清除搜尋歷史",
"Search in": "搜尋於",
"Clear History": "清除歷史",
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
"No Explore Pages": "沒有探索頁面",
"Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁",
"Also remove files on disk": "同時刪除磁碟上的文件",
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
"Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf",
"Export as epub": "匯出為epub",
"Aggregated Search": "聚合搜尋",
"No search results found": "未找到搜尋結果",
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載佇列",
"Download started": "下載已開始",
"Click favorite": "點擊收藏",
"Local comic collection is not supported at present": "本機收藏暫不支援",
"The cover cannot be uncollected here": "封面不能在此取消收藏",
"Uncollected the image": "取消收藏圖片",
"Successfully collected": "收藏成功",
"Collect the image": "收藏圖片",
"Quick collect image": "快速收藏圖片",
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在圖片瀏覽頁面, 你可以根據你的閱讀模式橫向或者縱向滑動快速收藏圖片",
"Calculate your favorite from @a comics and @b images": "從 @a 本漫畫和 @b 張圖片中, 計算你最喜歡的",
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括號後是圖片數量或圖片數比漫畫頁數",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支援收藏此章節",
"Author: ": "作者: ",
"Tags: ": "標籤: ",
"Comics(number): ": "漫畫(數量): ",
"Comics(percentage): ": "漫畫(比例): ",
"Time Filter": "時間篩選",
"Image Favorites Greater Than": "圖片收藏數大於",
"Collection time": "收藏時間",
"Not enable": "不啟用",
"Double Tap": "雙擊",
"Swipe": "滑動",
"favoritesCompareComicPages": "收藏數與漫畫頁數比較",
"Cover": "封面",
"Page @a": "第 @a 頁",
"Time Asc": "時間升序",
"Time Desc": "時間降序",
"Favorite Num": "收藏數",
"Favorite Num Compare Comic Pages": "收藏數比漫畫頁數",
"All": "全部",
"Last Week": "上週",
"Last Month": "上月",
"Last Half Year": "半年",
"Last Year": "一年",
"Filter": "篩選",
"Image Favorites": "圖片收藏",
"Title": "標題",
"@a Cover": "@a 封面",
"Photo View": "圖片瀏覽",
"Delete @a images": "刪除 @a 張圖片",
"Update the page number by the latest collection": "按最新收藏更新頁數",
"Copy the title successfully": "複製標題成功",
"The comic is invalid, please long press to delete, you can double click the title to copy": "該漫畫已失效, 請長按刪除, 可以雙擊標題進行複製",
"End": "末尾",
"None": "無",
"View Detail": "查看詳情",
"Select a directory which contains multiple archive files." : "選擇一個包含多個歸檔文件的目錄",
"Multiple archive files" : "多個歸檔文件",
"No valid comics found" : "未找到有效的漫畫",
"Enable DNS Overrides": "啟用DNS覆寫",
"DNS Overrides": "DNS覆寫",
"Custom Image Processing": "自訂圖片處理",
"Enable": "啟用",
"Aggregated": "聚合",
"Default Search Target": "預設搜尋目標",
"Auto Language Filters": "自動語言篩選",
"Check for updates on startup": "啟動時檢查更新",
"Start Time": "開始時間",
"End Time": "結束時間",
"Custom": "自訂",
"Reset": "重設",
"Tags": "標籤",
"Authors": "作者",
"Comics": "漫畫",
"Imported @a comics": "已匯入 @a 部漫畫",
"New Version": "新版本",
"@c updates": "@c 項更新",
"No updates": "無更新",
"Set comic source list url": "設定漫畫源列表URL",
"Deselect All": "取消全選",
"Add keyword": "添加關鍵字",
"Keyword": "關鍵字",
"Manage": "管理",
"Verify": "驗證",
"Cloudflare verification required": "需要Cloudflare驗證",
"Success": "成功",
"Compressing": "壓縮中",
"Exporting": "匯出中",
"Search Sources": "搜尋源",
"Removed": "已移除",
"Added to favorites": "已添加到收藏",
"Not added": "未添加",
"Create a folder": "建立收藏夾",
"Created successfully": "建立成功",
"name": "名稱",
"Reverse tap to turn Pages": "反轉點擊翻頁",
"Show all": "顯示全部",
"Number of images preloaded": "預載入圖片數量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading": "上次閱讀",
"Replies": "回覆",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates." : "選擇一個資料夾以追更",
"Choose Folder": "選擇資料夾",
"No folders available": "沒有可用的資料夾",
"Updating comics...": "更新漫畫中...",
"Automatic update checking enabled." : "已啟用自動更新檢查",
"The app will check for updates at most once a day." : "APP將每天最多檢查一次更新",
"Change Folder": "更改資料夾",
"Check Now": "立即檢查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫畫",
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
"Disable": "停用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與服務器同步數據",
"Cache cleared": "緩存已清除",
"Disabled": "已禁用",
"WebDAV Auto Sync": "WebDAV 自動同步",
"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": "分類頁面"
} }
} }

11
debian/build.py vendored
View File

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

6
debian/debian.yaml vendored
View File

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

View File

@@ -1,5 +1,4 @@
[Desktop Entry] [Desktop Entry]
Version={{Version}}
Name=Venera Name=Venera
GenericName=Venera GenericName=Venera
Comment=venera Comment=venera
@@ -7,3 +6,4 @@ Terminal=false
Type=Application Type=Application
Categories=Utility Categories=Utility
Keywords=Flutter;comic;images; Keywords=Flutter;comic;images;
Icon=venera

655
doc/comic_source.md Normal file
View File

@@ -0,0 +1,655 @@
# Comic Source
## Introduction
Venera is a comic reader that can read comics from various sources.
All comic sources are written in javascript.
Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine which is forked from [ekibun](https://github.com/ekibun/flutter_qjs).
This document will describe how to write a comic source for Venera.
## Preparation
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
- An editor that supports javascript.
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
## Start Writing
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
Here is a brief introduction to the template:
> Note: Javascript api document is [here](js_api.md).
### Write basic information
```javascript
class NewComicSource extends ComicSource {
// Note: The fields which are marked as [Optional] should be removed if not used
// name of the source
name = ""
// unique id of the source
key = ""
version = "1.0.0"
minAppVersion = "1.0.0"
// update url
url = ""
// ...
}
```
In this part, you need to do the following:
- Change the class name to your source name.
- Fill in the name, key, version, minAppVersion, and url fields.
### init function
```javascript
/**
* [Optional] init function
*/
init() {
}
```
The function will be called when the source is initialized. You can do some initialization work here.
Remove this function if not used.
### Account
```javascript
// [Optional] account related
account = {
/**
* [Optional] login with account and password, return any value to indicate success
* @param account {string}
* @param pwd {string}
* @returns {Promise<any>}
*/
login: async (account, pwd) => {
},
/**
* [Optional] login with webview
*/
loginWithWebview: {
url: "",
/**
* check login status
* @param url {string} - current url
* @param title {string} - current title
* @returns {boolean} - return true if login success
*/
checkStatus: (url, title) => {
},
/**
* [Optional] Callback when login success
*/
onLoginSuccess: () => {
},
},
/**
* [Optional] login with cookies
* Note: If `this.account.login` is implemented, this will be ignored
*/
loginWithCookies: {
fields: [
"ipb_member_id",
"ipb_pass_hash",
"igneous",
"star",
],
/**
* Validate cookies, return false if cookies are invalid.
*
* Use `Network.setCookies` to set cookies before validate.
* @param values {string[]} - same order as `fields`
* @returns {Promise<boolean>}
*/
validate: async (values) => {
},
},
/**
* logout function, clear account related data
*/
logout: () => {
},
// {string?} - register url
registerWebsite: null
}
```
In this part, you can implement login, logout, and register functions.
Remove this part if not used.
### Explore page
```javascript
// explore page list
explore = [
{
// title of the page.
// title is used to identify the page, it should be unique
title: "",
/// multiPartPage or multiPageComicList or mixed
type: "multiPartPage",
/**
* load function
* @param page {number | null} - page number, null for `singlePageWithMultiPart` type
* @returns {{}}
* - for `multiPartPage` type, return {title: string, comics: Comic[], viewMore: string?}[]
* - for `multiPageComicList` type, for each page(1-based), return {comics: Comic[], maxPage: number}
* - for `mixed` type, use param `page` as index. for each index(0-based), return {data: [], maxPage: number?}, data is an array contains Comic[] or {title: string, comics: Comic[], viewMore: string?}
*/
load: async (page) => {
},
/**
* Only use for `multiPageComicList` type.
* `loadNext` would be ignored if `load` function is implemented.
* @param next {string | null} - next page token, null if first page
* @returns {Promise<{comics: Comic[], next: string?}>} - next is null if no next page.
*/
loadNext(next) {},
}
]
```
In this part, you can implement the explore page.
A comic source can have multiple explore pages.
There are three types of explore pages:
- multiPartPage: An explore page contains multiple parts, each part contains multiple comics.
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
### Category Page
```javascript
// categories
category = {
/// title of the category page, used to identify the page, it should be unique
title: "",
parts: [
{
// title of the part
name: "Theme",
// fixed or random
// if random, need to provide `randomNumber` field, which indicates the number of comics to display at the same time
type: "fixed",
// number of comics to display at the same time
// randomNumber: 5,
categories: ["All", "Adventure", "School"],
// category or search
// if `category`, use categoryComics.load to load comics
// if `search`, use search.load to load comics
itemType: "category",
// [Optional] {string[]?} must have same length as categories, used to provide loading param for each category
categoryParams: ["all", "adventure", "school"],
// [Optional] {string} cannot be used with `categoryParams`, set all category params to this value
groupParam: null,
}
],
// enable ranking page
enableRankingPage: false,
}
```
Category page is a static page that contains multiple parts, each part contains multiple categories.
A comic source can only have one category page.
### Category Comics Page
```javascript
/// category comic loading related
categoryComics = {
/**
* load comics of a category
* @param category {string} - category name
* @param param {string?} - category param
* @param options {string[]} - options from optionList
* @param page {number} - page number
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
load: async (category, param, options, page) => {
},
// provide options for category comic loading
optionList: [
{
// For a single option, use `-` to separate the value and text, left for value, right for text
options: [
"newToOld-New to Old",
"oldToNew-Old to New"
],
// [Optional] {string[]} - show this option only when the value not in the list
notShowWhen: null,
// [Optional] {string[]} - show this option only when the value in the list
showWhen: null
}
],
ranking: {
// For a single option, use `-` to separate the value and text, left for value, right for text
options: [
"day-Day",
"week-Week"
],
/**
* load ranking comics
* @param option {string} - option from optionList
* @param page {number} - page number
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
load: async (option, page) => {
}
}
}
```
When user clicks on a category, the category comics page will be displayed.
This part is used to load comics of a category.
### Search
```javascript
/// search related
search = {
/**
* load search result
* @param keyword {string}
* @param options {(string | null)[]} - options from optionList
* @param page {number}
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
load: async (keyword, options, page) => {
},
/**
* load search result with next page token.
* The field will be ignored if `load` function is implemented.
* @param keyword {string}
* @param options {(string)[]} - options from optionList
* @param next {string | null}
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
loadNext: async (keyword, options, next) => {
},
// provide options for search
optionList: [
{
// [Optional] default is `select`
// type: select, multi-select, dropdown
// For select, there is only one selected value
// For multi-select, there are multiple selected values or none. The `load` function will receive a json string which is an array of selected values
// For dropdown, there is one selected value at most. If no selected value, the `load` function will receive a null
type: "select",
// For a single option, use `-` to separate the value and text, left for value, right for text
options: [
"0-time",
"1-popular"
],
// option label
label: "sort",
// default selected options
default: null,
}
],
// enable tags suggestions
enableTagsSuggestions: false,
}
```
This part is used to load search results.
`load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored.
### Favorites
```javascript
// favorite related
favorites = {
// whether support multi folders
multiFolder: false,
/**
* add or delete favorite.
* throw `Login expired` to indicate login expired, App will automatically re-login and re-add/delete favorite
* @param comicId {string}
* @param folderId {string}
* @param isAdding {boolean} - true for add, false for delete
* @param favoriteId {string?} - [Comic.favoriteId]
* @returns {Promise<any>} - return any value to indicate success
*/
addOrDelFavorite: async (comicId, folderId, isAdding, favoriteId) => {
},
/**
* load favorite folders.
* throw `Login expired` to indicate login expired, App will automatically re-login retry.
* if comicId is not null, return favorite folders which contains the comic.
* @param comicId {string?}
* @returns {Promise<{folders: {[p: string]: string}, favorited: string[]}>} - `folders` is a map of folder id to folder name, `favorited` is a list of folder id which contains the comic
*/
loadFolders: async (comicId) => {
},
/**
* add a folder
* @param name {string}
* @returns {Promise<any>} - return any value to indicate success
*/
addFolder: async (name) => {
},
/**
* delete a folder
* @param folderId {string}
* @returns {Promise<void>} - return any value to indicate success
*/
deleteFolder: async (folderId) => {
},
/**
* load comics in a folder
* throw `Login expired` to indicate login expired, App will automatically re-login retry.
* @param page {number}
* @param folder {string?} - folder id, null for non-multi-folder
* @returns {Promise<{comics: Comic[], maxPage: number}>}
*/
loadComics: async (page, folder) => {
},
/**
* load comics with next page token
* @param next {string | null} - next page token, null for first page
* @param folder {string}
* @returns {Promise<{comics: Comic[], next: string?}>}
*/
loadNext: async (next, folder) => {
},
}
```
This part is used to manage network favorites of the source.
`load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored.
### Comic Details
```javascript
/// single comic related
comic = {
/**
* load comic info
* @param id {string}
* @returns {Promise<ComicDetails>}
*/
loadInfo: async (id) => {
},
/**
* [Optional] load thumbnails of a comic
*
* To render a part of an image as thumbnail, return `${url}@x=${start}-${end}&y=${start}-${end}`
* - If width is not provided, use full width
* - If height is not provided, use full height
* @param id {string}
* @param next {string?} - next page token, null for first page
* @returns {Promise<{thumbnails: string[], next: string?}>} - `next` is next page token, null for no more
*/
loadThumbnails: async (id, next) => {
},
/**
* rate a comic
* @param id
* @param rating {number} - [0-10] app use 5 stars, 1 rating = 0.5 stars,
* @returns {Promise<any>} - return any value to indicate success
*/
starRating: async (id, rating) => {
},
/**
* load images of a chapter
* @param comicId {string}
* @param epId {string?}
* @returns {Promise<{images: string[]}>}
*/
loadEp: async (comicId, epId) => {
},
/**
* [Optional] provide configs for an image loading
* @param url
* @param comicId
* @param epId
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
*/
onImageLoad: (url, comicId, epId) => {
return {}
},
/**
* [Optional] provide configs for a thumbnail loading
* @param url {string}
* @returns {ImageLoadingConfig | Promise<ImageLoadingConfig>}
*
* `ImageLoadingConfig.modifyImage` and `ImageLoadingConfig.onLoadFailed` will be ignored.
* They are not supported for thumbnails.
*/
onThumbnailLoad: (url) => {
return {}
},
/**
* [Optional] like or unlike a comic
* @param id {string}
* @param isLike {boolean} - true for like, false for unlike
* @returns {Promise<void>}
*/
likeComic: async (id, isLike) => {
},
/**
* [Optional] load comments
*
* Since app version 1.0.6, rich text is supported in comments.
* Following html tags are supported: ['a', 'b', 'i', 'u', 's', 'br', 'span', 'img'].
* span tag supports style attribute, but only support font-weight, font-style, text-decoration.
* All images will be placed at the end of the comment.
* Auto link detection is enabled, but only http/https links are supported.
* @param comicId {string}
* @param subId {string?} - ComicDetails.subId
* @param page {number}
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
* @returns {Promise<{comments: Comment[], maxPage: number?}>}
*/
loadComments: async (comicId, subId, page, replyTo) => {
},
/**
* [Optional] send a comment, return any value to indicate success
* @param comicId {string}
* @param subId {string?} - ComicDetails.subId
* @param content {string}
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
* @returns {Promise<any>}
*/
sendComment: async (comicId, subId, content, replyTo) => {
},
/**
* [Optional] like or unlike a comment
* @param comicId {string}
* @param subId {string?} - ComicDetails.subId
* @param commentId {string}
* @param isLike {boolean} - true for like, false for unlike
* @returns {Promise<void>}
*/
likeComment: async (comicId, subId, commentId, isLike) => {
},
/**
* [Optional] vote a comment
* @param id {string} - comicId
* @param subId {string?} - ComicDetails.subId
* @param commentId {string} - commentId
* @param isUp {boolean} - true for up, false for down
* @param isCancel {boolean} - true for cancel, false for vote
* @returns {Promise<number>} - new score
*/
voteComment: async (id, subId, commentId, isUp, isCancel) => {
},
// {string?} - regex string, used to identify comic id from user input
idMatch: null,
/**
* [Optional] Handle tag click event
* @param namespace {string}
* @param tag {string}
* @returns {{action: string, keyword: string, param: string?}}
*/
onClickTag: (namespace, tag) => {
},
/**
* [Optional] Handle links
*/
link: {
/**
* set accepted domains
*/
domains: [
'example.com'
],
/**
* parse url to comic id
* @param url {string}
* @returns {string | null}
*/
linkToId: (url) => {
}
},
// enable tags translate
enableTagsTranslate: false,
}
```
This part is used to load comic details.
### Settings
```javascript
/*
[Optional] settings related
Use this.loadSetting to load setting
```
let setting1Value = this.loadSetting('setting1')
console.log(setting1Value)
```
*/
settings = {
setting1: {
// title
title: "Setting1",
// type: input, select, switch
type: "select",
// options
options: [
{
// value
value: 'o1',
// [Optional] text, if not set, use value as text
text: 'Option 1',
},
],
default: 'o1',
},
setting2: {
title: "Setting2",
type: "switch",
default: true,
},
setting3: {
title: "Setting3",
type: "input",
validator: null, // string | null, regex string
default: '',
},
setting4: {
title: "Setting4",
type: "callback",
buttonText: "Click me",
/**
* callback function
*
* If the callback function returns a Promise, the button will show a loading indicator until the promise is resolved.
* @returns {void | Promise<any>}
*/
callback: () => {
// do something
}
}
}
```
This part is used to provide settings for the source.
### Translations
```javascript
// [Optional] translations for the strings in this config
translation = {
'zh_CN': {
'Setting1': '设置1',
'Setting2': '设置2',
'Setting3': '设置3',
},
'zh_TW': {},
'en': {}
}
```
This part is used to provide translations for the source.
> Note: strings in the UI api will not be translated automatically. You need to translate them manually.

61
doc/import_comic.md Normal file
View File

@@ -0,0 +1,61 @@
# Import Comic
## Introduction
Venera supports importing comics from local files.
However, the comic files must be in a specific format.
## Comic Directory
A directory considered as a comic directory only if it follows one of the following two types of structure:
**Without Chapter**
```
comic_directory
├── cover.[ext]
├── img1.[ext]
├── img2.[ext]
├── img3.[ext]
├── ...
```
**With Chapter**
```
comic_directory
├── cover.[ext]
├── chapter1
│ ├── img1.[ext]
│ ├── img2.[ext]
│ ├── img3.[ext]
│ ├── ...
├── chapter2
│ ├── img1.[ext]
│ ├── img2.[ext]
│ ├── img3.[ext]
│ ├── ...
├── ...
```
The file name can be anything, but the extension must be a valid image extension.
The page order is determined by the file name. App will sort the files by name and display them in that order.
Cover image is optional.
If there is a file named `cover.[ext]` in the directory, it will be considered as the cover image.
Otherwise, the first image will be considered as the cover image.
The name of directory will be used as comic title. And the name of chapter directory will be used as chapter title.
## Archive
Venera supports importing comics from archive files.
The archive file must follow [Comic Book Archive](https://en.wikipedia.org/wiki/Comic_book_archive_file) format.
Currently, Venera supports the following archive formats:
- `.cbz`
- `.cb7`
- `.zip`
- `.7z`

513
doc/js_api.md Normal file
View File

@@ -0,0 +1,513 @@
# Javascript API
## Overview
The Javascript API is a set of functions that used to interact application.
There are following parts in the API:
- [Convert](#Convert)
- [Network](#Network)
- [Html](#Html)
- [UI](#UI)
- [Utils](#Utils)
- [Types](#Types)
## Convert
Convert is a set of functions that used to convert data between different types.
### `Convert.encodeUtf8(str: string): ArrayBuffer`
Convert a string to an ArrayBuffer.
### `Convert.decodeUtf8(value: ArrayBuffer): string`
Convert an ArrayBuffer to a string.
### `Convert.encodeBase64(value: ArrayBuffer): string`
Convert an ArrayBuffer to a base64 string.
### `Convert.decodeBase64(value: string): ArrayBuffer`
Convert a base64 string to an ArrayBuffer.
### `Convert.md5(value: ArrayBuffer): ArrayBuffer`
Calculate the md5 hash of an ArrayBuffer.
### `Convert.sha1(value: ArrayBuffer): ArrayBuffer`
Calculate the sha1 hash of an ArrayBuffer.
### `Convert.sha256(value: ArrayBuffer): ArrayBuffer`
Calculate the sha256 hash of an ArrayBuffer.
### `Convert.sha512(value: ArrayBuffer): ArrayBuffer`
Calculate the sha512 hash of an ArrayBuffer.
### `Convert.hmac(key: ArrayBuffer, value: ArrayBuffer, hash: string): ArrayBuffer`
Calculate the hmac hash of an ArrayBuffer.
### `Convert.hmacString(key: ArrayBuffer, value: ArrayBuffer, hash: string): string`
Calculate the hmac hash of an ArrayBuffer and return a string.
### `Convert.decryptAesEcb(value: ArrayBuffer, key: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with AES ECB mode.
### `Convert.decryptAesCbc(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with AES CBC mode.
### `Convert.decryptAesCfb(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with AES CFB mode.
### `Convert.decryptAesOfb(value: ArrayBuffer, key: ArrayBuffer, iv: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with AES OFB mode.
### `Convert.decryptRsa(value: ArrayBuffer, key: ArrayBuffer): ArrayBuffer`
Decrypt an ArrayBuffer with RSA.
### `Convert.hexEncode(value: ArrayBuffer): string`
Convert an ArrayBuffer to a hex string.
## Network
Network is a set of functions that used to send network requests and manage network resources.
### `Network.fetchBytes(method: string, url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: ArrayBuffer}>`
Send a network request and return the response as an ArrayBuffer.
### `Network.sendRequest(method: string, url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
Send a network request and return the response as a string.
### `Network.get(url: string, headers: object): Promise<{status: number, headers: object, body: string}>`
Send a GET request and return the response as a string.
### `Network.post(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
Send a POST request and return the response as a string.
### `Network.put(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
Send a PUT request and return the response as a string.
### `Network.delete(url: string, headers: object): Promise<{status: number, headers: object, body: string}>`
Send a DELETE request and return the response as a string.
### `Network.patch(url: string, headers: object, data: ArrayBuffer): Promise<{status: number, headers: object, body: string}>`
Send a PATCH request and return the response as a string.
### `Network.setCookies(url: string, cookies: Cookie[]): void`
Set cookies for a specific url.
### `Network.getCookies(url: string): Cookie[]`
Get cookies for a specific url.
### `Network.deleteCookies(url: string): void`
Delete cookies for a specific url.
### `fetch`
The fetch function is a wrapper of the `Network.fetchBytes` function. Same as the `fetch` function in the browser.
## Html
Api for parsing HTML.
### `new HtmlDocument(html: string): HtmlDocument`
Create a HtmlDocument object from a html string.
### `HtmlDocument.querySelector(selector: string): HtmlElement`
Find the first element that matches the selector.
### `HtmlDocument.querySelectorAll(selector: string): HtmlElement[]`
Find all elements that match the selector.
### `HtmlDocument.getElementById(id: string): HtmlElement`
Find the element with the id.
### `HtmlDocument.dispose(): void`
Dispose the HtmlDocument object.
### `HtmlElement.querySelector(selector: string): HtmlElement`
Find the first element that matches the selector.
### `HtmlElement.querySelectorAll(selector: string): HtmlElement[]`
Find all elements that match the selector.
### `HtmlElement.getElementById(id: string): HtmlElement`
Find the element with the id.
### `get HtmlElement.text(): string`
Get the text content of the element.
### `get HtmlElement.attributes(): object`
Get the attributes of the element.
### `get HtmlElement.children(): HtmlElement[]`
Get the children
### `get HtmlElement.nodes(): HtmlNode[]`
Get the child nodes
### `get HtmlElement.parent(): HtmlElement | null`
Get the parent element
### `get HtmlElement.innerHtml(): string`
Get the inner html
### `get HtmlElement.classNames(): string[]`
Get the class names
### `get HtmlElement.id(): string | null`
Get the id
### `get HtmlElement.localName(): string`
Get the local name
### `get HtmlElement.previousSibling(): HtmlElement | null`
Get the previous sibling
### `get HtmlElement.nextSibling(): HtmlElement | null`
Get the next sibling
### `get HtmlNode.type(): string`
Get the node type ("text", "element", "comment", "document", "unknown")
### `HtmlNode.toElement(): HtmlElement | null`
Convert the node to an element
### `get HtmlNode.text(): string`
Get the text content of the node
## UI
### `UI.showMessage(message: string): void`
Show a message.
### `UI.showDialog(title: string, content: string, actions: {text: string, callback: () => void | Promise<void>, style: "text"|"filled"|"danger"}[]): void`
Show a dialog. Any action will close the dialog.
### `UI.launchUrl(url: string): void`
Open a url in external browser.
### `UI.showLoading(onCancel: () => void | null | undefined): number`
Show a loading dialog.
### `UI.cancelLoading(id: number): void`
Cancel a loading dialog.
### `UI.showInputDialog(title: string, validator: (string) => string | null | undefined): string | null`
Show an input dialog.
### `UI.showSelectDialog(title: string, options: string[], initialIndex?: number): number | null`
Show a select dialog.
## Utils
### `createUuid(): string`
create a time-based uuid.
### `randomInt(min: number, max: number): number`
Generate a random integer between min and max.
### `randomDouble(min: number, max: number): number`
Generate a random double between min and max.
### console
Send log to application console. Same api as the browser console.
## Types
### `Cookie`
```javascript
/**
* Create a cookie object.
* @param name {string}
* @param value {string}
* @param domain {string}
* @constructor
*/
function Cookie({name, value, domain}) {
this.name = name;
this.value = value;
this.domain = domain;
}
```
### `Comic`
```javascript
/**
* Create a comic object
* @param id {string}
* @param title {string}
* @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string}
* @param tags {string[]}
* @param description {string}
* @param maxPage {number?}
* @param language {string?}
* @param favoriteId {string?} - Only set this field if the comic is from favorites page
* @param stars {number?} - 0-5, double
* @constructor
*/
function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage, language, favoriteId, stars}) {
this.id = id;
this.title = title;
this.subtitle = subtitle;
this.subTitle = subTitle;
this.cover = cover;
this.tags = tags;
this.description = description;
this.maxPage = maxPage;
this.language = language;
this.favoriteId = favoriteId;
this.stars = stars;
}
```
### `ComicDetails`
```javascript
/**
* Create a comic details object
* @param title {string}
* @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string}
* @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined}
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null
* @param subId {string?} - a param which is passed to comments api
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[]?} - related comics
* @param commentCount {number?}
* @param likesCount {number?}
* @param isLiked {boolean?}
* @param uploader {string?}
* @param updateTime {string?}
* @param uploadTime {string?}
* @param url {string?}
* @param stars {number?} - 0-5, double
* @param maxPage {number?}
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
* @constructor
*/
function ComicDetails({title, subtitle, subTitle, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
this.title = title;
this.subtitle = subtitle ?? subTitle;
this.cover = cover;
this.description = description;
this.tags = tags;
this.chapters = chapters;
this.isFavorite = isFavorite;
this.subId = subId;
this.thumbnails = thumbnails;
this.recommend = recommend;
this.commentCount = commentCount;
this.likesCount = likesCount;
this.isLiked = isLiked;
this.uploader = uploader;
this.updateTime = updateTime;
this.uploadTime = uploadTime;
this.url = url;
this.stars = stars;
this.maxPage = maxPage;
this.comments = comments;
}
```
### `Comment`
```javascript
/**
* Create a comment object
* @param userName {string}
* @param avatar {string?}
* @param content {string}
* @param time {string?}
* @param replyCount {number?}
* @param id {string?}
* @param isLiked {boolean?}
* @param score {number?}
* @param voteStatus {number?} - 1: upvote, -1: downvote, 0: none
* @constructor
*/
function Comment({userName, avatar, content, time, replyCount, id, isLiked, score, voteStatus}) {
this.userName = userName;
this.avatar = avatar;
this.content = content;
this.time = time;
this.replyCount = replyCount;
this.id = id;
this.isLiked = isLiked;
this.score = score;
this.voteStatus = voteStatus;
}
```
### `ImageLoadingConfig`
```javascript
/**
* Create image loading config
* @param url {string?}
* @param method {string?} - http method, uppercase
* @param data {any} - request data, may be null
* @param headers {Object?} - request headers
* @param onResponse {((ArrayBuffer) => ArrayBuffer)?} - modify response data
* @param modifyImage {string?}
* A js script string.
* The script will be executed in a new Isolate.
* A function named `modifyImage` should be defined in the script, which receives an [Image] as the only argument, and returns an [Image]..
* @param onLoadFailed {(() => ImageLoadingConfig)?} - called when the image loading failed
* @constructor
* @since 1.0.5
*
* To keep the compatibility with the old version, do not use the constructor. Consider creating a new object with the properties directly.
*/
function ImageLoadingConfig({url, method, data, headers, onResponse, modifyImage, onLoadFailed}) {
this.url = url;
this.method = method;
this.data = data;
this.headers = headers;
this.onResponse = onResponse;
this.modifyImage = modifyImage;
this.onLoadFailed = onLoadFailed;
}
```
### `ComicSource`
```javascript
class ComicSource {
name = ""
key = ""
version = ""
minAppVersion = ""
url = ""
/**
* load data with its key
* @param {string} dataKey
* @returns {any}
*/
loadData(dataKey) {
return sendMessage({
method: 'load_data',
key: this.key,
data_key: dataKey
})
}
/**
* load a setting with its key
* @param key {string}
* @returns {any}
*/
loadSetting(key) {
return sendMessage({
method: 'load_setting',
key: this.key,
setting_key: key
})
}
/**
* save data
* @param {string} dataKey
* @param data
*/
saveData(dataKey, data) {
return sendMessage({
method: 'save_data',
key: this.key,
data_key: dataKey,
data: data
})
}
/**
* delete data
* @param {string} dataKey
*/
deleteData(dataKey) {
return sendMessage({
method: 'delete_data',
key: this.key,
data_key: dataKey,
})
}
/**
*
* @returns {boolean}
*/
get isLogged() {
return sendMessage({
method: 'isLogged',
key: this.key,
});
}
init() { }
static sources = {}
}
```

View File

@@ -0,0 +1,15 @@
<p>A comic reader that support reading local and network comics.</p>
<h3>Features</h3>
<ul>
<li>Read local comics</li>
<li>Use javascript to create comic sources</li>
<li>Read comics from network sources</li>
<li>Manage favorite comics</li>
<li>Download comics</li>
<li>View comments, tags, and other information of comics if the source supports</li>
<li>Login to comment, rate, and other operations if the source supports</li>
</ul>
<h3>Thanks</h3>
<h4>Tags Translation</h4>
<li><a href="https://github.com/EhTagTranslation/Database">github.com/EhTagTranslation/Database</a></li>
<p>The Chinese translation of the manga tags is from this project.</p>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -0,0 +1 @@
A comic reader that support reading local and network comics.

View File

@@ -0,0 +1 @@
venera

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # Uncomment this line to define a global platform for your project
platform :ios, '14.0' platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -15,6 +15,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
C0086D072CDEFE6E004596D9 /* DirectoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -59,6 +60,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryPicker.swift; sourceTree = "<group>"; };
C22B8A9F3177D4A68EB8F66B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; C22B8A9F3177D4A68EB8F66B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -133,6 +135,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -144,7 +147,6 @@
730F73FE38E23FCF3E461640 /* Pods-Runner.release.xcconfig */, 730F73FE38E23FCF3E461640 /* Pods-Runner.release.xcconfig */,
29B89F848F26E839605E1D88 /* Pods-Runner.profile.xcconfig */, 29B89F848F26E839605E1D88 /* Pods-Runner.profile.xcconfig */,
); );
name = Pods;
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -336,6 +338,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
C0086D072CDEFE6E004596D9 /* DirectoryPicker.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
); );

View File

@@ -1,12 +1,16 @@
import Flutter import Flutter
import UIKit import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import Foundation //
@main @main
@objc class AppDelegate: FlutterAppDelegate, UIDocumentPickerDelegate { @objc class AppDelegate: FlutterAppDelegate, UIDocumentPickerDelegate {
var flutterResult: FlutterResult? var flutterResult: FlutterResult?
var directoryPath: URL! var directoryPath: URL!
//
private var directoryPicker: DirectoryPicker?
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -42,6 +46,9 @@ import UniformTypeIdentifiers
self.directoryPath?.stopAccessingSecurityScopedResource() self.directoryPath?.stopAccessingSecurityScopedResource()
self.directoryPath = nil self.directoryPath = nil
result(nil) result(nil)
} else if call.method == "selectDirectory" {
self.directoryPicker = DirectoryPicker()
self.directoryPicker?.selectDirectory(result: result)
} else { } else {
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }

View File

@@ -0,0 +1,36 @@
import UIKit
import Flutter
class DirectoryPicker: NSObject, UIDocumentPickerDelegate {
private var result: FlutterResult?
//
func selectDirectory(result: @escaping FlutterResult) {
self.result = result
// UIDocumentPicker
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
documentPicker.delegate = self
documentPicker.allowsMultipleSelection = false
//
if let rootViewController = UIApplication.shared.keyWindow?.rootViewController {
rootViewController.present(documentPicker, animated: true, completion: nil)
}
}
//
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
//
if let url = urls.first {
result?(url.path)
} else {
result?(nil)
}
}
//
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
result?(nil)
}
}

View File

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

View File

@@ -1,12 +1,14 @@
part of 'components.dart'; part of 'components.dart';
class Appbar extends StatefulWidget implements PreferredSizeWidget { class Appbar extends StatefulWidget implements PreferredSizeWidget {
const Appbar( const Appbar({
{required this.title, required this.title,
this.leading, this.leading,
this.actions, this.actions,
this.backgroundColor, this.backgroundColor,
super.key}); this.style = AppbarStyle.blur,
super.key,
});
final Widget title; final Widget title;
@@ -16,6 +18,8 @@ class Appbar extends StatefulWidget implements PreferredSizeWidget {
final Color? backgroundColor; final Color? backgroundColor;
final AppbarStyle style;
@override @override
State<Appbar> createState() => _AppbarState(); State<Appbar> createState() => _AppbarState();
@@ -76,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.withOpacity(0.72), context.colorScheme.surface.toOpacity(0.72),
), ),
height: _kAppBarHeight + context.padding.top, height: _kAppBarHeight + context.padding.top,
child: Row( child: Row(
@@ -108,13 +112,26 @@ class _AppbarState extends State<Appbar> {
], ],
).paddingTop(context.padding.top), ).paddingTop(context.padding.top),
); );
return BlurEffect( if (widget.style == AppbarStyle.shadow) {
blur: _scrolledUnder ? 15 : 0, return Material(
child: content, color: context.colorScheme.surface,
); elevation: _scrolledUnder ? 2 : 0,
child: content,
);
} else {
return BlurEffect(
blur: _scrolledUnder ? 15 : 0,
child: content,
);
}
} }
} }
enum AppbarStyle {
blur,
shadow,
}
class SliverAppbar extends StatelessWidget { class SliverAppbar extends StatelessWidget {
const SliverAppbar({ const SliverAppbar({
super.key, super.key,
@@ -122,6 +139,7 @@ class SliverAppbar extends StatelessWidget {
this.leading, this.leading,
this.actions, this.actions,
this.radius = 0, this.radius = 0,
this.style = AppbarStyle.blur,
}); });
final Widget? leading; final Widget? leading;
@@ -132,6 +150,8 @@ class SliverAppbar extends StatelessWidget {
final double radius; final double radius;
final AppbarStyle style;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverPersistentHeader( return SliverPersistentHeader(
@@ -142,6 +162,7 @@ class SliverAppbar extends StatelessWidget {
actions: actions, actions: actions,
topPadding: MediaQuery.of(context).padding.top, topPadding: MediaQuery.of(context).padding.top,
radius: radius, radius: radius,
style: style,
), ),
); );
} }
@@ -160,57 +181,73 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
final double radius; final double radius;
_MySliverAppBarDelegate( final AppbarStyle style;
{this.leading,
required this.title, _MySliverAppBarDelegate({
this.actions, this.leading,
required this.topPadding, required this.title,
this.radius = 0}); this.actions,
required this.topPadding,
this.radius = 0,
this.style = AppbarStyle.blur,
});
@override @override
Widget build( Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) { BuildContext context, double shrinkOffset, bool overlapsContent) {
return SizedBox.expand( var body = Row(
child: BlurEffect( children: [
blur: 15, const SizedBox(width: 8),
child: Material( leading ??
color: context.colorScheme.surface.withOpacity(0.72), (Navigator.of(context).canPop()
elevation: 0, ? Tooltip(
borderRadius: BorderRadius.circular(radius), message: "Back".tl,
child: Row( child: IconButton(
children: [ icon: const Icon(Icons.arrow_back),
const SizedBox(width: 8), onPressed: () => Navigator.maybePop(context),
leading ?? ),
(Navigator.of(context).canPop() )
? Tooltip( : const SizedBox()),
message: "Back".tl, const SizedBox(
child: IconButton( width: 16,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.maybePop(context),
),
)
: const SizedBox()),
const SizedBox(
width: 16,
),
Expanded(
child: DefaultTextStyle(
style:
DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: title,
),
),
...?actions,
const SizedBox(
width: 8,
)
],
).paddingTop(topPadding),
), ),
), Expanded(
); child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: title,
),
),
...?actions,
const SizedBox(
width: 8,
)
],
).paddingTop(topPadding);
if (style == AppbarStyle.blur) {
return SizedBox.expand(
child: BlurEffect(
blur: 15,
child: Material(
color: context.colorScheme.surface.toOpacity(0.72),
elevation: 0,
borderRadius: BorderRadius.circular(radius),
child: body,
),
),
);
} else {
return SizedBox.expand(
child: Material(
color: context.colorScheme.surface,
elevation: shrinkOffset == 0 ? 0 : 2,
borderRadius: BorderRadius.circular(radius),
child: body,
),
);
}
} }
@override @override
@@ -224,22 +261,35 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
return oldDelegate is! _MySliverAppBarDelegate || return oldDelegate is! _MySliverAppBarDelegate ||
leading != oldDelegate.leading || leading != oldDelegate.leading ||
title != oldDelegate.title || title != oldDelegate.title ||
actions != oldDelegate.actions; actions != oldDelegate.actions ||
topPadding != oldDelegate.topPadding ||
radius != oldDelegate.radius ||
style != oldDelegate.style;
} }
} }
class FilledTabBar extends StatefulWidget { class AppTabBar extends StatefulWidget {
const FilledTabBar({super.key, this.controller, required this.tabs}); const AppTabBar({
super.key,
this.controller,
required this.tabs,
this.actionButton,
this.withUnderLine = true,
});
final TabController? controller; final TabController? controller;
final List<Tab> tabs; final List<Tab> tabs;
final Widget? actionButton;
final bool withUnderLine;
@override @override
State<FilledTabBar> createState() => _FilledTabBarState(); State<AppTabBar> createState() => _AppTabBarState();
} }
class _FilledTabBarState extends State<FilledTabBar> { class _AppTabBarState extends State<AppTabBar> {
late TabController _controller; late TabController _controller;
late List<GlobalKey> keys; late List<GlobalKey> keys;
@@ -269,16 +319,25 @@ class _FilledTabBarState extends State<FilledTabBar> {
super.dispose(); super.dispose();
} }
PageStorageBucket get bucket => PageStorage.of(context);
@override @override
void didChangeDependencies() { void didChangeDependencies() {
_controller = widget.controller ?? DefaultTabController.of(context); _controller = widget.controller ?? DefaultTabController.of(context);
_controller.animation!.addListener(onTabChanged);
initPainter(); initPainter();
super.didChangeDependencies(); super.didChangeDependencies();
var prevIndex = bucket.readState(context) as int?;
if (prevIndex != null &&
prevIndex != _controller.index &&
prevIndex >= 0 &&
prevIndex < widget.tabs.length) {
_controller.index = prevIndex;
}
_controller.animation!.addListener(onTabChanged);
} }
@override @override
void didUpdateWidget(covariant FilledTabBar oldWidget) { void didUpdateWidget(covariant AppTabBar oldWidget) {
if (widget.controller != oldWidget.controller) { if (widget.controller != oldWidget.controller) {
_controller = widget.controller ?? DefaultTabController.of(context); _controller = widget.controller ?? DefaultTabController.of(context);
_controller.animation!.addListener(onTabChanged); _controller.animation!.addListener(onTabChanged);
@@ -303,7 +362,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
animation: _controller, animation: _controller.animation ?? _controller,
builder: buildTabBar, builder: buildTabBar,
); );
} }
@@ -318,6 +377,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
controller: scrollController, controller: scrollController,
builder: (context, controller, physics) { builder: (context, controller, physics) {
return SingleChildScrollView( return SingleChildScrollView(
key: const PageStorageKey('scroll'),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
controller: controller, controller: controller,
@@ -328,25 +388,29 @@ class _FilledTabBarState extends State<FilledTabBar> {
painter: painter, painter: painter,
child: _TabRow( child: _TabRow(
callback: _tabLayoutCallback, callback: _tabLayoutCallback,
children: List.generate(widget.tabs.length, buildTab), children: List.generate(widget.tabs.length, buildTab)
..addIfNotNull(widget.actionButton?.padding(tabPadding)),
), ),
).paddingHorizontal(4), ).paddingHorizontal(4),
); );
}, },
); );
return Container( return Container(
key: tabBarKey, key: tabBarKey,
height: _kTabHeight, height: _kTabHeight,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: widget.withUnderLine
border: Border( ? BoxDecoration(
bottom: BorderSide( border: Border(
color: context.colorScheme.outlineVariant, bottom: BorderSide(
width: 0.6, color: context.colorScheme.outlineVariant,
), width: 0.6,
), ),
), ),
child: widget.tabs.isEmpty ? const SizedBox() : child); )
: null,
child: widget.tabs.isEmpty ? const SizedBox() : child,
);
} }
int? previousIndex; int? previousIndex;
@@ -358,6 +422,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
} }
updateScrollOffset(i); updateScrollOffset(i);
previousIndex = i; previousIndex = i;
bucket.writeState(context, i);
} }
void updateScrollOffset(int i) { void updateScrollOffset(int i) {
@@ -369,10 +434,14 @@ class _FilledTabBarState extends State<FilledTabBar> {
final double tabWidth = tabRight - tabLeft; final double tabWidth = tabRight - tabLeft;
final double tabCenter = tabLeft + tabWidth / 2; final double tabCenter = tabLeft + tabWidth / 2;
final double tabBarWidth = tabBarBox.size.width; final double tabBarWidth = tabBarBox.size.width;
final double scrollOffset = tabCenter - tabBarWidth / 2; double scrollOffset = tabCenter - tabBarWidth / 2;
if (scrollOffset == scrollController.offset) { if (scrollOffset == scrollController.offset) {
return; return;
} }
scrollOffset = scrollOffset.clamp(
0.0,
scrollController.position.maxScrollExtent,
);
scrollController.animateTo( scrollController.animateTo(
scrollOffset, scrollOffset,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -394,7 +463,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: DefaultTextStyle( child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith( style: DefaultTextStyle.of(context).style.copyWith(
color: i == _controller.index color: i == _controller.animation?.value.round()
? context.colorScheme.primary ? context.colorScheme.primary
: context.colorScheme.onSurface, : context.colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -501,7 +570,7 @@ class _IndicatorPainter extends CustomPainter {
var rect = Rect.fromLTWH( var rect = Rect.fromLTWH(
tabLeft + padding.left + horizontalPadding, tabLeft + padding.left + horizontalPadding,
_FilledTabBarState._kTabHeight - 3.6, _AppTabBarState._kTabHeight - 3.6,
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2, tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
3, 3,
); );
@@ -534,6 +603,51 @@ class _IndicatorPainter extends CustomPainter {
} }
} }
class TabViewBody extends StatefulWidget {
/// Create a tab view body, which will show the child at the current tab index.
const TabViewBody({super.key, required this.children, this.controller});
final List<Widget> children;
final TabController? controller;
@override
State<TabViewBody> createState() => _TabViewBodyState();
}
class _TabViewBodyState extends State<TabViewBody> {
late TabController _controller;
int _currentIndex = 0;
void updateIndex() {
if (_controller.index != _currentIndex) {
setState(() {
_currentIndex = _controller.index;
});
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controller = widget.controller ?? DefaultTabController.of(context);
_currentIndex = _controller.index;
_controller.addListener(updateIndex);
}
@override
void dispose() {
super.dispose();
_controller.removeListener(updateIndex);
}
@override
Widget build(BuildContext context) {
return widget.children[_currentIndex];
}
}
class SearchBarController { class SearchBarController {
_SearchBarMixin? _state; _SearchBarMixin? _state;
@@ -691,6 +805,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate {
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
editingController.clear(); editingController.clear();
onChanged?.call("");
}, },
); );
}, },
@@ -805,3 +920,42 @@ class _SearchBarState extends State<AppSearchBar> with _SearchBarMixin {
); );
} }
} }
class TabActionButton extends StatelessWidget {
const TabActionButton({
super.key,
required this.icon,
required this.text,
required this.onPressed,
});
final Icon icon;
final String text;
final void Function() onPressed;
static const _kTabHeight = 46.0;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Container(
height: _kTabHeight,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: IconTheme(
data: IconThemeData(size: 20, color: context.colorScheme.primary),
child: Row(
children: [
icon,
const SizedBox(width: 8),
Text(text, style: ts.withColor(context.colorScheme.primary)),
],
),
),
),
);
}
}

View File

@@ -156,7 +156,7 @@ class _ButtonState extends State<Button> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var padding = widget.padding ?? var padding = widget.padding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 4); const EdgeInsets.symmetric(horizontal: 16);
var width = widget.width; var width = widget.width;
if (width != null) { if (width != null) {
width = width - padding.horizontal; width = width - padding.horizontal;
@@ -206,6 +206,7 @@ class _ButtonState extends State<Button> {
padding: padding, padding: padding,
constraints: const BoxConstraints( constraints: const BoxConstraints(
minWidth: 76, minWidth: 76,
minHeight: 32,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: buttonColor, color: buttonColor,
@@ -213,7 +214,7 @@ class _ButtonState extends State<Button> {
boxShadow: (isHover && !isLoading && (widget.type == ButtonType.filled || widget.type == ButtonType.normal)) boxShadow: (isHover && !isLoading && (widget.type == ButtonType.filled || widget.type == ButtonType.normal))
? [ ? [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color: Colors.black.toOpacity(0.1),
blurRadius: 2, blurRadius: 2,
offset: const Offset(0, 1), offset: const Offset(0, 1),
) )
@@ -247,7 +248,7 @@ class _ButtonState extends State<Button> {
if (widget.type == ButtonType.filled) { if (widget.type == ButtonType.filled) {
var color = widget.color ?? context.colorScheme.primary; var color = widget.color ?? context.colorScheme.primary;
if (isHover) { if (isHover) {
return color.withOpacity(0.9); return color.toOpacity(0.9);
} else { } else {
return color; return color;
} }
@@ -255,13 +256,13 @@ class _ButtonState extends State<Button> {
if (widget.type == ButtonType.normal) { if (widget.type == ButtonType.normal) {
var color = widget.color ?? context.colorScheme.surfaceContainer; var color = widget.color ?? context.colorScheme.surfaceContainer;
if (isHover) { if (isHover) {
return color.withOpacity(0.9); return color.toOpacity(0.9);
} else { } else {
return color; return color;
} }
} }
if (isHover) { if (isHover) {
return context.colorScheme.outline.withOpacity(0.2); return context.colorScheme.outline.toOpacity(0.2);
} }
return Colors.transparent; return Colors.transparent;
} }
@@ -344,7 +345,7 @@ class _IconButtonState extends State<_IconButton> {
? Theme.of(context) ? Theme.of(context)
.colorScheme .colorScheme
.outlineVariant .outlineVariant
.withOpacity(0.4) .toOpacity(0.4)
: null, : null,
borderRadius: BorderRadius.circular((iconSize + 12) / 2), borderRadius: BorderRadius.circular((iconSize + 12) / 2),
), ),

383
lib/components/code.dart Normal file
View File

@@ -0,0 +1,383 @@
part of 'components.dart';
class CodeEditor extends StatefulWidget {
const CodeEditor({super.key, this.initialValue, this.onChanged});
final String? initialValue;
final void Function(String value)? onChanged;
@override
State<CodeEditor> createState() => _CodeEditorState();
}
class _CodeEditorState extends State<CodeEditor> {
late _CodeTextEditingController _controller;
late FocusNode _focusNode;
var horizontalScrollController = ScrollController();
var verticalScrollController = ScrollController();
int lineCount = 1;
@override
void initState() {
super.initState();
_controller = _CodeTextEditingController(text: widget.initialValue);
_focusNode = FocusNode()
..onKeyEvent = (node, event) {
if (event.logicalKey == LogicalKeyboardKey.tab) {
if (event is KeyDownEvent) {
handleTab();
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
lineCount = calculateLineCount(widget.initialValue ?? '');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
future = _controller.init(context.brightness);
}
void handleTab() {
var text = _controller.text;
var start = _controller.selection.start;
var end = _controller.selection.end;
_controller.text = '${text.substring(0, start)} ${text.substring(end)}';
_controller.selection = TextSelection.collapsed(offset: start + 4);
}
int calculateLineCount(String text) {
return text.split('\n').length;
}
Widget buildLineNumbers() {
return SizedBox(
width: 36,
child: Column(
children: [
for (var i = 1; i <= lineCount; i++)
SizedBox(
height: 14 * 1.5,
child: Center(
child: Text(
i.toString(),
style: TextStyle(
color: context.colorScheme.outline,
fontSize: 13,
height: 1.0,
fontFamily: 'Consolas',
fontFamilyFallback: ['Courier New', 'monospace'],
),
),
),
),
],
),
).paddingVertical(8);
}
late Future future;
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: future,
builder: (context, value) {
if (value.connectionState == ConnectionState.waiting) {
return const SizedBox();
}
return GestureDetector(
onTap: () {
_controller.selection = TextSelection.collapsed(
offset: _controller.text.length,
);
_focusNode.requestFocus();
},
child: Scrollbar(
thumbVisibility: true,
controller: verticalScrollController,
notificationPredicate: (notif) =>
notif.metrics.axis == Axis.vertical,
child: Scrollbar(
thumbVisibility: true,
controller: horizontalScrollController,
notificationPredicate: (notif) =>
notif.metrics.axis == Axis.horizontal,
child: SizedBox.expand(
child: ScrollConfiguration(
behavior: _CustomScrollBehavior(),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: horizontalScrollController,
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
controller: verticalScrollController,
child: Row(
children: [
buildLineNumbers(),
IntrinsicWidth(
stepWidth: 100,
child: TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: null,
cursorHeight: 1.5 * 14,
style: TextStyle(height: 1.5, fontSize: 14),
decoration: InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.all(8),
),
onChanged: (value) {
widget.onChanged?.call(value);
if (lineCount != calculateLineCount(value)) {
setState(() {
lineCount = calculateLineCount(value);
});
}
},
),
),
],
),
),
),
),
),
),
),
);
},
);
}
}
class _CustomScrollBehavior extends MaterialScrollBehavior {
const _CustomScrollBehavior();
@override
Widget buildScrollbar(
BuildContext context, Widget child, ScrollableDetails details) {
return child;
}
}
class _CodeTextEditingController extends TextEditingController {
_CodeTextEditingController({super.text});
HighlighterTheme? _theme;
Future<void> init(Brightness brightness) async {
Highlighter.addLanguage('js', _jsGrammer);
_theme = await HighlighterTheme.loadForBrightness(brightness);
}
@override
TextSpan buildTextSpan(
{required BuildContext context,
TextStyle? style,
required bool withComposing}) {
var highlighter = Highlighter(
language: 'js',
theme: _theme!,
);
var result = highlighter.highlight(text);
style = TextStyle(
height: 1.5,
fontSize: 14,
fontFamily: 'Consolas',
fontFamilyFallback: ['Courier New', 'Roboto Mono', 'monospace'],
);
return mergeTextStyle(result, style);
}
TextSpan mergeTextStyle(TextSpan span, TextStyle style) {
var result = TextSpan(
style: style.merge(span.style),
children: span.children
?.whereType()
.map((e) => mergeTextStyle(e, style))
.toList(),
text: span.text,
);
return result;
}
}
const _jsGrammer = r'''
{
"name": "JavaScript",
"version": "1.0.0",
"fileTypes": ["js", "mjs", "cjs"],
"scopeName": "source.js",
"foldingStartMarker": "\\{\\s*$",
"foldingStopMarker": "^\\s*\\}",
"patterns": [
{
"name": "meta.preprocessor.script.js",
"match": "^(#!.*)$"
},
{
"name": "meta.import-export.js",
"begin": "\\b(import|export)\\b",
"beginCaptures": {
"0": {
"name": "keyword.control.import.js"
}
},
"end": ";",
"endCaptures": {
"0": {
"name": "punctuation.terminator.js"
}
},
"patterns": [
{
"include": "#strings"
},
{
"include": "#comments"
},
{
"name": "keyword.control.import.js",
"match": "\\b(as|from)\\b"
}
]
},
{
"include": "#comments"
},
{
"include": "#keywords"
},
{
"include": "#constants-and-special-vars"
},
{
"include": "#operators"
},
{
"include": "#strings"
}
],
"repository": {
"comments": {
"patterns": [
{
"name": "comment.block.js",
"begin": "/\\*",
"end": "\\*/"
},
{
"name": "comment.line.double-slash.js",
"match": "//.*$"
}
]
},
"keywords": {
"patterns": [
{
"name": "keyword.control.js",
"match": "\\b(if|else|for|while|do|switch|case|default|break|continue|return|throw|try|catch|finally)\\b"
},
{
"name": "keyword.operator.js",
"match": "\\b(instanceof|typeof|new|delete|in|void)\\b"
},
{
"name": "storage.type.js",
"match": "\\b(var|let|const|function|class|extends)\\b"
},
{
"name": "keyword.declaration.js",
"match": "\\b(export|import|default)\\b"
}
]
},
"constants-and-special-vars": {
"patterns": [
{
"name": "constant.language.js",
"match": "\\b(true|false|null|undefined|NaN|Infinity)\\b"
},
{
"name": "constant.numeric.js",
"match": "\\b(0x[0-9A-Fa-f]+|[0-9]+\\.?[0-9]*(e[+-]?[0-9]+)?)\\b"
}
]
},
"operators": {
"patterns": [
{
"name": "keyword.operator.assignment.js",
"match": "(=|\\+=|-=|\\*=|/=|%=|\\|=|&=|\\^=|<<=|>>=|>>>=)"
},
{
"name": "keyword.operator.comparison.js",
"match": "(==|!=|===|!==|<|<=|>|>=)"
},
{
"name": "keyword.operator.logical.js",
"match": "(&&|\\|\\||!)"
},
{
"name": "keyword.operator.arithmetic.js",
"match": "(-|\\+|\\*|/|%)"
},
{
"name": "keyword.operator.bitwise.js",
"match": "(\\||&|\\^|~|<<|>>|>>>)"
}
]
},
"strings": {
"patterns": [
{
"name": "string.quoted.double.js",
"begin": "\"",
"end": "\"",
"patterns": [
{
"include": "#string-interpolation"
}
]
},
{
"name": "string.quoted.single.js",
"begin": "'",
"end": "'",
"patterns": [
{
"include": "#string-interpolation"
}
]
},
{
"name": "string.template.js",
"begin": "`",
"end": "`",
"patterns": [
{
"include": "#string-interpolation"
}
]
}
]
},
"string-interpolation": {
"patterns": [
{
"name": "variable.parameter.js",
"begin": "\\$\\{",
"end": "\\}"
}
]
}
}
}
''';

View File

@@ -1,5 +1,27 @@
part of 'components.dart'; part of 'components.dart';
ImageProvider? _findImageProvider(Comic comic) {
ImageProvider image;
if (comic is LocalComic) {
image = LocalComicImageProvider(comic);
} else if (comic is History) {
image = HistoryImageProvider(comic);
} else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local);
if (localComic == null) {
return null;
}
image = FileImage(localComic.coverFile);
} else {
image = CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
}
return image;
}
class ComicTile extends StatelessWidget { class ComicTile extends StatelessWidget {
const ComicTile({ const ComicTile({
super.key, super.key,
@@ -8,6 +30,8 @@ class ComicTile extends StatelessWidget {
this.badge, this.badge,
this.menuOptions, this.menuOptions,
this.onTap, this.onTap,
this.onLongPressed,
this.heroID,
}); });
final Comic comic; final Comic comic;
@@ -20,20 +44,39 @@ class ComicTile extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onLongPressed;
final int? heroID;
void _onTap() { void _onTap() {
if (onTap != null) { if (onTap != null) {
onTap!(); onTap!();
return; return;
} }
App.mainNavigatorKey?.currentContext App.mainNavigatorKey?.currentContext?.to(
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); () => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
heroID: heroID,
),
);
}
void _onLongPressed(context) {
if (onLongPressed != null) {
onLongPressed!();
return;
}
onLongPress(context);
} }
void onLongPress(BuildContext context) { void onLongPress(BuildContext context) {
var renderBox = context.findRenderObject() as RenderBox; var renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size; var size = renderBox.size;
var location = renderBox.localToGlobal( var location = renderBox.localToGlobal(
Offset(size.width / 2, size.height / 2), Offset((size.width - 242) / 2, size.height / 2),
); );
showMenu(location, context); showMenu(location, context);
} }
@@ -51,8 +94,14 @@ class ComicTile extends StatelessWidget {
icon: Icons.chrome_reader_mode_outlined, icon: Icons.chrome_reader_mode_outlined,
text: 'Details'.tl, text: 'Details'.tl,
onClick: () { onClick: () {
App.mainNavigatorKey?.currentContext App.mainNavigatorKey?.currentContext?.to(
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); () => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
),
);
}, },
), ),
MenuEntry( MenuEntry(
@@ -67,7 +116,7 @@ class ComicTile extends StatelessWidget {
icon: Icons.stars_outlined, icon: Icons.stars_outlined,
text: 'Add to favorites'.tl, text: 'Add to favorites'.tl,
onClick: () { onClick: () {
addFavorite(comic); addFavorite([comic]);
}, },
), ),
MenuEntry( MenuEntry(
@@ -93,8 +142,7 @@ class ComicTile extends StatelessWidget {
.isExist(comic.id, ComicType(comic.sourceKey.hashCode)) .isExist(comic.id, ComicType(comic.sourceKey.hashCode))
: false; : false;
var history = appdata.settings['showHistoryStatusOnTile'] var history = appdata.settings['showHistoryStatusOnTile']
? HistoryManager() ? HistoryManager().find(comic.id, ComicType(comic.sourceKey.hashCode))
.findSync(comic.id, ComicType(comic.sourceKey.hashCode))
: null; : null;
if (history?.page == 0) { if (history?.page == 0) {
history!.page = 1; history!.page = 1;
@@ -134,7 +182,7 @@ class ComicTile extends StatelessWidget {
if (history != null) if (history != null)
Container( Container(
height: 24, height: 24,
color: Colors.blue.withOpacity(0.9), color: Colors.blue.toOpacity(0.9),
constraints: const BoxConstraints(minWidth: 24), constraints: const BoxConstraints(minWidth: 24),
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: CustomPaint( child: CustomPaint(
@@ -151,16 +199,9 @@ class ComicTile extends StatelessWidget {
} }
Widget buildImage(BuildContext context) { Widget buildImage(BuildContext context) {
ImageProvider image; var image = _findImageProvider(comic);
if (comic is LocalComic) { if (image == null) {
image = FileImage((comic as LocalComic).coverFile); return const SizedBox();
} else if (comic.cover.startsWith('file://')) {
image = FileImage(File(comic.cover.substring(7)));
} else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local);
image = FileImage(localComic!.coverFile);
} else {
image = CachedImageProvider(comic.cover, sourceKey: comic.sourceKey);
} }
return AnimatedImage( return AnimatedImage(
image: image, image: image,
@@ -173,130 +214,237 @@ class ComicTile extends StatelessWidget {
Widget _buildDetailedMode(BuildContext context) { Widget _buildDetailedMode(BuildContext context) {
return LayoutBuilder(builder: (context, constrains) { return LayoutBuilder(builder: (context, constrains) {
final height = constrains.maxHeight - 16; final height = constrains.maxHeight - 16;
return InkWell(
borderRadius: BorderRadius.circular(12), Widget image = Container(
onTap: _onTap, width: height * 0.68,
onLongPress: enableLongPressed ? () => onLongPress(context) : null, height: double.infinity,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), decoration: BoxDecoration(
child: Padding( color: Theme.of(context).colorScheme.secondaryContainer,
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), borderRadius: BorderRadius.circular(8),
child: Row( boxShadow: [
children: [ BoxShadow(
Container( color: context.colorScheme.outlineVariant,
width: height * 0.68, blurRadius: 1,
height: double.infinity, offset: const Offset(0, 1),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
SizedBox.fromSize(
size: const Size(16, 5),
),
Expanded(
child: _ComicDescription(
title: comic.maxPage == null
? comic.title.replaceAll("\n", "")
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
subtitle: comic.subtitle ?? '',
description: comic.description,
badge: badge ?? comic.language,
tags: comic.tags,
maxLines: 2,
enableTranslate: ComicSource.find(comic.sourceKey)
?.enableTagsTranslate ??
false,
rating: comic.stars,
),
),
],
), ),
)); ],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
);
if (heroID != null) {
image = Hero(
tag: "cover$heroID",
child: image,
);
}
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _onTap,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row(
children: [
image,
SizedBox.fromSize(
size: const Size(16, 5),
),
Expanded(
child: _ComicDescription(
title: comic.maxPage == null
? comic.title.replaceAll("\n", "")
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
subtitle: comic.subtitle ?? '',
description: comic.description,
badge: badge ?? comic.language,
tags: comic.tags,
maxLines: 2,
enableTranslate:
ComicSource.find(comic.sourceKey)?.enableTagsTranslate ??
false,
rating: comic.stars,
),
),
],
),
),
);
}); });
} }
Widget _buildBriefMode(BuildContext context) { Widget _buildBriefMode(BuildContext context) {
return Padding( return LayoutBuilder(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), builder: (context, constraints) {
child: Material( Widget image = Container(
color: Colors.transparent, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color: context.colorScheme.secondaryContainer,
elevation: 1, borderRadius: BorderRadius.circular(8),
child: Stack( boxShadow: [
children: [ BoxShadow(
Positioned.fill( color: Colors.black.toOpacity(0.2),
child: Container( blurRadius: 2,
decoration: BoxDecoration( offset: const Offset(0, 2),
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
), ),
), ],
Positioned( ),
bottom: 0, clipBehavior: Clip.antiAlias,
left: 0, child: buildImage(context),
right: 0, );
child: Container(
width: double.infinity, if (heroID != null) {
decoration: BoxDecoration( image = Hero(
gradient: LinearGradient( tag: "cover$heroID",
begin: Alignment.topCenter, child: image,
end: Alignment.bottomCenter, );
colors: [ }
Colors.transparent,
Colors.black.withOpacity(0.3), return InkWell(
Colors.black.withOpacity(0.5), borderRadius: BorderRadius.circular(8),
]), onTap: _onTap,
borderRadius: const BorderRadius.only( onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
bottomLeft: Radius.circular(8), onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
bottomRight: Radius.circular(8), child: Column(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: image,
), ),
), Align(
child: Padding( alignment: Alignment.bottomRight,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), child: (() {
child: Text( final subtitle =
comic.title.replaceAll("\n", ""), comic.subtitle?.replaceAll('\n', '').trim();
style: const TextStyle( final text = comic.description.isNotEmpty
fontWeight: FontWeight.w500, ? comic.description.split('|').join('\n')
fontSize: 14.0, : (subtitle?.isNotEmpty == true ? subtitle : null);
color: Colors.white, final fortSize = constraints.maxWidth < 80
), ? 8.0
maxLines: 2, : constraints.maxWidth < 150
overflow: TextOverflow.ellipsis, ? 10.0
: 12.0;
if (text == null) {
return const SizedBox();
}
var children = <Widget>[];
for (var line in text.split('\n')) {
children.add(Container(
margin: const EdgeInsets.fromLTRB(2, 0, 2, 2),
padding: constraints.maxWidth < 80
? const EdgeInsets.fromLTRB(3, 1, 3, 1)
: constraints.maxWidth < 150
? const EdgeInsets.fromLTRB(4, 2, 4, 2)
: const EdgeInsets.fromLTRB(5, 2, 5, 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.black.toOpacity(0.5),
),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
),
child: Text(
line,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: fortSize,
color: Colors.white,
),
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
));
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: children,
);
})(),
), ),
), ],
)),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _onTap,
onLongPress:
enableLongPressed ? () => onLongPress(context) : null,
onSecondaryTapDown: (detail) =>
onSecondaryTap(detail, context),
borderRadius: BorderRadius.circular(8),
child: const SizedBox.expand(),
), ),
), ),
) Padding(
], padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
), child: Text(
), comic.title.replaceAll('\n', ''),
maxLines: 1,
overflow: TextOverflow.clip,
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
),
],
).paddingHorizontal(6).paddingVertical(8),
);
},
); );
} }
List<String> _splitText(String text) {
// split text by comma, brackets
var words = <String>[];
var buffer = StringBuffer();
var inBracket = false;
String? prevBracket;
for (var i = 0; i < text.length; i++) {
var c = text[i];
if (c == '[' || c == '(') {
if (inBracket) {
buffer.write(c);
} else {
if (buffer.isNotEmpty) {
words.add(buffer.toString().trim());
buffer.clear();
}
inBracket = true;
prevBracket = c;
}
} else if (c == ']' || c == ')') {
if (prevBracket == '[' && c == ']' || prevBracket == '(' && c == ')') {
if (buffer.isNotEmpty) {
words.add(buffer.toString().trim());
buffer.clear();
}
inBracket = false;
} else {
buffer.write(c);
}
} else if (c == ',') {
if (inBracket) {
buffer.write(c);
} else {
words.add(buffer.toString().trim());
buffer.clear();
}
} else {
buffer.write(c);
}
}
if (buffer.isNotEmpty) {
words.add(buffer.toString().trim());
}
words.removeWhere((element) => element == "");
words = words.toSet().toList();
return words;
}
void block(BuildContext comicTileContext) { void block(BuildContext comicTileContext) {
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {
var words = <String>[]; var words = <String>[];
var all = <String>[]; var all = <String>[];
all.addAll(comic.title.split(' ').where((element) => element != '')); all.addAll(_splitText(comic.title));
if (comic.subtitle != null && comic.subtitle != "") { if (comic.subtitle != null && comic.subtitle != "") {
all.add(comic.subtitle!); all.add(comic.subtitle!);
} }
@@ -304,26 +452,35 @@ class ComicTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: 'Block'.tl, title: 'Block'.tl,
content: Wrap( content: ConstrainedBox(
runSpacing: 8, constraints: BoxConstraints(
spacing: 8, maxHeight: math.min(400, context.height - 136),
children: [ ),
for (var word in all) child: SingleChildScrollView(
OptionChip( child: Wrap(
text: word, runSpacing: 8,
isSelected: words.contains(word), spacing: 8,
onTap: () { children: [
setState(() { for (var word in all)
if (!words.contains(word)) { OptionChip(
words.add(word); text: (comic.tags?.contains(word) ?? false)
} else { ? word.translateTagIfNeed
words.remove(word); : word,
} isSelected: words.contains(word),
}); onTap: () {
}, setState(() {
), if (!words.contains(word)) {
], words.add(word);
).paddingHorizontal(16), } else {
words.remove(word);
}
});
},
),
],
),
).paddingHorizontal(16),
),
actions: [ actions: [
Button.filled( Button.filled(
onPressed: () { onPressed: () {
@@ -396,15 +553,13 @@ class _ComicDescription extends StatelessWidget {
subtitle, subtitle,
style: TextStyle( style: TextStyle(
fontSize: 10.0, fontSize: 10.0,
color: context.colorScheme.onSurface.withOpacity(0.7)), color: context.colorScheme.onSurface.toOpacity(0.7)),
maxLines: 1, maxLines: 1,
softWrap: true, softWrap: true,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox( const SizedBox(height: 4),
height: 4, if (tags != null && tags!.isNotEmpty)
),
if (tags != null)
Expanded( Expanded(
child: LayoutBuilder(builder: (context, constraints) { child: LayoutBuilder(builder: (context, constraints) {
if (constraints.maxHeight < 22) { if (constraints.maxHeight < 22) {
@@ -413,7 +568,7 @@ class _ComicDescription extends StatelessWidget {
int cnt = (constraints.maxHeight - 22).toInt() ~/ 25; int cnt = (constraints.maxHeight - 22).toInt() ~/ 25;
return Container( return Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
height: 22 + cnt * 25, height: 21 + cnt * 24,
width: double.infinity, width: double.infinity,
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
child: Wrap( child: Wrap(
@@ -425,31 +580,30 @@ class _ComicDescription extends StatelessWidget {
children: [ children: [
for (var s in tags!) for (var s in tags!)
Container( Container(
height: 22, height: 21,
padding: const EdgeInsets.fromLTRB(3, 2, 3, 2), padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45, maxWidth: constraints.maxWidth * 0.45,
),
decoration: BoxDecoration(
color: s == "Unavailable"
? context.colorScheme.errorContainer
: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
), ),
decoration: BoxDecoration( ),
color: s == "Unavailable" ),
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius:
const BorderRadius.all(Radius.circular(8)),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
))),
], ],
), ),
).toAlign(Alignment.topCenter); ).toAlign(Alignment.topCenter);
@@ -470,6 +624,8 @@ class _ComicDescription extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
fontSize: 12.0, fontSize: 12.0,
), ),
maxLines: (tags == null || tags!.isEmpty) ? 3 : 2,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
@@ -482,11 +638,11 @@ class _ComicDescription extends StatelessWidget {
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
), ),
child: Center( child: Center(
child:Text( child: Text(
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}", "${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
) ),
), ),
], ],
) )
@@ -571,17 +727,20 @@ class _ReadingHistoryPainter extends CustomPainter {
} }
class SliverGridComics extends StatefulWidget { class SliverGridComics extends StatefulWidget {
const SliverGridComics({ const SliverGridComics(
super.key, {super.key,
required this.comics, required this.comics,
this.onLastItemBuild, this.onLastItemBuild,
this.badgeBuilder, this.badgeBuilder,
this.menuBuilder, this.menuBuilder,
this.onTap, this.onTap,
}); this.onLongPressed,
this.selections});
final List<Comic> comics; final List<Comic> comics;
final Map<Comic, bool>? selections;
final void Function()? onLastItemBuild; final void Function()? onLastItemBuild;
final String? Function(Comic)? badgeBuilder; final String? Function(Comic)? badgeBuilder;
@@ -590,22 +749,35 @@ class SliverGridComics extends StatefulWidget {
final void Function(Comic)? onTap; final void Function(Comic)? onTap;
final void Function(Comic)? onLongPressed;
@override @override
State<SliverGridComics> createState() => _SliverGridComicsState(); State<SliverGridComics> createState() => _SliverGridComicsState();
} }
class _SliverGridComicsState extends State<SliverGridComics> { class _SliverGridComicsState extends State<SliverGridComics> {
List<Comic> comics = []; List<Comic> comics = [];
List<int> heroIDs = [];
static int _nextHeroID = 0;
void generateHeroID() {
heroIDs.clear();
for (var i = 0; i < comics.length; i++) {
heroIDs.add(_nextHeroID++);
}
}
@override @override
void didUpdateWidget(covariant SliverGridComics oldWidget) { void didUpdateWidget(covariant SliverGridComics oldWidget) {
if (oldWidget.comics != widget.comics) { if (!oldWidget.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) {
comics.add(comic); comics.add(comic);
} }
} }
generateHeroID();
} }
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
} }
@@ -617,9 +789,17 @@ class _SliverGridComicsState extends State<SliverGridComics> {
comics.add(comic); comics.add(comic);
} }
} }
generateHeroID();
HistoryManager().addListener(update);
super.initState(); super.initState();
} }
@override
void dispose() {
HistoryManager().removeListener(update);
super.dispose();
}
void update() { void update() {
setState(() { setState(() {
comics.clear(); comics.clear();
@@ -635,10 +815,13 @@ class _SliverGridComicsState extends State<SliverGridComics> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _SliverGridComics( return _SliverGridComics(
comics: comics, comics: comics,
heroIDs: heroIDs,
selection: widget.selections,
onLastItemBuild: widget.onLastItemBuild, onLastItemBuild: widget.onLastItemBuild,
badgeBuilder: widget.badgeBuilder, badgeBuilder: widget.badgeBuilder,
menuBuilder: widget.menuBuilder, menuBuilder: widget.menuBuilder,
onTap: widget.onTap, onTap: widget.onTap,
onLongPressed: widget.onLongPressed,
); );
} }
} }
@@ -646,14 +829,21 @@ class _SliverGridComicsState extends State<SliverGridComics> {
class _SliverGridComics extends StatelessWidget { class _SliverGridComics extends StatelessWidget {
const _SliverGridComics({ const _SliverGridComics({
required this.comics, required this.comics,
required this.heroIDs,
this.onLastItemBuild, this.onLastItemBuild,
this.badgeBuilder, this.badgeBuilder,
this.menuBuilder, this.menuBuilder,
this.onTap, this.onTap,
this.onLongPressed,
this.selection,
}); });
final List<Comic> comics; final List<Comic> comics;
final List<int> heroIDs;
final Map<Comic, bool>? selection;
final void Function()? onLastItemBuild; final void Function()? onLastItemBuild;
final String? Function(Comic)? badgeBuilder; final String? Function(Comic)? badgeBuilder;
@@ -662,6 +852,8 @@ class _SliverGridComics extends StatelessWidget {
final void Function(Comic)? onTap; final void Function(Comic)? onTap;
final void Function(Comic)? onLongPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverGrid( return SliverGrid(
@@ -671,11 +863,34 @@ class _SliverGridComics extends StatelessWidget {
onLastItemBuild?.call(); onLastItemBuild?.call();
} }
var badge = badgeBuilder?.call(comics[index]); var badge = badgeBuilder?.call(comics[index]);
return ComicTile( var isSelected =
selection == null ? false : selection![comics[index]] ?? false;
var comic = ComicTile(
comic: comics[index], comic: comics[index],
badge: badge, badge: badge,
menuOptions: menuBuilder?.call(comics[index]), menuOptions: menuBuilder?.call(comics[index]),
onTap: onTap != null ? () => onTap!(comics[index]) : null, onTap: onTap != null ? () => onTap!(comics[index]) : null,
onLongPressed: onLongPressed != null
? () => onLongPressed!(comics[index])
: null,
heroID: heroIDs[index],
);
if (selection == null) {
return comic;
}
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context)
.colorScheme
.secondaryContainer
.toOpacity(0.72)
: null,
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(4),
child: comic,
); );
}, },
childCount: comics.length, childCount: comics.length,
@@ -721,6 +936,9 @@ class ComicList extends StatefulWidget {
this.trailingSliver, this.trailingSliver,
this.errorLeading, this.errorLeading,
this.menuBuilder, this.menuBuilder,
this.controller,
this.refreshHandlerCallback,
this.enablePageStorage = false,
}); });
final Future<Res<List<Comic>>> Function(int page)? loadPage; final Future<Res<List<Comic>>> Function(int page)? loadPage;
@@ -735,6 +953,12 @@ class ComicList extends StatefulWidget {
final List<MenuEntry> Function(Comic)? menuBuilder; final List<MenuEntry> Function(Comic)? menuBuilder;
final ScrollController? controller;
final void Function(VoidCallback c)? refreshHandlerCallback;
final bool enablePageStorage;
@override @override
State<ComicList> createState() => ComicListState(); State<ComicList> createState() => ComicListState();
} }
@@ -752,6 +976,55 @@ class ComicListState extends State<ComicList> {
String? _nextUrl; String? _nextUrl;
late bool enablePageStorage = widget.enablePageStorage;
Map<String, dynamic> get state => {
'maxPage': _maxPage,
'data': _data,
'page': _page,
'error': _error,
'loading': _loading,
'nextUrl': _nextUrl,
};
void restoreState(Map<String, dynamic>? state) {
if (state == null || !enablePageStorage) {
return;
}
_maxPage = state['maxPage'];
_data.clear();
_data.addAll(state['data']);
_page = state['page'];
_error = state['error'];
_loading.clear();
_loading.addAll(state['loading']);
_nextUrl = state['nextUrl'];
}
void storeState() {
if (enablePageStorage) {
PageStorage.of(context).writeState(context, state);
}
}
void refresh() {
_data.clear();
_page = 1;
_maxPage = null;
_error = null;
_nextUrl = null;
_loading.clear();
storeState();
setState(() {});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
restoreState(PageStorage.of(context).readState(context));
widget.refreshHandlerCallback?.call(refresh);
}
void remove(Comic c) { void remove(Comic c) {
if (_data[_page] == null || !_data[_page]!.remove(c)) { if (_data[_page] == null || !_data[_page]!.remove(c)) {
for (var page in _data.values) { for (var page in _data.values) {
@@ -874,7 +1147,7 @@ class ComicListState extends State<ComicList> {
try { try {
if (widget.loadPage != null) { if (widget.loadPage != null) {
var res = await widget.loadPage!(page); var res = await widget.loadPage!(page);
if(!mounted) return; if (!mounted) return;
if (res.success) { if (res.success) {
if (res.data.isEmpty) { if (res.data.isEmpty) {
_data[page] = const []; _data[page] = const [];
@@ -899,15 +1172,20 @@ class ComicListState extends State<ComicList> {
while (_data[page] == null) { while (_data[page] == null) {
await _fetchNext(); await _fetchNext();
} }
setState(() {}); if (mounted) {
setState(() {});
}
} catch (e) { } catch (e) {
setState(() { if (mounted) {
_error = e.toString(); setState(() {
}); _error = e.toString();
});
}
} }
} }
} finally { } finally {
_loading[page] = false; _loading[page] = false;
storeState();
} }
} }
@@ -956,6 +1234,8 @@ class ComicListState extends State<ComicList> {
); );
} }
return SmoothCustomScrollView( return SmoothCustomScrollView(
key: enablePageStorage ? PageStorageKey('scroll$_page') : null,
controller: widget.controller,
slivers: [ slivers: [
if (widget.leadingSliver != null) widget.leadingSliver!, if (widget.leadingSliver != null) widget.leadingSliver!,
if (_maxPage != 1) _buildSliverPageSelector(), if (_maxPage != 1) _buildSliverPageSelector(),
@@ -1189,7 +1469,7 @@ class _RatingWidgetState extends State<RatingWidget> {
} }
if (full < widget.count) { if (full < widget.count) {
children.add(ClipRect( children.add(ClipRect(
clipper: SMClipper(rating: star() * widget.size), clipper: _SMClipper(rating: star() * widget.size),
child: Icon( child: Icon(
Icons.star, Icons.star,
size: widget.size, size: widget.size,
@@ -1238,10 +1518,10 @@ class _RatingWidgetState extends State<RatingWidget> {
} }
} }
class SMClipper extends CustomClipper<Rect> { class _SMClipper extends CustomClipper<Rect> {
final double rating; final double rating;
SMClipper({required this.rating}); _SMClipper({required this.rating});
@override @override
Rect getClip(Size size) { Rect getClip(Size size) {
@@ -1249,7 +1529,53 @@ class SMClipper extends CustomClipper<Rect> {
} }
@override @override
bool shouldReclip(SMClipper oldClipper) { bool shouldReclip(_SMClipper oldClipper) {
return rating != oldClipper.rating; return rating != oldClipper.rating;
} }
} }
class SimpleComicTile extends StatelessWidget {
const SimpleComicTile({super.key, required this.comic, this.onTap});
final Comic comic;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
var image = _findImageProvider(comic);
var child = image == null
? const SizedBox()
: AnimatedImage(
image: image,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
);
return AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ??
() {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
),
);
}
}

View File

@@ -1,5 +1,3 @@
library components;
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:math' as math; import 'dart:math' as math;
@@ -10,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:syntax_highlight/syntax_highlight.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/app_page_route.dart'; import 'package:venera/foundation/app_page_route.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
@@ -19,13 +18,14 @@ import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.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_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';
@@ -46,3 +46,4 @@ part 'side_bar.dart';
part 'comic.dart'; part 'comic.dart';
part 'effects.dart'; part 'effects.dart';
part 'gesture.dart'; part 'gesture.dart';
part 'code.dart';

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart';
/// patched slider.dart with RtL support
class _SliderDefaultsM3 extends SliderThemeData {
_SliderDefaultsM3(this.context)
: super(trackHeight: 4.0);
final BuildContext context;
late final ColorScheme _colors = Theme.of(context).colorScheme;
@override
Color? get activeTrackColor => _colors.primary;
@override
Color? get inactiveTrackColor => _colors.surfaceContainerHighest;
@override
Color? get secondaryActiveTrackColor => _colors.primary.toOpacity(0.54);
@override
Color? get disabledActiveTrackColor => _colors.onSurface.toOpacity(0.38);
@override
Color? get disabledInactiveTrackColor => _colors.onSurface.toOpacity(0.12);
@override
Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.toOpacity(0.12);
@override
Color? get activeTickMarkColor => _colors.onPrimary.toOpacity(0.38);
@override
Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.toOpacity(0.38);
@override
Color? get disabledActiveTickMarkColor => _colors.onSurface.toOpacity(0.38);
@override
Color? get disabledInactiveTickMarkColor => _colors.onSurface.toOpacity(0.38);
@override
Color? get thumbColor => _colors.primary;
@override
Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.toOpacity(0.38), _colors.surface);
@override
Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.dragged)) {
return _colors.primary.toOpacity(0.1);
}
if (states.contains(WidgetState.hovered)) {
return _colors.primary.toOpacity(0.08);
}
if (states.contains(WidgetState.focused)) {
return _colors.primary.toOpacity(0.1);
}
return Colors.transparent;
});
@override
TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith(
color: _colors.onPrimary,
);
@override
SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape();
}
class CustomSlider extends StatefulWidget {
const CustomSlider({required this.min, required this.max, required this.value, required this.divisions, required this.onChanged, required this.focusNode, this.reversed = false, super.key});
final double min;
final double max;
final double value;
final int divisions;
final void Function(double) onChanged;
final FocusNode? focusNode;
final bool reversed;
@override
State<CustomSlider> createState() => _CustomSliderState();
}
class _CustomSliderState extends State<CustomSlider> {
late double value;
@override
void initState() {
super.initState();
value = widget.value;
}
@override
void didUpdateWidget(CustomSlider oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
setState(() {
value = widget.value;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final theme = _SliderDefaultsM3(context);
return Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
child: widget.max - widget.min > 0 ? LayoutBuilder(
builder: (context, constraints) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (details){
var dx = details.localPosition.dx;
if(widget.reversed){
dx = constraints.maxWidth - dx;
}
var gap = constraints.maxWidth / widget.divisions;
var gapValue = (widget.max - widget.min) / widget.divisions;
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
},
onVerticalDragUpdate: (details){
var dx = details.localPosition.dx;
if(dx > constraints.maxWidth || dx < 0) return;
if(widget.reversed){
dx = constraints.maxWidth - dx;
}
var gap = constraints.maxWidth / widget.divisions;
var gapValue = (widget.max - widget.min) / widget.divisions;
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
},
child: SizedBox(
height: 24,
child: Center(
child: SizedBox(
height: 24,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Center(
child: Container(
width: double.infinity,
height: 6,
decoration: BoxDecoration(
color: theme.inactiveTrackColor,
borderRadius: const BorderRadius.all(Radius.circular(10))
),
),
),
),
if(constraints.maxWidth / widget.divisions > 10)
Positioned.fill(
child: Row(
children: (){
var res = <Widget>[];
for(int i = 0; i<widget.divisions-1; i++){
res.add(const Spacer());
res.add(Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: colorScheme.surface.withRed(10),
shape: BoxShape.circle,
),
));
}
res.add(const Spacer());
return res;
}.call(),
),
),
Positioned(
top: 0,
bottom: 0,
left: widget.reversed ? null : 0,
right: widget.reversed ? 0 : null,
child: Center(
child: Container(
width: constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min)),
height: 8,
decoration: BoxDecoration(
color: theme.activeTrackColor,
borderRadius: const BorderRadius.all(Radius.circular(10))
),
),
)
),
Positioned(
top: 0,
bottom: 0,
left: widget.reversed ? null : constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
right: !widget.reversed ? null : constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
child: Center(
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: theme.activeTrackColor,
shape: BoxShape.circle,
),
),
),
)
],
),
),
),
),
),
),
) : null,
);
}
}

View File

@@ -51,6 +51,10 @@ class Flyout extends StatefulWidget {
@override @override
State<Flyout> createState() => FlyoutState(); State<Flyout> createState() => FlyoutState();
static FlyoutState of(BuildContext context) {
return context.findAncestorStateOfType<FlyoutState>()!;
}
} }
class FlyoutState extends State<Flyout> { class FlyoutState extends State<Flyout> {
@@ -137,7 +141,7 @@ class FlyoutState extends State<Flyout> {
animation: animation, animation: animation,
builder: (context, builder) { builder: (context, builder) {
return ColoredBox( return ColoredBox(
color: Colors.black.withOpacity(0.3 * animation.value), color: Colors.black.toOpacity(0.3 * animation.value),
); );
}, },
), ),
@@ -181,12 +185,18 @@ class FlyoutContent extends StatelessWidget {
child: Material( child: Material(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
type: MaterialType.card, type: MaterialType.card,
color: context.colorScheme.surface.withOpacity(0.82), color: context.colorScheme.surface.toOpacity(0.82),
child: Container( child: Container(
constraints: const BoxConstraints( constraints: const BoxConstraints(
minWidth: minFlyoutWidth, minWidth: minFlyoutWidth,
), ),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: context.brightness == ui.Brightness.dark
? Border.all(color: context.colorScheme.outlineVariant)
: null,
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -211,108 +221,3 @@ class FlyoutContent extends StatelessWidget {
); );
} }
} }
class FlyoutTextButton extends StatefulWidget {
const FlyoutTextButton(
{super.key,
required this.child,
required this.flyoutBuilder,
this.navigator});
final Widget child;
final WidgetBuilder flyoutBuilder;
final NavigatorState? navigator;
@override
State<FlyoutTextButton> createState() => _FlyoutTextButtonState();
}
class _FlyoutTextButtonState extends State<FlyoutTextButton> {
final FlyoutController _controller = FlyoutController();
@override
Widget build(BuildContext context) {
return Flyout(
controller: _controller,
flyoutBuilder: widget.flyoutBuilder,
navigator: widget.navigator,
child: TextButton(
onPressed: () {
_controller.show();
},
child: widget.child,
));
}
}
class FlyoutIconButton extends StatefulWidget {
const FlyoutIconButton(
{super.key,
required this.icon,
required this.flyoutBuilder,
this.navigator});
final Widget icon;
final WidgetBuilder flyoutBuilder;
final NavigatorState? navigator;
@override
State<FlyoutIconButton> createState() => _FlyoutIconButtonState();
}
class _FlyoutIconButtonState extends State<FlyoutIconButton> {
final FlyoutController _controller = FlyoutController();
@override
Widget build(BuildContext context) {
return Flyout(
controller: _controller,
flyoutBuilder: widget.flyoutBuilder,
navigator: widget.navigator,
child: IconButton(
onPressed: () {
_controller.show();
},
icon: widget.icon,
));
}
}
class FlyoutFilledButton extends StatefulWidget {
const FlyoutFilledButton(
{super.key,
required this.child,
required this.flyoutBuilder,
this.navigator});
final Widget child;
final WidgetBuilder flyoutBuilder;
final NavigatorState? navigator;
@override
State<FlyoutFilledButton> createState() => _FlyoutFilledButtonState();
}
class _FlyoutFilledButtonState extends State<FlyoutFilledButton> {
final FlyoutController _controller = FlyoutController();
@override
Widget build(BuildContext context) {
return Flyout(
controller: _controller,
flyoutBuilder: widget.flyoutBuilder,
navigator: widget.navigator,
child: ElevatedButton(
onPressed: () {
_controller.show();
},
child: widget.child,
));
}
}

View File

@@ -1,7 +1,8 @@
part of 'components.dart'; part of 'components.dart';
class MouseBackDetector extends StatelessWidget { class MouseBackDetector extends StatelessWidget {
const MouseBackDetector({super.key, required this.onTapDown, required this.child}); const MouseBackDetector(
{super.key, required this.onTapDown, required this.child});
final Widget child; final Widget child;
@@ -20,3 +21,52 @@ class MouseBackDetector extends StatelessWidget {
); );
} }
} }
class AnimatedTapRegion extends StatefulWidget {
const AnimatedTapRegion({
super.key,
required this.child,
required this.onTap,
this.borderRadius = 0,
});
final Widget child;
final void Function() onTap;
final double borderRadius;
@override
State<AnimatedTapRegion> createState() => _AnimatedTapRegionState();
}
class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
bool isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) {
setState(() {
isHovered = true;
});
},
onExit: (_) {
setState(() {
isHovered = false;
});
},
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedPhysicalModel(
duration: _fastAnimationDuration,
elevation: isHovered ? 3 : 1,
color: context.colorScheme.surface,
shadowColor: context.colorScheme.shadow,
borderRadius: BorderRadius.circular(widget.borderRadius),
child: widget.child,
),
),
);
}
}

View File

@@ -22,6 +22,7 @@ class AnimatedImage extends StatefulWidget {
this.filterQuality = FilterQuality.medium, this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false, this.isAntiAlias = false,
this.part, this.part,
this.onError,
Map<String, String>? headers, Map<String, String>? headers,
int? cacheWidth, int? cacheWidth,
int? cacheHeight, int? cacheHeight,
@@ -63,6 +64,8 @@ class AnimatedImage extends StatefulWidget {
final ImagePart? part; final ImagePart? part;
final Function? onError;
static void clear() => _AnimatedImageState.clear(); static void clear() => _AnimatedImageState.clear();
@override @override
@@ -169,6 +172,8 @@ class _AnimatedImageState extends State<AnimatedImage>
_handleImageFrame, _handleImageFrame,
onChunk: _handleImageChunk, onChunk: _handleImageChunk,
onError: (Object error, StackTrace? stackTrace) { onError: (Object error, StackTrace? stackTrace) {
// 图片加错错误回调
widget.onError?.call(error, stackTrace);
setState(() { setState(() {
_lastException = error; _lastException = error;
}); });
@@ -271,36 +276,39 @@ class _AnimatedImageState extends State<AnimatedImage>
Widget result; Widget result;
if (_imageInfo != null) { if (_imageInfo != null) {
if(widget.part != null) { if (widget.part != null) {
return CustomPaint( result = CustomPaint(
isComplex: true,
painter: ImagePainter( painter: ImagePainter(
image: _imageInfo!.image, image: _imageInfo!.image,
part: widget.part!, part: widget.part!,
fit: widget.fit ?? BoxFit.cover,
), ),
child: SizedBox( child: SizedBox(
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
), ),
); );
} else {
result = RawImage(
image: _imageInfo?.image,
width: widget.width,
height: widget.height,
debugImageLabel: _imageInfo?.debugLabel,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: BoxFit.cover,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
} }
result = RawImage(
image: _imageInfo?.image,
width: widget.width,
height: widget.height,
debugImageLabel: _imageInfo?.debugLabel,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: BoxFit.cover,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
} else if (_lastException != null) { } else if (_lastException != null) {
result = const Center( result = const Center(
child: Icon(Icons.error), child: Icon(Icons.error),
@@ -357,10 +365,13 @@ class ImagePainter extends CustomPainter {
final ImagePart part; final ImagePart part;
final BoxFit fit;
/// Render a part of the image. /// Render a part of the image.
const ImagePainter({ const ImagePainter({
required this.image, required this.image,
this.part = const ImagePart(), this.part = const ImagePart(),
this.fit = BoxFit.cover,
}); });
@override @override
@@ -372,7 +383,8 @@ class ImagePainter extends CustomPainter {
part.y2 ?? image.height.toDouble(), part.y2 ?? image.height.toDouble(),
), ),
); );
final Rect dst = Offset.zero & size; var fitted = applyBoxFit(fit, Size(src.width, src.height), size).destination;
var dst = Alignment.center.inscribe(fitted, Offset.zero & size);
canvas.drawImageRect(image, src, dst, Paint()); canvas.drawImageRect(image, src, dst, Paint());
} }

242
lib/components/js_ui.dart Normal file
View File

@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/js_engine.dart';
import 'components.dart';
mixin class JsUiApi {
final Map<int, LoadingDialogController> _loadingDialogControllers = {};
dynamic handleUIMessage(Map<String, dynamic> message) {
switch (message['function']) {
case 'showMessage':
var m = message['message'];
if (m.toString().isNotEmpty) {
App.rootContext.showMessage(message: m.toString());
}
case 'showDialog':
return _showDialog(message);
case 'launchUrl':
var url = message['url'];
if (url.toString().isNotEmpty) {
launchUrlString(url.toString());
}
case 'showLoading':
var onCancel = message['onCancel'];
if (onCancel != null && onCancel is! JSInvokable) {
return;
}
return _showLoading(onCancel);
case 'cancelLoading':
var id = message['id'];
if (id is int) {
_cancelLoading(id);
}
case 'showInputDialog':
var title = message['title'];
var validator = message['validator'];
if (title is! String) return;
if (validator != null && validator is! JSInvokable) return;
return _showInputDialog(title, validator);
case 'showSelectDialog':
var title = message['title'];
var options = message['options'];
var initialIndex = message['initialIndex'];
if (title is! String) return;
if (options is! List) return;
if (initialIndex != null && initialIndex is! int) return;
return _showSelectDialog(
title,
options.whereType<String>().toList(),
initialIndex,
);
}
}
Future<void> _showDialog(Map<String, dynamic> message) {
BuildContext? dialogContext;
var title = message['title'];
var content = message['content'];
var actions = <Widget>[];
for (var action in message['actions']) {
if (action['callback'] is! JSInvokable) {
continue;
}
var callback = action['callback'] as JSInvokable;
var text = action['text'].toString();
var style = (action['style'] ?? 'text').toString();
actions.add(_JSCallbackButton(
text: text,
callback: JSAutoFreeFunction(callback),
style: style,
onCallbackFinished: () {
dialogContext?.pop();
},
));
}
if (actions.isEmpty) {
actions.add(TextButton(
onPressed: () {
dialogContext?.pop();
},
child: Text('OK'),
));
}
return showDialog(
context: App.rootContext,
builder: (context) {
dialogContext = context;
return ContentDialog(
title: title,
content: Text(content).paddingHorizontal(16),
actions: actions,
);
},
).then((value) {
dialogContext = null;
});
}
int _showLoading(JSInvokable? onCancel) {
var func = onCancel == null ? null : JSAutoFreeFunction(onCancel);
var controller = showLoadingDialog(
App.rootContext,
barrierDismissible: onCancel != null,
allowCancel: onCancel != null,
onCancel: onCancel == null
? null
: () {
func?.call([]);
},
);
var i = 0;
while (_loadingDialogControllers.containsKey(i)) {
i++;
}
_loadingDialogControllers[i] = controller;
return i;
}
void _cancelLoading(int id) {
var controller = _loadingDialogControllers.remove(id);
controller?.close();
}
Future<String?> _showInputDialog(String title, JSInvokable? validator) async {
String? result;
var func = validator == null ? null : JSAutoFreeFunction(validator);
await showInputDialog(
context: App.rootContext,
title: title,
onConfirm: (v) {
if (func != null) {
var res = func.call([v]);
if (res != null) {
return res.toString();
} else {
result = v;
}
} else {
result = v;
}
return null;
},
);
return result;
}
Future<int?> _showSelectDialog(
String title,
List<String> options,
int? initialIndex,
) {
if (options.isEmpty) {
return Future.value(null);
}
if (initialIndex != null &&
(initialIndex >= options.length || initialIndex < 0)) {
initialIndex = null;
}
return showSelectDialog(
title: title,
options: options,
initialIndex: initialIndex,
);
}
}
class _JSCallbackButton extends StatefulWidget {
const _JSCallbackButton({
required this.text,
required this.callback,
required this.style,
this.onCallbackFinished,
});
final JSAutoFreeFunction callback;
final String text;
final String style;
final void Function()? onCallbackFinished;
@override
State<_JSCallbackButton> createState() => _JSCallbackButtonState();
}
class _JSCallbackButtonState extends State<_JSCallbackButton> {
bool isLoading = false;
void onClick() async {
if (isLoading) {
return;
}
var res = widget.callback.call([]);
if (res is Future) {
setState(() {
isLoading = true;
});
await res;
setState(() {
isLoading = false;
});
}
widget.onCallbackFinished?.call();
}
@override
Widget build(BuildContext context) {
return switch (widget.style) {
"filled" => FilledButton(
onPressed: onClick,
child: isLoading
? CircularProgressIndicator(strokeWidth: 1.4)
.fixWidth(18)
.fixHeight(18)
: Text(widget.text),
),
"danger" => FilledButton(
onPressed: onClick,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(context.colorScheme.error),
),
child: isLoading
? CircularProgressIndicator(strokeWidth: 1.4)
.fixWidth(18)
.fixHeight(18)
: Text(widget.text),
),
_ => TextButton(
onPressed: onClick,
child: isLoading
? CircularProgressIndicator(strokeWidth: 1.4)
.fixWidth(18)
.fixHeight(18)
: Text(widget.text),
),
};
}
}

View File

@@ -74,23 +74,23 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
} }
class SliverGridDelegateWithComics extends SliverGridDelegate { class SliverGridDelegateWithComics extends SliverGridDelegate {
SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]); SliverGridDelegateWithComics();
final bool useBriefMode; final bool useBriefMode = appdata.settings['comicDisplayMode'] == 'brief';
final double? scale; final double scale = (appdata.settings['comicTileScale'] as num).toDouble();
@override @override
SliverGridLayout getLayout(SliverConstraints constraints) { SliverGridLayout getLayout(SliverConstraints constraints) {
if (appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode) { if (useBriefMode) {
return getBriefModeLayout( return getBriefModeLayout(
constraints, constraints,
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(), scale,
); );
} else { } else {
return getDetailedModeLayout( return getDetailedModeLayout(
constraints, constraints,
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(), scale,
); );
} }
} }
@@ -114,7 +114,7 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
SliverGridLayout getBriefModeLayout( SliverGridLayout getBriefModeLayout(
SliverConstraints constraints, double scale) { SliverConstraints constraints, double scale) {
final maxCrossAxisExtent = 192.0 * scale; final maxCrossAxisExtent = 192.0 * scale;
const childAspectRatio = 0.72; const childAspectRatio = 0.64;
const crossAxisSpacing = 0.0; const crossAxisSpacing = 0.0;
int crossAxisCount = int crossAxisCount =
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)) (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
@@ -140,6 +140,26 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
@override @override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) { bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
return true; if (oldDelegate is! SliverGridDelegateWithComics) return true;
if (oldDelegate.scale != scale ||
oldDelegate.useBriefMode != useBriefMode) {
return true;
}
return false;
}
}
class SliverLazyToBoxAdapter extends StatelessWidget {
/// Creates a sliver that contains a single box widget which can be lazy loaded.
const SliverLazyToBoxAdapter({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SliverList.list(children: [
SizedBox(),
child,
]);
} }
} }

View File

@@ -6,6 +6,7 @@ class NetworkError extends StatelessWidget {
required this.message, required this.message,
this.retry, this.retry,
this.withAppbar = true, this.withAppbar = true,
this.buttonText,
}); });
final String message; final String message;
@@ -14,6 +15,8 @@ class NetworkError extends StatelessWidget {
final bool withAppbar; final bool withAppbar;
final String? buttonText;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var cfe = CloudflareException.fromString(message); var cfe = CloudflareException.fromString(message);
@@ -54,13 +57,15 @@ class NetworkError extends StatelessWidget {
if (cfe != null) if (cfe != null)
FilledButton( FilledButton(
onPressed: () => passCloudflare( onPressed: () => passCloudflare(
CloudflareException.fromString(message)!, retry!), CloudflareException.fromString(message)!,
retry!,
),
child: Text('Verify'.tl), child: Text('Verify'.tl),
) )
else else
FilledButton( FilledButton(
onPressed: retry, onPressed: retry,
child: Text('Retry'.tl), child: Text(buttonText ?? 'Retry'.tl),
), ),
], ],
), ),
@@ -96,6 +101,20 @@ class ListLoadingIndicator extends StatelessWidget {
} }
} }
class SliverListLoadingIndicator extends StatelessWidget {
const SliverListLoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
// SliverToBoxAdapter can not been lazy loaded.
// Use SliverList to make sure the animation can be lazy loaded.
return SliverList.list(children: const [
SizedBox(),
ListLoadingIndicator(),
]);
}
}
abstract class LoadingState<T extends StatefulWidget, S extends Object> abstract class LoadingState<T extends StatefulWidget, S extends Object>
extends State<T> { extends State<T> {
bool isLoading = false; bool isLoading = false;
@@ -113,7 +132,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
if (res.success) { if (res.success) {
return res; return res;
} else { } else {
if(!mounted) return res; if (!mounted) return res;
if (retry >= 3) { if (retry >= 3) {
return res; return res;
} }
@@ -171,7 +190,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
isLoading = true; isLoading = true;
Future.microtask(() { Future.microtask(() {
loadDataWithRetry().then((value) async { loadDataWithRetry().then((value) async {
if(!mounted) return; if (!mounted) return;
if (value.success) { if (value.success) {
data = value.data; data = value.data;
await onDataLoaded(); await onDataLoaded();
@@ -299,28 +318,16 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
Widget buildLoading(BuildContext context) { Widget buildLoading(BuildContext context) {
return Center( return Center(
child: const CircularProgressIndicator( child: const CircularProgressIndicator().fixWidth(32).fixHeight(32),
strokeWidth: 2,
).fixWidth(32).fixHeight(32),
); );
} }
Widget buildError(BuildContext context, String error) { Widget buildError(BuildContext context, String error) {
return Center( return NetworkError(
child: Column( withAppbar: false,
mainAxisSize: MainAxisSize.min, message: error,
children: [ retry: reset,
Text(error, maxLines: 3), );
const SizedBox(height: 12),
Button.outlined(
onPressed: () {
reset();
},
child: const Text("Retry"),
)
],
),
).paddingHorizontal(16);
} }
@override @override

View File

@@ -20,17 +20,22 @@ class _MenuRoute<T> extends PopupRoute<T> {
@override @override
String? get barrierLabel => "menu"; String? get barrierLabel => "menu";
double get entryHeight => App.isMobile ? 42 : 36;
@override @override
Widget buildPage(BuildContext context, Animation<double> animation, Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) { Animation<double> secondaryAnimation) {
var width = entries.first.icon == null ? 216.0 : 242.0; var width = entries.first.icon == null ? 216.0 : 242.0;
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
var left = location.dx; var left = location.dx;
if (left < 10) {
left = 10;
}
if (left + width > size.width - 10) { if (left + width > size.width - 10) {
left = size.width - width - 10; left = size.width - width - 10;
} }
var top = location.dy; var top = location.dy;
var height = 16 + 32 * entries.length; var height = 16 + entryHeight * entries.length;
if (top + height > size.height - 15) { if (top + height > size.height - 15) {
top = size.height - height - 15; top = size.height - height - 15;
} }
@@ -42,9 +47,12 @@ class _MenuRoute<T> extends PopupRoute<T> {
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: context.brightness == Brightness.dark
? Border.all(color: context.colorScheme.outlineVariant)
: null,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: context.colorScheme.shadow.withOpacity(0.2), color: context.colorScheme.shadow.toOpacity(0.2),
blurRadius: 8, blurRadius: 8,
blurStyle: BlurStyle.outer, blurStyle: BlurStyle.outer,
), ),
@@ -53,9 +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.brightness == Brightness.light color: context.colorScheme.surface.toOpacity(0.78),
? const Color(0xFFFAFAFA).withOpacity(0.82)
: const Color(0xFF090909).withOpacity(0.82),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: Container( child: Container(
width: width, width: width,
@@ -83,7 +89,7 @@ class _MenuRoute<T> extends PopupRoute<T> {
entry.onClick(); entry.onClick();
}, },
child: SizedBox( child: SizedBox(
height: App.isMobile ? 42 : 36, height: entryHeight,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
@@ -92,9 +98,13 @@ class _MenuRoute<T> extends PopupRoute<T> {
Icon( Icon(
entry.icon, entry.icon,
size: 18, size: 18,
color: entry.color
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text(entry.text), Text(
entry.text,
style: TextStyle(color: entry.color)
),
], ],
), ),
), ),
@@ -119,7 +129,8 @@ class _MenuRoute<T> extends PopupRoute<T> {
class MenuEntry { class MenuEntry {
final String text; final String text;
final IconData? icon; final IconData? icon;
final Color? color;
final void Function() onClick; final void Function() onClick;
MenuEntry({required this.text, this.icon, required this.onClick}); MenuEntry({required this.text, this.icon, this.color, required this.onClick});
} }

View File

@@ -5,6 +5,7 @@ void showToast({
required BuildContext context, required BuildContext context,
Widget? icon, Widget? icon,
Widget? trailing, Widget? trailing,
int? seconds,
}) { }) {
var newEntry = OverlayEntry( var newEntry = OverlayEntry(
builder: (context) => _ToastOverlay( builder: (context) => _ToastOverlay(
@@ -17,7 +18,7 @@ void showToast({
state?.addOverlay(newEntry); state?.addOverlay(newEntry);
Timer(const Duration(seconds: 2), () => state?.remove(newEntry)); Timer(Duration(seconds: seconds ?? 2), () => state?.remove(newEntry));
} }
class _ToastOverlay extends StatelessWidget { class _ToastOverlay extends StatelessWidget {
@@ -46,21 +47,29 @@ class _ToastOverlay extends StatelessWidget {
child: IconTheme( child: IconTheme(
data: IconThemeData( data: IconThemeData(
color: Theme.of(context).colorScheme.onInverseSurface), color: Theme.of(context).colorScheme.onInverseSurface),
child: Container( child: IntrinsicWidth(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), child: Container(
child: Row( padding:
mainAxisSize: MainAxisSize.min, const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
children: [ constraints: BoxConstraints(
if (icon != null) icon!.paddingRight(8), maxWidth: context.width - 32,
Text( ),
message, child: Row(
style: const TextStyle( mainAxisSize: MainAxisSize.min,
fontSize: 16, fontWeight: FontWeight.w500), children: [
maxLines: 3, if (icon != null) icon!.paddingRight(8),
overflow: TextOverflow.ellipsis, Expanded(
), child: Text(
if (trailing != null) trailing!.paddingLeft(8) message,
], style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (trailing != null) trailing!.paddingLeft(8)
],
),
), ),
), ),
), ),
@@ -116,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),
) )
@@ -135,6 +144,7 @@ Future<void> showConfirmDialog({
required String content, required String content,
required void Function() onConfirm, required void Function() onConfirm,
String confirmText = "Confirm", String confirmText = "Confirm",
Color? btnColor,
}) { }) {
return showDialog( return showDialog(
context: context, context: context,
@@ -147,6 +157,9 @@ Future<void> showConfirmDialog({
context.pop(); context.pop();
onConfirm(); onConfirm();
}, },
style: FilledButton.styleFrom(
backgroundColor: btnColor,
),
child: Text(confirmText.tl), child: Text(confirmText.tl),
), ),
], ],
@@ -155,7 +168,15 @@ Future<void> showConfirmDialog({
} }
class LoadingDialogController { class LoadingDialogController {
void Function()? closeDialog; double? _progress;
String? _message;
void Function()? _closeDialog;
void Function(double? value)? _serProgress;
void Function(String message)? _setMessage;
bool closed = false; bool closed = false;
@@ -164,63 +185,86 @@ class LoadingDialogController {
return; return;
} }
closed = true; closed = true;
if (closeDialog == null) { if (_closeDialog == null) {
Future.microtask(closeDialog!); Future.microtask(_closeDialog!);
} else { } else {
closeDialog!(); _closeDialog!();
} }
} }
void setProgress(double? value) {
if (closed) {
return;
}
_serProgress?.call(value);
}
void setMessage(String message) {
if (closed) {
return;
}
_setMessage?.call(message);
}
} }
LoadingDialogController showLoadingDialog(BuildContext context, LoadingDialogController showLoadingDialog(
{void Function()? onCancel, BuildContext context, {
bool barrierDismissible = true, void Function()? onCancel,
bool allowCancel = true, bool barrierDismissible = true,
String? message, bool allowCancel = true,
String cancelButtonText = "Cancel"}) { String? message,
String cancelButtonText = "Cancel",
bool withProgress = false,
}) {
var controller = LoadingDialogController(); var controller = LoadingDialogController();
controller._message = message;
if (withProgress) {
controller._progress = 0;
}
var loadingDialogRoute = DialogRoute( var loadingDialogRoute = DialogRoute(
context: context, context: context,
barrierDismissible: barrierDismissible, barrierDismissible: barrierDismissible,
builder: (BuildContext context) { builder: (BuildContext context) {
return Dialog( return StatefulBuilder(builder: (context, setState) {
child: Container( controller._serProgress = (value) {
width: 100, setState(() {
padding: const EdgeInsets.all(16.0), controller._progress = value;
child: Row( });
children: [ };
const SizedBox( controller._setMessage = (message) {
width: 30, setState(() {
height: 30, controller._message = message;
child: CircularProgressIndicator(), });
), };
const SizedBox( return ContentDialog(
width: 16, title: controller._message ?? 'Loading',
), content: LinearProgressIndicator(
Text( value: controller._progress,
message ?? 'Loading', backgroundColor: context.colorScheme.surfaceContainer,
style: const TextStyle(fontSize: 16), ).paddingHorizontal(16).paddingVertical(16),
), actions: [
const Spacer(), FilledButton(
if (allowCancel) onPressed: allowCancel
TextButton( ? () {
onPressed: () { controller.close();
controller.close(); onCancel?.call();
onCancel?.call(); }
}, : null,
child: Text(cancelButtonText.tl)) child: Text(cancelButtonText.tl),
], )
), ],
),
); );
}); });
},
);
var navigator = Navigator.of(context); var navigator = Navigator.of(context, rootNavigator: true);
navigator.push(loadingDialogRoute).then((value) => controller.closed = true); navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
controller.closeDialog = () { controller._closeDialog = () {
navigator.removeRoute(loadingDialogRoute); navigator.removeRoute(loadingDialogRoute);
}; };
@@ -230,13 +274,13 @@ LoadingDialogController showLoadingDialog(BuildContext context,
class ContentDialog extends StatelessWidget { class ContentDialog extends StatelessWidget {
const ContentDialog({ const ContentDialog({
super.key, super.key,
required this.title, this.title, // 如果不传 title 将不会展示
required this.content, required this.content,
this.dismissible = true, this.dismissible = true,
this.actions = const [], this.actions = const [],
}); });
final String title; final String? title;
final Widget content; final Widget content;
@@ -250,14 +294,16 @@ class ContentDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Appbar( title != null
leading: IconButton( ? Appbar(
icon: const Icon(Icons.close), leading: IconButton(
onPressed: dismissible ? context.pop : null, icon: const Icon(Icons.close),
), onPressed: dismissible ? context.pop : null,
title: Text(title), ),
backgroundColor: Colors.transparent, title: Text(title!),
), backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content, this.content,
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -279,6 +325,7 @@ class ContentDialog extends StatelessWidget {
: const EdgeInsets.symmetric(horizontal: 16), : const EdgeInsets.symmetric(horizontal: 16),
elevation: 2, elevation: 2,
shadowColor: context.colorScheme.shadow, shadowColor: context.colorScheme.shadow,
backgroundColor: context.colorScheme.surface,
child: AnimatedSize( child: AnimatedSize(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
@@ -348,7 +395,7 @@ Future<void> showInputDialog({
} else { } else {
result = futureOr; result = futureOr;
} }
if(result == null) { if (result == null) {
context.pop(); context.pop();
} else { } else {
setState(() => error = result.toString()); setState(() => error = result.toString());
@@ -386,3 +433,57 @@ void showInfoDialog({
}, },
); );
} }
Future<int?> showSelectDialog({
required String title,
required List<String> options,
int? initialIndex,
}) async {
int? current = initialIndex;
await showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: title,
content: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Select(
current: current == null ? "" : options[current!],
values: options,
minWidth: 156,
onTap: (i) {
setState(() {
current = i;
});
},
)
],
),
),
actions: [
TextButton(
onPressed: () {
current = null;
context.pop();
},
child: Text('Cancel'.tl),
),
FilledButton(
onPressed: current == null ? null : context.pop,
child: Text('Confirm'.tl),
),
],
);
},
);
},
);
return current;
}

View File

@@ -23,14 +23,15 @@ class PaneActionEntry {
} }
class NaviPane extends StatefulWidget { class NaviPane extends StatefulWidget {
const NaviPane({required this.paneItems, const NaviPane(
required this.paneActions, {required this.paneItems,
required this.pageBuilder, required this.paneActions,
this.initialPage = 0, required this.pageBuilder,
this.onPageChange, this.initialPage = 0,
required this.observer, this.onPageChanged,
required this.navigatorKey, required this.observer,
super.key}); required this.navigatorKey,
super.key});
final List<PaneItemEntry> paneItems; final List<PaneItemEntry> paneItems;
@@ -38,7 +39,7 @@ class NaviPane extends StatefulWidget {
final Widget Function(int page) pageBuilder; final Widget Function(int page) pageBuilder;
final void Function(int index)? onPageChange; final void Function(int index)? onPageChanged;
final int initialPage; final int initialPage;
@@ -47,10 +48,16 @@ class NaviPane extends StatefulWidget {
final GlobalKey<NavigatorState> navigatorKey; final GlobalKey<NavigatorState> navigatorKey;
@override @override
State<NaviPane> createState() => _NaviPaneState(); State<NaviPane> createState() => NaviPaneState();
static NaviPaneState of(BuildContext context) {
return context.findAncestorStateOfType<NaviPaneState>()!;
}
} }
class _NaviPaneState extends State<NaviPane> typedef NaviItemTapListener = void Function(int);
class NaviPaneState extends State<NaviPane>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late int _currentPage = widget.initialPage; late int _currentPage = widget.initialPage;
@@ -59,35 +66,48 @@ class _NaviPaneState extends State<NaviPane>
set currentPage(int value) { set currentPage(int value) {
if (value == _currentPage) return; if (value == _currentPage) return;
_currentPage = value; _currentPage = value;
widget.onPageChange?.call(value); widget.onPageChanged?.call(value);
} }
void Function()? mainViewUpdateHandler; void Function()? mainViewUpdateHandler;
late AnimationController controller; late AnimationController controller;
final _naviItemTapListeners = <NaviItemTapListener>[];
void addNaviItemTapListener(NaviItemTapListener listener) {
_naviItemTapListeners.add(listener);
}
void removeNaviItemTapListener(NaviItemTapListener listener) {
_naviItemTapListeners.remove(listener);
}
static const _kBottomBarHeight = 58.0; static const _kBottomBarHeight = 58.0;
static const _kFoldedSideBarWidth = 80.0; static const _kFoldedSideBarWidth = 72.0;
static const _kSideBarWidth = 256.0; static const _kSideBarWidth = 224.0;
static const _kTopBarHeight = 48.0; static const _kTopBarHeight = 48.0;
double get bottomBarHeight => double get bottomBarHeight =>
_kBottomBarHeight + MediaQuery _kBottomBarHeight + MediaQuery.of(context).padding.bottom;
.of(context)
.padding
.bottom;
void onNavigatorStateChange() { void onNavigatorStateChange() {
onRebuild(context); onRebuild(context);
} }
void updatePage(int index) { void updatePage(int index) {
for (var listener in _naviItemTapListeners) {
listener(index);
}
if (widget.observer.routes.length > 1) { if (widget.observer.routes.length > 1) {
widget.navigatorKey.currentState!.popUntil((route) => route.isFirst); widget.navigatorKey.currentState!.popUntil((route) => route.isFirst);
} }
if (currentPage == index) {
return;
}
setState(() { setState(() {
currentPage = index; currentPage = index;
}); });
@@ -114,10 +134,7 @@ class _NaviPaneState extends State<NaviPane>
} }
double targetFormContext(BuildContext context) { double targetFormContext(BuildContext context) {
var width = MediaQuery var width = MediaQuery.of(context).size.width;
.of(context)
.size
.width;
double target = 0; double target = 0;
if (width > changePoint) { if (width > changePoint) {
target = 2; target = 2;
@@ -183,17 +200,18 @@ class _NaviPaneState extends State<NaviPane>
} }
Widget buildMainView() { Widget buildMainView() {
return Navigator( return HeroControllerScope(
observers: [widget.observer], controller: MaterialApp.createMaterialHeroController(),
key: widget.navigatorKey, child: Navigator(
onGenerateRoute: (settings) => observers: [widget.observer],
AppPageRoute( key: widget.navigatorKey,
preventRebuild: false, onGenerateRoute: (settings) => AppPageRoute(
isRootRoute: true, preventRebuild: false,
builder: (context) { builder: (context) {
return _NaviMainView(state: this); return _NaviMainView(state: this);
}, },
), ),
),
); );
} }
@@ -230,20 +248,14 @@ class _NaviPaneState extends State<NaviPane>
Widget buildBottom() { Widget buildBottom() {
return Material( return Material(
textStyle: Theme textStyle: Theme.of(context).textTheme.labelSmall,
.of(context)
.textTheme
.labelSmall,
elevation: 0, elevation: 0,
child: Container( child: Container(
height: _kBottomBarHeight, height: _kBottomBarHeight,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: BorderSide( top: BorderSide(
color: Theme color: Theme.of(context).colorScheme.outlineVariant,
.of(context)
.colorScheme
.outlineVariant,
width: 1, width: 1,
), ),
), ),
@@ -251,7 +263,7 @@ class _NaviPaneState extends State<NaviPane>
child: Row( child: Row(
children: List<Widget>.generate( children: List<Widget>.generate(
widget.paneItems.length, widget.paneItems.length,
(index) { (index) {
return Expanded( return Expanded(
child: _SingleBottomNaviWidget( child: _SingleBottomNaviWidget(
enabled: currentPage == index, enabled: currentPage == index,
@@ -271,7 +283,7 @@ class _NaviPaneState extends State<NaviPane>
Widget buildLeft() { Widget buildLeft() {
final value = controller.value; final value = controller.value;
const paddingHorizontal = 16.0; const paddingHorizontal = 12.0;
return Material( return Material(
child: Container( child: Container(
width: _kFoldedSideBarWidth + width: _kFoldedSideBarWidth +
@@ -281,57 +293,39 @@ class _NaviPaneState extends State<NaviPane>
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
right: BorderSide( right: BorderSide(
color: Theme color: Theme.of(context).colorScheme.outlineVariant,
.of(context) width: 1.0,
.colorScheme
.outlineVariant,
width: 1,
), ),
), ),
), ),
child: Row( child: Column(
children: [ children: [
SizedBox( const SizedBox(height: 16),
width: value == 3 SizedBox(height: MediaQuery.of(context).padding.top),
? (_kSideBarWidth - paddingHorizontal * 2 - 1) ...List<Widget>.generate(
: (_kFoldedSideBarWidth - paddingHorizontal * 2 - 1), widget.paneItems.length,
child: Column( (index) => _SideNaviWidget(
children: [ enabled: currentPage == index,
const SizedBox(height: 16), entry: widget.paneItems[index],
SizedBox(height: MediaQuery showTitle: value == 3,
.of(context) onTap: () {
.padding updatePage(index);
.top), },
...List<Widget>.generate( key: ValueKey(index),
widget.paneItems.length,
(index) =>
_SideNaviWidget(
enabled: currentPage == index,
entry: widget.paneItems[index],
showTitle: value == 3,
onTap: () {
updatePage(index);
},
key: ValueKey(index),
),
),
const Spacer(),
...List<Widget>.generate(
widget.paneActions.length,
(index) =>
_PaneActionWidget(
entry: widget.paneActions[index],
showTitle: value == 3,
key: ValueKey(index + widget.paneItems.length),
),
),
const SizedBox(
height: 16,
)
],
), ),
), ),
const Spacer(), const Spacer(),
...List<Widget>.generate(
widget.paneActions.length,
(index) => _PaneActionWidget(
entry: widget.paneActions[index],
showTitle: value == 3,
key: ValueKey(index + widget.paneItems.length),
),
),
const SizedBox(
height: 16,
)
], ],
), ),
), ),
@@ -339,12 +333,13 @@ class _NaviPaneState extends State<NaviPane>
} }
} }
class _SideNaviWidget extends StatefulWidget { class _SideNaviWidget extends StatelessWidget {
const _SideNaviWidget({required this.enabled, const _SideNaviWidget(
required this.entry, {required this.enabled,
required this.onTap, required this.entry,
required this.showTitle, required this.onTap,
super.key}); required this.showTitle,
super.key});
final bool enabled; final bool enabled;
@@ -354,60 +349,35 @@ class _SideNaviWidget extends StatefulWidget {
final bool showTitle; final bool showTitle;
@override
State<_SideNaviWidget> createState() => _SideNaviWidgetState();
}
class _SideNaviWidgetState extends State<_SideNaviWidget> {
bool isHovering = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme final colorScheme = Theme.of(context).colorScheme;
.of(context) final icon = Icon(enabled ? entry.activeIcon : entry.icon);
.colorScheme; return InkWell(
final icon = borderRadius: BorderRadius.circular(12),
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon); onTap: onTap,
return MouseRegion( child: AnimatedContainer(
cursor: SystemMouseCursors.click, duration: const Duration(milliseconds: 180),
onEnter: (details) => setState(() => isHovering = true), padding: const EdgeInsets.symmetric(horizontal: 12),
onExit: (details) => setState(() => isHovering = false), height: 38,
child: GestureDetector( decoration: BoxDecoration(
behavior: HitTestBehavior.translucent, color: enabled ? colorScheme.primaryContainer : null,
onTap: widget.onTap, borderRadius: BorderRadius.circular(12),
child: AnimatedContainer( ),
duration: const Duration(milliseconds: 180), child: showTitle
margin: const EdgeInsets.symmetric(vertical: 4), ? Row(
padding: const EdgeInsets.symmetric(horizontal: 12), children: [icon, const SizedBox(width: 12), Text(entry.label)],
width: double.infinity, )
height: 42, : Align(
decoration: BoxDecoration( alignment: Alignment.centerLeft,
color: widget.enabled child: icon,
? colorScheme.primaryContainer ),
: isHovering
? colorScheme.surfaceContainerHigh
: null,
borderRadius: BorderRadius.circular(8),
),
child: widget.showTitle
? Row(
children: [
icon,
const SizedBox(
width: 12,
),
Text(widget.entry.label)
],
)
: Center(
child: icon,
)),
), ),
); ).paddingVertical(4);
} }
} }
class _PaneActionWidget extends StatefulWidget { class _PaneActionWidget extends StatelessWidget {
const _PaneActionWidget( const _PaneActionWidget(
{required this.entry, required this.showTitle, super.key}); {required this.entry, required this.showTitle, super.key});
@@ -415,58 +385,35 @@ class _PaneActionWidget extends StatefulWidget {
final bool showTitle; final bool showTitle;
@override
State<_PaneActionWidget> createState() => _PaneActionWidgetState();
}
class _PaneActionWidgetState extends State<_PaneActionWidget> {
bool isHovering = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme final icon = Icon(entry.icon);
.of(context) return InkWell(
.colorScheme; onTap: entry.onTap,
final icon = Icon(widget.entry.icon); borderRadius: BorderRadius.circular(12),
return MouseRegion( child: AnimatedContainer(
cursor: SystemMouseCursors.click, duration: const Duration(milliseconds: 180),
onEnter: (details) => setState(() => isHovering = true), padding: const EdgeInsets.symmetric(horizontal: 12),
onExit: (details) => setState(() => isHovering = false), height: 38,
child: GestureDetector( child: showTitle
behavior: HitTestBehavior.translucent, ? Row(
onTap: widget.entry.onTap, children: [icon, const SizedBox(width: 12), Text(entry.label)],
child: AnimatedContainer( )
duration: const Duration(milliseconds: 180), : Align(
margin: const EdgeInsets.symmetric(vertical: 4), alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 12), child: icon,
width: double.infinity, ),
height: 42,
decoration: BoxDecoration(
color: isHovering ? colorScheme.surfaceContainerHigh : null,
borderRadius: BorderRadius.circular(8)),
child: widget.showTitle
? Row(
children: [
icon,
const SizedBox(
width: 12,
),
Text(widget.entry.label)
],
)
: Center(
child: icon,
)),
), ),
); ).paddingVertical(4);
} }
} }
class _SingleBottomNaviWidget extends StatefulWidget { class _SingleBottomNaviWidget extends StatefulWidget {
const _SingleBottomNaviWidget({required this.enabled, const _SingleBottomNaviWidget(
required this.entry, {required this.enabled,
required this.onTap, required this.entry,
super.key}); required this.onTap,
super.key});
final bool enabled; final bool enabled;
@@ -534,11 +481,9 @@ class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
Widget buildContent() { Widget buildContent() {
final value = controller.value; final value = controller.value;
final colorScheme = Theme final colorScheme = Theme.of(context).colorScheme;
.of(context)
.colorScheme;
final icon = final icon =
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon); Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
return Center( return Center(
child: Container( child: Container(
width: 64, width: 64,
@@ -639,12 +584,12 @@ class _NaviPopScope extends StatelessWidget {
Widget res = App.isIOS Widget res = App.isIOS
? child ? child
: PopScope( : PopScope(
canPop: App.isAndroid ? false : true, canPop: App.isAndroid ? false : true,
onPopInvokedWithResult: (value, result) { onPopInvokedWithResult: (value, result) {
action(); action();
}, },
child: child, child: child,
); );
if (popGesture) { if (popGesture) {
res = GestureDetector( res = GestureDetector(
onPanStart: (details) { onPanStart: (details) {
@@ -670,14 +615,14 @@ class _NaviPopScope extends StatelessWidget {
class _NaviMainView extends StatefulWidget { class _NaviMainView extends StatefulWidget {
const _NaviMainView({required this.state}); const _NaviMainView({required this.state});
final _NaviPaneState state; final NaviPaneState state;
@override @override
State<_NaviMainView> createState() => _NaviMainViewState(); State<_NaviMainView> createState() => _NaviMainViewState();
} }
class _NaviMainViewState extends State<_NaviMainView> { class _NaviMainViewState extends State<_NaviMainView> {
_NaviPaneState get state => widget.state; NaviPaneState get state => widget.state;
@override @override
void initState() { void initState() {
@@ -703,8 +648,8 @@ class _NaviMainViewState extends State<_NaviMainView> {
), ),
), ),
), ),
if (shouldShowAppBar) state.buildBottom().paddingBottom( if (shouldShowAppBar)
context.padding.bottom), state.buildBottom().paddingBottom(context.padding.bottom),
], ],
); );
} }

View File

@@ -22,8 +22,15 @@ class PopUpWidget<T> extends PopupRoute<T> {
Widget body = PopupIndicatorWidget( Widget body = PopupIndicatorWidget(
child: Container( child: Container(
decoration: showPopUp decoration: showPopUp
? const BoxDecoration( ? BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(12)), borderRadius: BorderRadius.all(Radius.circular(12)),
boxShadow: context.brightness == ui.Brightness.dark ? [
BoxShadow(
color: Colors.white.withAlpha(50),
blurRadius: 10,
offset: Offset(0, 2),
),
] : null,
) )
: null, : null,
clipBehavior: showPopUp ? Clip.antiAlias : Clip.none, clipBehavior: showPopUp ? Clip.antiAlias : Clip.none,
@@ -86,7 +93,8 @@ class PopupIndicatorWidget extends InheritedWidget {
} }
Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async { Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async {
return await Navigator.of(context, rootNavigator: true).push(PopUpWidget(widget)); return await Navigator.of(context, rootNavigator: true)
.push(PopUpWidget(widget));
} }
class PopUpWidgetScaffold extends StatefulWidget { class PopUpWidgetScaffold extends StatefulWidget {
@@ -127,9 +135,8 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
message: "Back".tl, message: "Back".tl,
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back_sharp), icon: const Icon(Icons.arrow_back_sharp),
onPressed: () => context.canPop() onPressed: () =>
? context.pop() context.canPop() ? context.pop() : App.pop(),
: App.pop(),
), ),
), ),
const SizedBox( const SizedBox(
@@ -148,6 +155,9 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
), ),
NotificationListener<ScrollNotification>( NotificationListener<ScrollNotification>(
onNotification: (notifications) { onNotification: (notifications) {
if (notifications.metrics.axisDirection != AxisDirection.down) {
return false;
}
if (notifications.metrics.pixels == if (notifications.metrics.pixels ==
notifications.metrics.minScrollExtent && notifications.metrics.minScrollExtent &&
!top) { !top) {

View File

@@ -78,6 +78,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
}, },
onPointerSignal: (pointerSignal) { onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) { if (pointerSignal is PointerScrollEvent) {
if (HardwareKeyboard.instance.isShiftPressed) {
return;
}
if (pointerSignal.kind == PointerDeviceKind.mouse && if (pointerSignal.kind == PointerDeviceKind.mouse &&
!_isMouseScroll) { !_isMouseScroll) {
setState(() { setState(() {
@@ -95,17 +98,49 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
_controller.position.maxScrollExtent, _controller.position.maxScrollExtent,
); );
if (_futurePosition == old) return; if (_futurePosition == old) return;
_controller.animateTo(_futurePosition!, var target = _futurePosition!;
duration: _fastAnimationDuration, curve: Curves.linear); _controller.animateTo(
_futurePosition!,
duration: _fastAnimationDuration,
curve: Curves.linear,
).then((_) {
var current = _controller.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
}
});
} }
}, },
child: widget.builder( child: ScrollControllerProvider._(
context, controller: _controller,
_controller, child: widget.builder(
_isMouseScroll context,
? const NeverScrollableScrollPhysics() _controller,
: const BouncingScrollPhysics(), _isMouseScroll
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
),
), ),
); );
} }
} }
class ScrollControllerProvider extends InheritedWidget {
const ScrollControllerProvider._({
required this.controller,
required super.child,
});
final ScrollController controller;
static ScrollController of(BuildContext context) {
final ScrollControllerProvider? provider =
context.dependOnInheritedWidgetOfExactType<ScrollControllerProvider>();
return provider!.controller;
}
@override
bool updateShouldNotify(ScrollControllerProvider oldWidget) {
return oldWidget.controller != controller;
}
}

View File

@@ -31,8 +31,9 @@ class Select extends StatelessWidget {
var size = renderBox.size; var size = renderBox.size;
showMenu( showMenu(
elevation: 3, elevation: 3,
color: context.colorScheme.surface, color: context.brightness == Brightness.light
surfaceTintColor: Colors.transparent, ? const Color(0xFFF6F6F6)
: const Color(0xFF1E1E1E),
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
constraints: BoxConstraints( constraints: BoxConstraints(
@@ -41,8 +42,8 @@ class Select extends StatelessWidget {
), ),
position: RelativeRect.fromLTRB( position: RelativeRect.fromLTRB(
offset.dx, offset.dx,
offset.dy + size.height, offset.dy + size.height + 2,
offset.dx + size.height, offset.dx + size.height + 2,
offset.dy, offset.dy,
), ),
items: values items: values
@@ -266,13 +267,14 @@ class OptionChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return AnimatedContainer(
duration: _fastAnimationDuration,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? context.colorScheme.primaryContainer ? context.colorScheme.secondaryContainer
: context.colorScheme.surface, : context.colorScheme.surface,
border: isSelected border: isSelected
? Border.all(color: context.colorScheme.primaryContainer) ? Border.all(color: context.colorScheme.secondaryContainer)
: Border.all(color: context.colorScheme.outline), : Border.all(color: context.colorScheme.outline),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),

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(
@@ -57,10 +51,18 @@ class SideBarRoute<T> extends PopupRoute<T> {
body = Container( body = Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: showSideBar borderRadius: showSideBar
? const BorderRadius.horizontal(left: Radius.circular(16)) ? const BorderRadius.horizontal(left: Radius.circular(16))
: null, : null,
color: Theme.of(context).colorScheme.surfaceTint), color: Theme.of(context).colorScheme.surfaceTint,
boxShadow: context.brightness == ui.Brightness.dark ? [
BoxShadow(
color: Colors.white.withAlpha(50),
blurRadius: 10,
offset: Offset(0, 2),
),
] : null,
),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
constraints: BoxConstraints(maxWidth: sideBarWidth), constraints: BoxConstraints(maxWidth: sideBarWidth),
height: MediaQuery.of(context).size.height, height: MediaQuery.of(context).size.height,
@@ -121,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

@@ -6,61 +6,102 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.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_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
const _kTitleBarHeight = 36.0; const _kTitleBarHeight = 36.0;
class WindowFrameController extends StateController { class WindowFrameController extends InheritedWidget {
bool useDarkTheme = false; /// Whether the window frame is hidden.
final bool isWindowFrameHidden;
bool isHideWindowFrame = false; /// Sets the visibility of the window frame.
final void Function(bool) setWindowFrame;
void setDarkTheme() { /// Adds a listener that will be called when close button is clicked.
useDarkTheme = true; /// The listener should return `true` to allow the window to be closed.
update(); final void Function(WindowCloseListener listener) addCloseListener;
}
void resetTheme() { /// Removes a close listener.
useDarkTheme = false; final void Function(WindowCloseListener listener) removeCloseListener;
update();
}
VoidCallback openSideBar = () {}; const WindowFrameController._create({
required this.isWindowFrameHidden,
required this.setWindowFrame,
required this.addCloseListener,
required this.removeCloseListener,
required super.child,
});
void hideWindowFrame() { @override
isHideWindowFrame = true; bool updateShouldNotify(covariant InheritedWidget oldWidget) {
update(); return false;
}
void showWindowFrame() {
isHideWindowFrame = false;
update();
} }
} }
class WindowFrame extends StatelessWidget { class WindowFrame extends StatefulWidget {
const WindowFrame(this.child, {super.key}); const WindowFrame(this.child, {super.key});
final Widget child; final Widget child;
@override @override
Widget build(BuildContext context) { State<WindowFrame> createState() => _WindowFrameState();
StateController.putIfNotExists<WindowFrameController>(
WindowFrameController());
if (App.isMobile) return child;
return StateBuilder<WindowFrameController>(builder: (controller) {
if (controller.isHideWindowFrame) return child;
var body = Stack( static WindowFrameController of(BuildContext context) {
children: [ return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
Positioned.fill( }
child: MediaQuery( }
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)), typedef WindowCloseListener = bool Function();
child: child,
class _WindowFrameState extends State<WindowFrame> {
bool isWindowFrameHidden = false;
bool useDarkTheme = false;
var closeListeners = <WindowCloseListener>[];
/// Sets the visibility of the window frame.
void setWindowFrame(bool show) {
setState(() {
isWindowFrameHidden = !show;
});
}
/// Adds a listener that will be called when close button is clicked.
/// The listener should return `true` to allow the window to be closed.
void addCloseListener(WindowCloseListener listener) {
closeListeners.add(listener);
}
/// Removes a close listener.
void removeCloseListener(WindowCloseListener listener) {
closeListeners.remove(listener);
}
void _onClose() {
for (var listener in closeListeners) {
if (!listener()) {
return;
}
}
windowManager.close();
}
@override
Widget build(BuildContext context) {
if (App.isMobile) return widget.child;
Widget body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: isWindowFrameHidden
? null
: const EdgeInsets.only(top: _kTitleBarHeight),
), ),
child: widget.child,
), ),
),
if (!isWindowFrameHidden)
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@@ -69,7 +110,7 @@ class WindowFrame extends StatelessWidget {
color: Colors.transparent, color: Colors.transparent,
child: Theme( child: Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
brightness: controller.useDarkTheme ? Brightness.dark : null, brightness: useDarkTheme ? Brightness.dark : null,
), ),
child: Builder(builder: (context) { child: Builder(builder: (context) {
return SizedBox( return SizedBox(
@@ -91,12 +132,14 @@ class WindowFrame extends StatelessWidget {
'Venera', 'Venera',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: (controller.useDarkTheme || color: (useDarkTheme ||
context.brightness == Brightness.dark) context.brightness == Brightness.dark)
? Colors.white ? Colors.white
: Colors.black, : Colors.black,
), ),
).toAlign(Alignment.centerLeft).paddingLeft(4+(App.isMacOS?25:0)), )
.toAlign(Alignment.centerLeft)
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
), ),
), ),
if (kDebugMode) if (kDebugMode)
@@ -104,7 +147,9 @@ class WindowFrame extends StatelessWidget {
onPressed: debug, onPressed: debug,
child: Text('Debug'), child: Text('Debug'),
), ),
if (!App.isMacOS) const WindowButtons() if (!App.isMacOS) _WindowButtons(
onClose: _onClose,
)
], ],
), ),
); );
@@ -112,70 +157,33 @@ class WindowFrame extends StatelessWidget {
), ),
), ),
) )
], ],
); );
if (App.isLinux) { if (App.isLinux) {
return VirtualWindowFrame(child: body); body = VirtualWindowFrame(child: body);
} else { }
return body;
}
});
}
Widget buildMenuButton( return WindowFrameController._create(
WindowFrameController controller, BuildContext context) { isWindowFrameHidden: isWindowFrameHidden,
return InkWell( setWindowFrame: setWindowFrame,
onTap: () { addCloseListener: addCloseListener,
controller.openSideBar(); removeCloseListener: removeCloseListener,
}, child: body,
child: SizedBox( );
width: 42,
height: double.infinity,
child: Center(
child: CustomPaint(
size: const Size(18, 20),
painter: _MenuPainter(
color: (controller.useDarkTheme ||
Theme.of(context).brightness == Brightness.dark)
? Colors.white
: Colors.black),
),
),
));
} }
} }
class _MenuPainter extends CustomPainter { class _WindowButtons extends StatefulWidget {
final Color color; const _WindowButtons({required this.onClose});
_MenuPainter({this.color = Colors.black}); final void Function() onClose;
@override @override
void paint(Canvas canvas, Size size) { State<_WindowButtons> createState() => _WindowButtonsState();
final paint = getPaint(color);
final path = Path()
..moveTo(0, size.height / 4)
..lineTo(size.width, size.height / 4)
..moveTo(0, size.height / 4 * 2)
..lineTo(size.width, size.height / 4 * 2)
..moveTo(0, size.height / 4 * 3)
..lineTo(size.width, size.height / 4 * 3);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
} }
class WindowButtons extends StatefulWidget { class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
const WindowButtons({super.key});
@override
State<WindowButtons> createState() => _WindowButtonsState();
}
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
bool isMaximized = false; bool isMaximized = false;
@override @override
@@ -264,9 +272,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();
},
) )
], ],
), ),
@@ -485,8 +491,15 @@ class WindowPlacement {
} }
} }
static Rect? lastValidRect;
static Future<WindowPlacement> get current async { static Future<WindowPlacement> get current async {
var rect = await windowManager.getBounds(); var rect = await windowManager.getBounds();
if (validate(rect)) {
lastValidRect = rect;
} else {
rect = lastValidRect ?? defaultPlacement.rect;
}
var isMaximized = await windowManager.isMaximized(); var isMaximized = await windowManager.isMaximized();
return WindowPlacement(rect, isMaximized); return WindowPlacement(rect, isMaximized);
} }
@@ -501,9 +514,6 @@ class WindowPlacement {
static void loop() async { static void loop() async {
timer ??= Timer.periodic(const Duration(milliseconds: 100), (timer) async { timer ??= Timer.periodic(const Duration(milliseconds: 100), (timer) async {
var placement = await WindowPlacement.current; var placement = await WindowPlacement.current;
if (!validate(placement.rect)) {
return;
}
if (placement.rect != cache.rect || if (placement.rect != cache.rect ||
placement.isMaximized != cache.isMaximized) { placement.isMaximized != cache.isMaximized) {
cache = placement; cache = placement;
@@ -559,7 +569,7 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
if (!_isMaximized && !_isFullScreen) if (!_isMaximized && !_isFullScreen)
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color: Colors.black.toOpacity(0.1),
offset: Offset(0.0, _isFocused ? 4 : 2), offset: Offset(0.0, _isFocused ? 4 : 2),
blurRadius: 6, blurRadius: 6,
) )
@@ -630,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.0.3"; final version = "1.3.2";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;
@@ -51,8 +54,16 @@ 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?.pop(); rootNavigatorKey.currentState?.maybePop();
} }
void pop() { void pop() {
@@ -63,22 +74,18 @@ class _App {
} }
} }
var mainColor = Colors.blue;
Future<void> init() async { Future<void> init() async {
cachePath = (await getApplicationCacheDirectory()).path; cachePath = (await getApplicationCacheDirectory()).path;
dataPath = (await getApplicationSupportDirectory()).path; dataPath = (await getApplicationSupportDirectory()).path;
mainColor = switch (appdata.settings['color']) { }
'red' => Colors.red,
'pink' => Colors.pink, Future<void> initComponents() async {
'purple' => Colors.purple, await Future.wait([
'green' => Colors.green, data.init(),
'orange' => Colors.orange, history.init(),
'blue' => Colors.blue, favorites.init(),
'yellow' => Colors.yellow, local.init(),
'cyan' => Colors.cyan, ]);
_ => Colors.blue,
};
} }
Function? _forceRebuildHandler; Function? _forceRebuildHandler;

View File

@@ -19,7 +19,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
super.barrierDismissible = false, super.barrierDismissible = false,
this.enableIOSGesture = true, this.enableIOSGesture = true,
this.preventRebuild = true, this.preventRebuild = true,
this.isRootRoute = false,
}) { }) {
assert(opaque); assert(opaque);
} }
@@ -50,9 +49,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
@override @override
final bool preventRebuild; final bool preventRebuild;
@override
final bool isRootRoute;
} }
mixin _AppRouteTransitionMixin<T> on PageRoute<T> { mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@@ -79,8 +75,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
bool get preventRebuild; bool get preventRebuild;
bool get isRootRoute;
Widget? _child; Widget? _child;
@override @override
@@ -121,22 +115,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@override @override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
if(isRootRoute) {
return FadeTransition(
opacity: Tween<double>(begin: 0, end: 1.0).animate(CurvedAnimation(
parent: animation,
curve: Curves.ease
)),
child: FadeTransition(
opacity: Tween<double>(begin: 1.0, end: 0).animate(CurvedAnimation(
parent: secondaryAnimation,
curve: Curves.ease
)),
child: child,
),
);
}
return SlidePageTransitionBuilder().buildTransitions( return SlidePageTransitionBuilder().buildTransitions(
this, this,
context, context,

View File

@@ -3,16 +3,19 @@ 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/utils/data_sync.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
class _Appdata { class Appdata {
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() async { Future<void> saveData([bool sync = true]) async {
if (_isSavingData) { if (_isSavingData) {
await Future.doWhile(() async { await Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 20)); await Future.delayed(const Duration(milliseconds: 20));
@@ -24,6 +27,9 @@ class _Appdata {
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);
_isSavingData = false; _isSavingData = false;
if (sync) {
DataSync().uploadData();
}
} }
void addSearchHistory(String keyword) { void addSearchHistory(String keyword) {
@@ -76,6 +82,28 @@ class _Appdata {
}; };
} }
/// Following fields are related to device-specific data and should not be synced.
static const _disableSync = [
"proxy",
"authorizationRequired",
"customImageProcessing",
"webdav",
];
/// Sync data from another device
void syncData(Map<String, dynamic> data) {
if (data['settings'] is Map) {
var settings = data['settings'] as Map<String, dynamic>;
for (var key in settings.keys) {
if (!_disableSync.contains(key)) {
this.settings[key] = settings[key];
}
}
}
searchHistory = List.from(data['searchHistory'] ?? []);
saveData();
}
var implicitData = <String, dynamic>{}; var implicitData = <String, dynamic>{};
void writeImplicitData() { void writeImplicitData() {
@@ -84,15 +112,15 @@ class _Appdata {
} }
} }
final appdata = _Appdata(); final appdata = Appdata._create();
class _Settings with ChangeNotifier { class Settings with ChangeNotifier {
_Settings(); Settings._create();
final _data = <String, dynamic>{ final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief 'comicDisplayMode': 'detailed', // detailed, brief
'comicTileScale': 1.00, // 0.75-1.25 'comicTileScale': 1.00, // 0.75-1.25
'color': 'blue', // red, pink, purple, green, orange, blue 'color': 'system', // red, pink, purple, green, orange, blue
'theme_mode': 'system', // light, dark, system 'theme_mode': 'system', // light, dark, system
'newFavoriteAddTo': 'end', // start, end 'newFavoriteAddTo': 'end', // start, end
'moveFavoriteAfterRead': 'none', // none, end, start 'moveFavoriteAfterRead': 'none', // none, end, start
@@ -100,20 +128,42 @@ class _Settings with ChangeNotifier {
'explore_pages': [], 'explore_pages': [],
'categories': [], 'categories': [],
'favorites': [], 'favorites': [],
'searchSources': null,
'showFavoriteStatusOnTile': true, 'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false, 'showHistoryStatusOnTile': false,
'blockedWords': [], 'blockedWords': [],
'defaultSearchTarget': null, 'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds 'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode] 'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumberForLandscape': 1, // 1 - 5
'readerScreenPicNumberForPortrait': 1, // 1 - 5
'enableTapToTurnPages': true, 'enableTapToTurnPages': true,
'reverseTapToTurnPages': false,
'enablePageAnimation': true, 'enablePageAnimation': true,
'language': 'system', // system, zh-CN, zh-TW, en-US 'language': 'system', // system, zh-CN, zh-TW, en-US
'cacheSize': 2048, // in MB 'cacheSize': 2048, // in MB
'downloadThreads': 5, 'downloadThreads': 5,
'enableLongPressToZoom': true, 'enableLongPressToZoom': true,
'checkUpdateOnStart': true, 'checkUpdateOnStart': false,
'limitImageWidth': true, 'limitImageWidth': true,
'webdav': [], // empty means not configured
'dataVersion': 0,
'quickFavorite': null,
'enableTurnPageByVolumeKey': true,
'enableClockAndBatteryInfoInReader': true,
'quickCollectImage': 'No', // No, DoubleTap, Swipe
'authorizationRequired': false,
'onClickFavorite': 'viewDetail', // viewDetail, read
'enableDnsOverrides': false,
'dnsOverrides': {},
'enableCustomImageProcessing': false,
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'preloadImageCount': 4,
'followUpdatesFolder': null,
'initialPage': '0',
}; };
operator [](String key) { operator [](String key) {
@@ -130,3 +180,21 @@ class _Settings with ChangeNotifier {
return _data.toString(); return _data.toString();
} }
} }
const defaultCustomImageProcessing = '''
/**
* Process an image
* @param image {ArrayBuffer} - The image to process
* @param cid {string} - The comic ID
* @param eid {string} - The episode ID
* @param page {number} - The page number
* @param sourceKey {string} - The source key
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
*/
function processImage(image, cid, eid, page, sourceKey) {
let futureImage = new Promise((resolve, reject) => {
resolve(image);
});
return futureImage;
}
''';

View File

@@ -1,4 +1,4 @@
part of comic_source; part of 'comic_source.dart';
class CategoryData { class CategoryData {
/// The title is displayed in the tab bar. /// The title is displayed in the tab bar.
@@ -145,7 +145,7 @@ class RandomCategoryPartWithRuntimeData extends BaseCategoryPart {
} }
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

@@ -1,4 +1,4 @@
library comic_source; library;
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
@@ -6,11 +6,14 @@ import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/app.dart'; 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/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';
@@ -25,81 +28,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();
@@ -118,23 +69,50 @@ 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();
} }
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;
@@ -198,7 +176,7 @@ class ComicSource {
final LikeCommentFunc? likeCommentFunc; final LikeCommentFunc? likeCommentFunc;
final Map<String, dynamic>? settings; final Map<String, Map<String, dynamic>>? settings;
final Map<String, Map<String, String>>? translations; final Map<String, Map<String, String>>? translations;
@@ -212,6 +190,8 @@ class ComicSource {
final StarRatingFunc? starRatingFunc; final StarRatingFunc? starRatingFunc;
final ArchiveDownloader? archiveDownloader;
Future<void> loadData() async { Future<void> loadData() async {
var file = File("${App.dataPath}/comic_source/$key.data"); var file = File("${App.dataPath}/comic_source/$key.data");
if (await file.exists()) { if (await file.exists()) {
@@ -236,6 +216,7 @@ class ComicSource {
} }
await file.writeAsString(jsonEncode(data)); await file.writeAsString(jsonEncode(data));
_isSaving = false; _isSaving = false;
DataSync().uploadData();
} }
Future<bool> reLogin() async { Future<bool> reLogin() async {
@@ -280,6 +261,7 @@ class ComicSource {
this.enableTagsSuggestions, this.enableTagsSuggestions,
this.enableTagsTranslate, this.enableTagsTranslate,
this.starRatingFunc, this.starRatingFunc,
this.archiveDownloader,
); );
} }
@@ -311,7 +293,7 @@ class AccountConfig {
this.onLoginWithWebviewSuccess, this.onLoginWithWebviewSuccess,
this.cookieFields, this.cookieFields,
this.validateCookies, this.validateCookies,
) : infoItems = const []; ) : infoItems = const [];
} }
class AccountInfoItem { class AccountInfoItem {
@@ -407,7 +389,7 @@ class SearchOptions {
const SearchOptions(this.options, this.label, this.type, this.defaultVal); const SearchOptions(this.options, this.label, this.type, this.defaultVal);
String get defaultValue => defaultVal ?? options.keys.first; String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? "";
} }
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function( typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
@@ -461,3 +443,11 @@ class LinkHandler {
const LinkHandler(this.domains, this.linkToId); const LinkHandler(this.domains, this.linkToId);
} }
class ArchiveDownloader {
final Future<Res<List<ArchiveInfo>>> Function(String cid) getArchives;
final Future<Res<String>> Function(String cid, String aid) getDownloadUrl;
const ArchiveDownloader(this.getArchives, this.getDownloadUrl);
}

View File

@@ -10,6 +10,10 @@ class FavoriteData {
final bool multiFolder; final bool multiFolder;
// 这个收藏时间新旧顺序, 是为了最小成本同步远端的收藏, 只拉取远程最新收藏的漫画, 就不需要全拉取一遍了
// 如果为 null, 当做从新到旧
final bool? isOldToNewSort;
final Future<Res<List<Comic>>> Function(int page, [String? folder])? final Future<Res<List<Comic>>> Function(int page, [String? folder])?
loadComic; loadComic;
@@ -33,6 +37,8 @@ class FavoriteData {
final AddOrDelFavFunc? addOrDelFavorite; final AddOrDelFavFunc? addOrDelFavorite;
final bool singleFolderForSingleComic;
const FavoriteData({ const FavoriteData({
required this.key, required this.key,
required this.title, required this.title,
@@ -44,6 +50,8 @@ class FavoriteData {
this.addFolder, this.addFolder,
this.allFavoritesId, this.allFavoritesId,
this.addOrDelFavorite, this.addOrDelFavorite,
this.isOldToNewSort,
this.singleFolderForSingleComic = false,
}); });
} }

View File

@@ -73,7 +73,8 @@ class Comic {
this.sourceKey, this.sourceKey,
this.maxPage, this.maxPage,
this.language, this.language,
): favoriteId = null, stars = null; ) : favoriteId = null,
stars = null;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
@@ -92,7 +93,7 @@ class Comic {
Comic.fromJson(Map<String, dynamic> json, this.sourceKey) Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
: title = json["title"], : title = json["title"],
subtitle = json["subTitle"] ?? "", subtitle = json["subtitle"] ?? json["subTitle"] ?? "",
cover = json["cover"], cover = json["cover"],
id = json["id"], id = json["id"],
tags = List<String>.from(json["tags"] ?? []), tags = List<String>.from(json["tags"] ?? []),
@@ -127,7 +128,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;
final List<String>? thumbnails; final List<String>? thumbnails;
@@ -145,7 +146,7 @@ class ComicDetails with HistoryMixin {
final int? likesCount; final int? likesCount;
final int? commentsCount; final int? commentCount;
final String? uploader; final String? uploader;
@@ -160,6 +161,8 @@ class ComicDetails with HistoryMixin {
@override @override
final int? maxPage; final int? maxPage;
final List<Comment>? comments;
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) {
@@ -170,13 +173,11 @@ class ComicDetails with HistoryMixin {
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 = json["chapters"] == null chapters = ComicChapters.fromJsonOrNull(json["chapters"]),
? null
: Map<String, String>.from(json["chapters"]),
sourceKey = json["sourceKey"], sourceKey = json["sourceKey"],
comicId = json["comicId"], comicId = json["comicId"],
thumbnails = ListOrNull.from(json["thumbnails"]), thumbnails = ListOrNull.from(json["thumbnails"]),
@@ -187,13 +188,16 @@ class ComicDetails with HistoryMixin {
subId = json["subId"], subId = json["subId"],
likesCount = json["likesCount"], likesCount = json["likesCount"],
isLiked = json["isLiked"], isLiked = json["isLiked"],
commentsCount = json["commentsCount"], commentCount = json["commentCount"],
uploader = json["uploader"], uploader = json["uploader"],
uploadTime = json["uploadTime"], uploadTime = json["uploadTime"],
updateTime = json["updateTime"], updateTime = json["updateTime"],
url = json["url"], url = json["url"],
stars = (json["stars"] as num?)?.toDouble(), stars = (json["stars"] as num?)?.toDouble(),
maxPage = json["maxPage"]; maxPage = json["maxPage"],
comments = (json["comments"] as List?)
?.map((e) => Comment.fromJson(e))
.toList();
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
@@ -211,7 +215,7 @@ class ComicDetails with HistoryMixin {
"subId": subId, "subId": subId,
"isLiked": isLiked, "isLiked": isLiked,
"likesCount": likesCount, "likesCount": likesCount,
"commentsCount": commentsCount, "commentsCount": commentCount,
"uploader": uploader, "uploader": uploader,
"uploadTime": uploadTime, "uploadTime": uploadTime,
"updateTime": updateTime, "updateTime": updateTime,
@@ -226,4 +230,199 @@ class ComicDetails with HistoryMixin {
String get id => comicId; String get id => comicId;
ComicType get comicType => ComicType(sourceKey.hashCode); ComicType get comicType => ComicType(sourceKey.hashCode);
/// Convert tags map to plain list
List<String> get plainTags {
var res = <String>[];
tags.forEach((key, value) {
res.addAll(value.map((e) => "$key:$e"));
});
return res;
}
/// Find the first author tag
String? findAuthor() {
var authorNamespaces = [
"author",
"authors",
"artist",
"artists",
"作者",
"画师"
];
for (var entry in tags.entries) {
if (authorNamespaces.contains(entry.key.toLowerCase()) &&
entry.value.isNotEmpty) {
return entry.value.first;
}
}
return null;
}
String? _validateUpdateTime(String time) {
time = time.split(" ").first;
var segments = time.split("-");
if (segments.length != 3) return null;
var year = int.tryParse(segments[0]);
var month = int.tryParse(segments[1]);
var day = int.tryParse(segments[2]);
if (year == null || month == null || day == null) return null;
if (year < 2000 || year > 3000) return null;
if (month < 1 || month > 12) return null;
if (day < 1 || day > 31) return null;
return "$year-$month-$day";
}
String? findUpdateTime() {
if (updateTime != null) {
return _validateUpdateTime(updateTime!);
}
const acceptedNamespaces = [
"更新",
"最後更新",
"最后更新",
"update",
"last update",
];
for (var entry in tags.entries) {
if (acceptedNamespaces.contains(entry.key.toLowerCase()) &&
entry.value.isNotEmpty) {
var value = entry.value.first;
return _validateUpdateTime(value);
}
}
return null;
}
}
class ArchiveInfo {
final String title;
final String description;
final String id;
ArchiveInfo.fromJson(Map<String, dynamic> json)
: title = json["title"],
description = json["description"],
id = json["id"];
}
class ComicChapters {
final Map<String, String>? _chapters;
final Map<String, Map<String, String>>? _groupedChapters;
/// Create a ComicChapters object with a flat map
const ComicChapters(Map<String, String> this._chapters)
: _groupedChapters = null;
/// Create a ComicChapters object with a grouped map
const ComicChapters.grouped(
Map<String, Map<String, String>> this._groupedChapters)
: _chapters = null;
factory ComicChapters.fromJson(dynamic json) {
if (json is! Map) throw ArgumentError("Invalid json type");
var chapters = <String, String>{};
var groupedChapters = <String, Map<String, String>>{};
for (var entry in json.entries) {
var key = entry.key;
var value = entry.value;
if (key is! String) throw ArgumentError("Invalid key type");
if (value is Map) {
groupedChapters[key] = Map.from(value);
} else {
chapters[key] = value.toString();
}
}
if (chapters.isNotEmpty) {
return ComicChapters(chapters);
} else if (groupedChapters.isNotEmpty) {
return ComicChapters.grouped(groupedChapters);
} else {
throw ArgumentError("Empty chapter list");
}
}
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];
}
}
} }

View File

@@ -1,5 +1,6 @@
part of 'comic_source.dart'; part of 'comic_source.dart';
/// return true if ver1 > ver2
bool compareSemVer(String ver1, String ver2) { bool compareSemVer(String ver1, String ver2) {
ver1 = ver1.replaceFirst("-", "."); ver1 = ver1.replaceFirst("-", ".");
ver2 = ver2.replaceFirst("-", "."); ver2 = ver2.replaceFirst("-", ".");
@@ -90,11 +91,10 @@ class ComicSourceParser {
var className = line1.split("class")[1].split("extends ComicSource").first; var className = line1.split("class")[1].split("extends ComicSource").first;
className = className.trim(); className = className.trim();
JsEngine().runCode(""" JsEngine().runCode("""
(() => { (() => { $js
$js
this['temp'] = new $className() this['temp'] = new $className()
}).call() }).call()
"""); """, className);
_name = JsEngine().runCode("this['temp'].name") ?? _name = JsEngine().runCode("this['temp'].name") ??
(throw ComicSourceParseException('name is required')); (throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") ?? var key = JsEngine().runCode("this['temp'].key") ??
@@ -153,13 +153,16 @@ class ComicSourceParser {
_getValue("search.enableTagsSuggestions") ?? false, _getValue("search.enableTagsSuggestions") ?? false,
_getValue("comic.enableTagsTranslate") ?? false, _getValue("comic.enableTagsTranslate") ?? false,
_parseStarRatingFunc(), _parseStarRatingFunc(),
_parseArchiveDownloader(),
); );
await source.loadData(); await source.loadData();
Future.delayed(const Duration(milliseconds: 50), () { if (_checkExists("init")) {
JsEngine().runCode("ComicSource.sources.$_key.init()"); Future.delayed(const Duration(milliseconds: 50), () {
}); JsEngine().runCode("ComicSource.sources.$_key.init()");
});
}
return source; return source;
} }
@@ -616,6 +619,8 @@ class ComicSourceParser {
if (!_checkExists("favorites")) return null; if (!_checkExists("favorites")) return null;
final bool multiFolder = _getValue("favorites.multiFolder"); final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic = _getValue("favorites.singleFolderForSingleComic");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async { Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) { if (!ComicSource.find(_key!)!.isLogged) {
@@ -768,6 +773,8 @@ class ComicSourceParser {
addFolder: addFolder, addFolder: addFolder,
deleteFolder: deleteFolder, deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc, addOrDelFavorite: addOrDelFavFunc,
isOldToNewSort: isOldToNewSort,
singleFolderForSingleComic: singleFolderForSingleComic ?? false,
); );
} }
@@ -918,8 +925,30 @@ class ComicSourceParser {
}; };
} }
Map<String, dynamic> _parseSettings() { Map<String, Map<String, dynamic>> _parseSettings() {
return _getValue("settings") ?? {}; var value = _getValue("settings");
if (value is Map) {
var newMap = <String, Map<String, dynamic>>{};
for (var e in value.entries) {
if (e.key is! String) {
continue;
}
var v = <String, dynamic>{};
for (var e2 in e.value.entries) {
if (e2.key is! String) {
continue;
}
var v2 = e2.value;
if (v2 is JSInvokable) {
v2 = JSAutoFreeFunction(v2);
}
v[e2.key] = v2;
}
newMap[e.key] = v;
}
return newMap;
}
return {};
} }
RegExp? _parseIdMatch() { RegExp? _parseIdMatch() {
@@ -986,4 +1015,35 @@ class ComicSourceParser {
} }
}; };
} }
ArchiveDownloader? _parseArchiveDownloader() {
if (!_checkExists("comic.archive")) {
return null;
}
return ArchiveDownloader(
(cid) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
""");
return Res(
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList());
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
},
(cid, aid) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.archive.getDownloadUrl(${jsonEncode(cid)}, ${jsonEncode(aid)})
""");
return Res(res as String);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
},
);
}
} }

View File

@@ -0,0 +1,48 @@
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 = 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);

View File

@@ -28,4 +28,12 @@ class ComicType {
} }
static const local = ComicType(0); static const local = ComicType(0);
factory ComicType.fromKey(String key) {
if(key == "local") {
return local;
} else {
return ComicType(key.hashCode);
}
}
} }

View File

@@ -1,6 +1,17 @@
/// If window width is less than this value, it is considered as mobile.
const changePoint = 600; const changePoint = 600;
/// If window width is less than this value, it is considered as tablet.
///
/// If it is more than this value, it is considered as desktop.
const changePoint2 = 1300; const changePoint2 = 1300;
/// Default user agent for http requests.
const webUA = const webUA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
/// Pages for all comics is started from this value.
const firstPage = 1;
/// Chapters for all comics is started from this value.
const firstChapter = 1;

View File

@@ -36,6 +36,8 @@ extension Navigation on BuildContext {
Brightness get brightness => Theme.of(this).brightness; Brightness get brightness => Theme.of(this).brightness;
bool get isDarkMode => brightness == Brightness.dark;
void showMessage({required String message}) { void showMessage({required String message}) {
showToast(message: message, context: this); showToast(message: message, context: this);
} }

View File

@@ -1,20 +1,21 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/image_provider/local_favorite_image.dart'; import 'package:venera/foundation/image_provider/local_favorite_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/utils/tags_translation.dart';
import 'dart:io'; import 'dart:io';
import 'app.dart'; import 'app.dart';
import 'comic_source/comic_source.dart'; import 'comic_source/comic_source.dart';
import 'comic_type.dart'; import 'comic_type.dart';
String _getCurTime() { String _getTimeString(DateTime time) {
return DateTime.now() return time.toIso8601String().replaceFirst("T", " ").substring(0, 19);
.toIso8601String()
.replaceFirst("T", " ")
.substring(0, 19);
} }
class FavoriteItem implements Comic { class FavoriteItem implements Comic {
@@ -26,16 +27,19 @@ class FavoriteItem implements Comic {
@override @override
String id; String id;
String coverPath; String coverPath;
String time = _getCurTime(); late String time;
FavoriteItem({ FavoriteItem(
required this.id, {required this.id,
required this.name, required this.name,
required this.coverPath, required this.coverPath,
required this.author, required this.author,
required this.type, required this.type,
required this.tags, required this.tags,
}); DateTime? favoriteTime}) {
var t = favoriteTime ?? DateTime.now();
time = _getTimeString(t);
}
FavoriteItem.fromRow(Row row) FavoriteItem.fromRow(Row row)
: name = row["name"], : name = row["name"],
@@ -70,7 +74,10 @@ class FavoriteItem implements Comic {
@override @override
String get description { String get description {
return "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}"; var time = this.time.substring(0, 10);
return appdata.settings['comicDisplayMode'] == 'detailed'
? "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}"
: "${type.comicSource?.name ?? "Unknown"} | $time";
} }
@override @override
@@ -148,7 +155,51 @@ class FavoriteItemWithFolderInfo extends FavoriteItem {
); );
} }
class LocalFavoritesManager { class FavoriteItemWithUpdateInfo extends FavoriteItem {
String? updateTime;
DateTime? lastCheckTime;
bool hasNewUpdate;
FavoriteItemWithUpdateInfo(
FavoriteItem item,
this.updateTime,
this.hasNewUpdate,
int? lastCheckTime,
) : lastCheckTime = lastCheckTime == null
? null
: DateTime.fromMillisecondsSinceEpoch(lastCheckTime),
super(
id: item.id,
name: item.name,
coverPath: item.coverPath,
author: item.author,
type: item.type,
tags: item.tags,
);
@override
String get description {
var updateTime = this.updateTime ?? "Unknown";
var sourceName = type.comicSource?.name ?? "Unknown";
return "$updateTime | $sourceName";
}
@override
operator ==(Object other) {
return other is FavoriteItemWithUpdateInfo &&
other.updateTime == updateTime &&
other.hasNewUpdate == hasNewUpdate &&
super == other;
}
@override
int get hashCode =>
super.hashCode ^ updateTime.hashCode ^ hasNewUpdate.hashCode;
}
class LocalFavoritesManager with ChangeNotifier {
factory LocalFavoritesManager() => factory LocalFavoritesManager() =>
cache ?? (cache = LocalFavoritesManager._create()); cache ?? (cache = LocalFavoritesManager._create());
@@ -166,6 +217,35 @@ class LocalFavoritesManager {
order_value int order_value int
); );
"""); """);
_db.execute("""
create table if not exists folder_sync (
folder_name text primary key,
source_key text,
source_folder text
);
""");
for (var folder in _getFolderNamesWithDB()) {
var columns = _db.select("""
pragma table_info("$folder");
""");
if (!columns.any((element) => element["name"] == "translated_tags")) {
_db.execute("""
alter table "$folder"
add column translated_tags TEXT;
""");
var comics = getAllComics(folder);
for (var comic in comics) {
var translatedTags = _translateTags(comic.tags);
_db.execute("""
update "$folder"
set translated_tags = ?
where id == ? and type == ?;
""", [translatedTags, comic.id, comic.type.value]);
}
} else {
break;
}
}
} }
List<String> find(String id, ComicType type) { List<String> find(String id, ComicType type) {
@@ -226,13 +306,14 @@ class LocalFavoritesManager {
return folders; return folders;
} }
void updateOrder(Map<String, int> order) { void updateOrder(List<String> folders) {
for (var folder in order.keys) { for (int i = 0; i < folders.length; i++) {
_db.execute(""" _db.execute("""
insert or replace into folder_order (folder_name, order_value) insert or replace into folder_order (folder_name, order_value)
values (?, ?); values (?, ?);
""", [folder, order[folder]]); """, [folders[i], i]);
} }
notifyListeners();
} }
int count(String folderName) { int count(String folderName) {
@@ -272,6 +353,7 @@ class LocalFavoritesManager {
set tags = '$tag,' || tags set tags = '$tag,' || tags
where id == ? where id == ?
""", [id]); """, [id]);
notifyListeners();
} }
List<FavoriteItemWithFolderInfo> allComics() { List<FavoriteItemWithFolderInfo> allComics() {
@@ -286,12 +368,16 @@ class LocalFavoritesManager {
return res; return res;
} }
bool existsFolder(String name) {
return folderNames.contains(name);
}
/// create a folder /// create a folder
String createFolder(String name, [bool renameWhenInvalidName = false]) { String createFolder(String name, [bool renameWhenInvalidName = false]) {
if (name.isEmpty) { if (name.isEmpty) {
if (renameWhenInvalidName) { if (renameWhenInvalidName) {
int i = 0; int i = 0;
while (folderNames.contains(i.toString())) { while (existsFolder(i.toString())) {
i++; i++;
} }
name = i.toString(); name = i.toString();
@@ -299,11 +385,11 @@ class LocalFavoritesManager {
throw "name is empty!"; throw "name is empty!";
} }
} }
if (folderNames.contains(name)) { if (existsFolder(name)) {
if (renameWhenInvalidName) { if (renameWhenInvalidName) {
var prevName = name; var prevName = name;
int i = 0; int i = 0;
while (folderNames.contains(i.toString())) { while (existsFolder(i.toString())) {
i++; i++;
} }
name = prevName + i.toString(); name = prevName + i.toString();
@@ -321,12 +407,41 @@ class LocalFavoritesManager {
cover_path TEXT, cover_path TEXT,
time TEXT, time TEXT,
display_order int, display_order int,
translated_tags TEXT,
primary key (id, type) primary key (id, type)
); );
"""); """);
notifyListeners();
return name; return name;
} }
void linkFolderToNetwork(String folder, String source, String networkFolder) {
_db.execute("""
insert or replace into folder_sync (folder_name, source_key, source_folder)
values (?, ?, ?);
""", [folder, source, networkFolder]);
}
bool isLinkedToNetworkFolder(
String folder, String source, String networkFolder) {
var res = _db.select("""
select * from folder_sync
where folder_name == ? and source_key == ? and source_folder == ?;
""", [folder, source, networkFolder]);
return res.isNotEmpty;
}
(String?, String?) findLinked(String folder) {
var res = _db.select("""
select * from folder_sync
where folder_name == ?;
""", [folder]);
if (res.isEmpty) {
return (null, null);
}
return (res.first["source_key"], res.first["source_folder"]);
}
bool comicExists(String folder, String id, ComicType type) { bool comicExists(String folder, String id, ComicType type) {
var res = _db.select(""" var res = _db.select("""
select * from "$folder" select * from "$folder"
@@ -346,21 +461,33 @@ class LocalFavoritesManager {
return FavoriteItem.fromRow(res.first); return FavoriteItem.fromRow(res.first);
} }
/// add comic to a folder String _translateTags(List<String> tags) {
/// var res = <String>[];
/// This method will download cover to local, to avoid problems like changing url for (var tag in tags) {
void addComic(String folder, FavoriteItem comic, [int? order]) async { var translated = tag.translateTagsToCN;
if (translated != tag) {
res.add(translated);
}
}
return res.join(",");
}
/// add comic to a folder.
/// return true if success, false if already exists
bool addComic(String folder, FavoriteItem comic,
[int? order, String? updateTime]) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
if (!folderNames.contains(folder)) { if (!existsFolder(folder)) {
throw Exception("Folder does not exists"); throw Exception("Folder does not exists");
} }
var res = _db.select(""" var res = _db.select("""
select * from "$folder" select * from "$folder"
where id == '${comic.id}'; where id == ? and type == ?;
"""); """, [comic.id, comic.type.value]);
if (res.isNotEmpty) { if (res.isNotEmpty) {
return; return false;
} }
var translatedTags = _translateTags(comic.tags);
final params = [ final params = [
comic.id, comic.id,
comic.name, comic.name,
@@ -368,24 +495,74 @@ class LocalFavoritesManager {
comic.type.value, comic.type.value,
comic.tags.join(","), comic.tags.join(","),
comic.coverPath, comic.coverPath,
comic.time comic.time,
translatedTags
]; ];
if (order != null) { if (order != null) {
_db.execute(""" _db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order) insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, order]); """, [...params, order]);
} else if (appdata.settings['newFavoriteAddTo'] == "end") { } else if (appdata.settings['newFavoriteAddTo'] == "end") {
_db.execute(""" _db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order) insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, maxValue(folder) + 1]); """, [...params, maxValue(folder) + 1]);
} else { } else {
_db.execute(""" _db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order) insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, minValue(folder) - 1]); """, [...params, minValue(folder) - 1]);
} }
if (updateTime != null) {
var columns = _db.select("""
pragma table_info("$folder");
""");
if (columns.any((element) => element["name"] == "last_update_time")) {
_db.execute("""
update "$folder"
set last_update_time = ?
where id == ? and type == ?;
""", [updateTime, comic.id, comic.type.value]);
}
}
notifyListeners();
return true;
}
void moveFavorite(
String sourceFolder, String targetFolder, String id, ComicType type) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
var res = _db.select("""
select * from "$targetFolder"
where id == ? and type == ?;
""", [id, type.value]);
if (res.isNotEmpty) {
return;
}
_db.execute("""
insert 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 == ?;
""", [minValue(targetFolder) - 1, id, type.value]);
_db.execute("""
delete from "$sourceFolder"
where id == ? and type == ?;
""", [id, type.value]);
notifyListeners();
} }
/// delete a folder /// delete a folder
@@ -394,6 +571,11 @@ class LocalFavoritesManager {
_db.execute(""" _db.execute("""
drop table "$name"; drop table "$name";
"""); """);
_db.execute("""
delete from folder_order
where folder_name == ?;
""", [name]);
notifyListeners();
} }
void deleteComic(String folder, FavoriteItem comic) { void deleteComic(String folder, FavoriteItem comic) {
@@ -408,6 +590,24 @@ class LocalFavoritesManager {
delete from "$folder" delete from "$folder"
where id == ? and type == ?; where id == ? and type == ?;
""", [id, type.value]); """, [id, type.value]);
notifyListeners();
}
Future<int> removeInvalid() async {
int count = 0;
await Future.microtask(() {
var all = allComics();
for (var c in all) {
var comicSource = c.type.comicSource;
if ((c.type == ComicType.local &&
LocalManager().find(c.id, c.type) == null) ||
(c.type != ComicType.local && comicSource == null)) {
deleteComicWithId(c.folder, c.id, c.type);
count++;
}
}
});
return count;
} }
Future<void> clearAll() async { Future<void> clearAll() async {
@@ -417,7 +617,7 @@ class LocalFavoritesManager {
} }
void reorder(List<FavoriteItem> newFolder, String folder) async { void reorder(List<FavoriteItem> newFolder, String folder) async {
if (!folderNames.contains(folder)) { if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found"); throw Exception("Failed to reorder: folder not found");
} }
deleteFolder(folder); deleteFolder(folder);
@@ -425,10 +625,11 @@ class LocalFavoritesManager {
for (int i = 0; i < newFolder.length; i++) { for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], i); addComic(folder, newFolder[i], i);
} }
notifyListeners();
} }
void rename(String before, String after) { void rename(String before, String after) {
if (folderNames.contains(after)) { if (existsFolder(after)) {
throw "Name already exists!"; throw "Name already exists!";
} }
if (after.contains('"')) { if (after.contains('"')) {
@@ -438,10 +639,26 @@ class LocalFavoritesManager {
ALTER TABLE "$before" ALTER TABLE "$before"
RENAME TO "$after"; RENAME TO "$after";
"""); """);
_db.execute("""
update folder_order
set folder_name = ?
where folder_name == ?;
""", [after, before]);
_db.execute("""
update folder_sync
set folder_name = ?
where folder_name == ?;
""", [after, before]);
notifyListeners();
} }
void onReadEnd(String id, ComicType type) async { void onRead(String id, ComicType type) async {
if (appdata.settings['moveFavoriteAfterRead'] == "none") {
markAsRead(id, type);
return;
}
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
for (final folder in folderNames) { for (final folder in folderNames) {
var rows = _db.select(""" var rows = _db.select("""
select * from "$folder" select * from "$folder"
@@ -470,11 +687,43 @@ class LocalFavoritesManager {
UPDATE "$folder" UPDATE "$folder"
SET SET
$updateLocationSql $updateLocationSql
${followUpdatesFolder == folder ? "has_new_update = 0," : ""}
time = ? time = ?
WHERE id == ?; WHERE id == ? and type == ?;
""", [newTime, id]); """, [newTime, id, type.value]);
if (followUpdatesFolder == folder) {
updateFollowUpdatesUI();
}
} }
} }
notifyListeners();
}
List<FavoriteItem> searchInFolder(String folder, String keyword) {
var keywordList = keyword.split(" ");
keyword = keywordList.first;
keyword = "%$keyword%";
var res = _db.select("""
SELECT * FROM "$folder"
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword, keyword]);
var comics = res.map((e) => FavoriteItem.fromRow(e)).toList();
bool test(FavoriteItem comic, String keyword) {
if (comic.name.contains(keyword)) {
return true;
} else if (comic.author.contains(keyword)) {
return true;
} else if (comic.tags.any((element) => element.contains(keyword))) {
return true;
}
return false;
}
for (var i = 1; i < keywordList.length; i++) {
comics =
comics.where((element) => test(element, keywordList[i])).toList();
}
return comics;
} }
List<FavoriteItemWithFolderInfo> search(String keyword) { List<FavoriteItemWithFolderInfo> search(String keyword) {
@@ -485,8 +734,8 @@ class LocalFavoritesManager {
keyword = "%$keyword%"; keyword = "%$keyword%";
var res = _db.select(""" var res = _db.select("""
SELECT * FROM "$table" SELECT * FROM "$table"
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ?; WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword]); """, [keyword, keyword, keyword, keyword]);
for (var comic in res) { for (var comic in res) {
comics.add( comics.add(
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table)); FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
@@ -521,6 +770,7 @@ class LocalFavoritesManager {
set tags = ? set tags = ?
where id == ?; where id == ?;
""", [tags.join(","), id]); """, [tags.join(","), id]);
notifyListeners();
} }
final _cachedFavoritedIds = <String, bool>{}; final _cachedFavoritedIds = <String, bool>{};
@@ -547,7 +797,7 @@ class LocalFavoritesManager {
} }
} }
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 = ?
@@ -560,6 +810,9 @@ class LocalFavoritesManager {
comic.id, comic.id,
comic.type.value comic.type.value
]); ]);
if (notify) {
notifyListeners();
}
} }
String folderToJson(String folder) { String folderToJson(String folder) {
@@ -579,9 +832,9 @@ class LocalFavoritesManager {
if (folder == null || folder is! String) { if (folder == null || folder is! String) {
throw "Invalid data"; throw "Invalid data";
} }
if (folderNames.contains(folder)) { if (existsFolder(folder)) {
int i = 0; int i = 0;
while (folderNames.contains("$folder($i)")) { while (existsFolder("$folder($i)")) {
i++; i++;
} }
folder = "$folder($i)"; folder = "$folder($i)";
@@ -596,7 +849,134 @@ class LocalFavoritesManager {
} }
} }
void prepareTableForFollowUpdates(String table) {
// check if the table has the column "last_update_time" "has_new_update" "last_check_time"
var columns = _db.select("""
pragma table_info("$table");
""");
if (!columns.any((element) => element["name"] == "last_update_time")) {
_db.execute("""
alter table "$table"
add column last_update_time TEXT;
""");
}
if (!columns.any((element) => element["name"] == "has_new_update")) {
_db.execute("""
alter table "$table"
add column has_new_update int;
""");
}
_db.execute("""
update "$table"
set has_new_update = 0;
""");
if (!columns.any((element) => element["name"] == "last_check_time")) {
_db.execute("""
alter table "$table"
add column last_check_time int;
""");
}
}
void updateUpdateTime(
String folder,
String id,
ComicType type,
String updateTime,
) {
var oldTime = _db.select("""
select last_update_time from "$folder"
where id == ? and type == ?;
""", [id, type.value]).first['last_update_time'];
var hasNewUpdate = oldTime != updateTime;
_db.execute("""
update "$folder"
set last_update_time = ?, has_new_update = ?, last_check_time = ?
where id == ? and type == ?;
""", [
updateTime,
hasNewUpdate ? 1 : 0,
DateTime.now().millisecondsSinceEpoch,
id,
type.value,
]);
}
void updateCheckTime(
String folder,
String id,
ComicType type,
) {
_db.execute("""
update "$folder"
set last_check_time = ?
where id == ? and type == ?;
""", [DateTime.now().millisecondsSinceEpoch, id, type.value]);
}
int countUpdates(String folder) {
return _db.select("""
select count(*) as c from "$folder"
where has_new_update == 1;
""").first['c'];
}
List<FavoriteItemWithUpdateInfo> getUpdates(String folder) {
if (!existsFolder(folder)) {
return [];
}
var res = _db.select("""
select * from "$folder"
where has_new_update == 1;
""");
return res
.map(
(e) => FavoriteItemWithUpdateInfo(
FavoriteItem.fromRow(e),
e['last_update_time'],
e['has_new_update'] == 1,
e['last_check_time'],
),
)
.toList();
}
List<FavoriteItemWithUpdateInfo> getComicsWithUpdatesInfo(String folder) {
if (!existsFolder(folder)) {
return [];
}
var res = _db.select("""
select * from "$folder";
""");
return res
.map(
(e) => FavoriteItemWithUpdateInfo(
FavoriteItem.fromRow(e),
e['last_update_time'],
e['has_new_update'] == 1,
e['last_check_time'],
),
)
.toList();
}
void markAsRead(String id, ComicType type) {
var folder = appdata.settings['followUpdatesFolder'];
if (!existsFolder(folder)) {
return;
}
_db.execute("""
update "$folder"
set has_new_update = 0
where id == ? and type == ?;
""", [id, type.value]);
}
void close() { void close() {
_db.dispose(); _db.dispose();
} }
void notifyChanges() {
notifyListeners();
}
} }

View File

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

View File

@@ -1,10 +1,24 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:ffi' as ffi;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'app.dart'; import 'app.dart';
import 'consts.dart';
part "image_favorites.dart";
typedef HistoryType = ComicType; typedef HistoryType = ComicType;
@@ -22,57 +36,57 @@ abstract mixin class HistoryMixin {
HistoryType get historyType; HistoryType get historyType;
} }
class History { class History implements Comic {
HistoryType type; HistoryType type;
DateTime time; DateTime time;
@override
String title; String title;
@override
String subtitle; String subtitle;
@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
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
int? maxPage; int? maxPage;
History.fromModel( History.fromModel(
{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"]),
@@ -82,8 +96,9 @@ class History {
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
@@ -100,35 +115,11 @@ class History {
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"];
static Future<History> findOrCreate(
HistoryMixin model, {
int ep = 0,
int page = 0,
}) async {
var history = await HistoryManager().find(model.id, model.historyType);
if (history != null) {
return history;
}
history = History.fromModel(model: model, ep: ep, page: page);
HistoryManager().addHistory(history);
return history;
}
static Future<History> createIfNull(
History? history, HistoryMixin model) async {
if (history != null) {
return history;
}
history = History.fromModel(model: model, ep: 0, page: 0);
HistoryManager().addHistory(history);
return history;
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@@ -137,6 +128,47 @@ class History {
@override @override
int get hashCode => Object.hash(id, type); int get hashCode => Object.hash(id, type);
@override
String get description {
var res = "";
if (ep >= 1) {
res += "Chapter @ep".tlParams({
"ep": ep,
});
}
if (page >= 1) {
if (ep >= 1) {
res += " - ";
}
res += "Page @page".tlParams({
"page": page,
});
}
return res;
}
@override
String? get favoriteId => null;
@override
String? get language => null;
@override
String get sourceKey => type == ComicType.local
? 'local'
: type.comicSource?.key ?? "Unknown:${type.value}";
@override
double? get stars => null;
@override
List<String>? get tags => null;
@override
Map<String, dynamic> toJson() {
throw UnimplementedError();
}
} }
class HistoryManager with ChangeNotifier { class HistoryManager with ChangeNotifier {
@@ -151,11 +183,18 @@ class HistoryManager with ChangeNotifier {
int get length => _db.select("select count(*) from history;").first[0] as int; int get length => _db.select("select count(*) from history;").first[0] as int;
Map<String, bool>? _cachedHistory; /// Cache of history ids. Improve the performance of find operation.
Map<String, bool>? _cachedHistoryIds;
static const _kMaxHistoryLength = 200; /// Cache records recently modified by the app. Improve the performance of listeners.
final cachedHistories = <String, History>{};
bool isInitialized = false;
Future<void> init() async { Future<void> init() async {
if (isInitialized) {
return;
}
_db = sqlite3.open("${App.dataPath}/history.db"); _db = sqlite3.open("${App.dataPath}/history.db");
_db.execute(""" _db.execute("""
@@ -169,27 +208,73 @@ 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();
ImageFavoriteManager().init();
isInitialized = true;
}
static const _insertHistorySql = """
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page, chapter_group)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""";
static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
return Isolate.run(() {
var db = sqlite3.fromPointer(ffi.Pointer.fromAddress(dbAddr));
db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
newItem.cover,
newItem.time.millisecondsSinceEpoch,
newItem.type.value,
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage,
newItem.group
]);
});
}
bool _haveAsyncTask = false;
/// Create a isolate to add history to prevent blocking the UI thread.
Future<void> addHistoryAsync(History newItem) async {
while (_haveAsyncTask) {
await Future.delayed(Duration(milliseconds: 20));
}
_haveAsyncTask = true;
await _addHistoryAsync(_db.handle.address, newItem);
_haveAsyncTask = false;
if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners(); notifyListeners();
} }
/// add history. if exists, update time. /// add history. if exists, update time.
/// ///
/// This function would be called when user start reading. /// This function would be called when user start reading.
Future<void> addHistory(History newItem) async { void addHistory(History newItem) {
while(count() >= _kMaxHistoryLength) { _db.execute(_insertHistorySql, [
_db.execute("""
delete from history
where time == (select min(time) from history);
""");
}
_db.execute("""
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [
newItem.id, newItem.id,
newItem.title, newItem.title,
newItem.subtitle, newItem.subtitle,
@@ -199,9 +284,18 @@ class HistoryManager with ChangeNotifier {
newItem.ep, newItem.ep,
newItem.page, newItem.page,
newItem.readEpisode.join(','), newItem.readEpisode.join(','),
newItem.maxPage newItem.maxPage,
newItem.group
]); ]);
updateCache(); if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners(); notifyListeners();
} }
@@ -220,27 +314,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<History?> find(String id, ComicType type) async {
return findSync(id, type);
}
void updateCache() { void updateCache() {
_cachedHistory = {}; _cachedHistoryIds = {};
var res = _db.select(""" var res = _db.select("""
select * from history; select id from history;
"""); """);
for (var element in res) { for (var element in res) {
_cachedHistory![element["id"] as String] = true; _cachedHistoryIds![element["id"] as String] = true;
}
for (var key in cachedHistories.keys.toList()) {
if (!_cachedHistoryIds!.containsKey(key)) {
cachedHistories.remove(key);
}
} }
} }
History? findSync(String id, ComicType type) { History? find(String id, ComicType type) {
if(_cachedHistory == null) { if (_cachedHistoryIds == null) {
updateCache(); updateCache();
} }
if (!_cachedHistory!.containsKey(id)) { if (!_cachedHistoryIds!.containsKey(id)) {
return null; return null;
} }
if (cachedHistories.containsKey(id)) {
return cachedHistories[id];
}
var res = _db.select(""" var res = _db.select("""
select * from history select * from history
@@ -279,6 +377,7 @@ class HistoryManager with ChangeNotifier {
} }
void close() { void close() {
isInitialized = false;
_db.dispose(); _db.dispose();
} }
} }

View File

@@ -0,0 +1,541 @@
part of "history.dart";
class ImageFavorite {
final String eid;
final String id; // 漫画id
final int ep;
final String epName;
final String sourceKey;
String imageKey;
int page;
bool? isAutoFavorite;
ImageFavorite(
this.page,
this.imageKey,
this.isAutoFavorite,
this.eid,
this.id,
this.ep,
this.sourceKey,
this.epName,
);
Map<String, dynamic> toJson() {
return {
'page': page,
'imageKey': imageKey,
'isAutoFavorite': isAutoFavorite,
'eid': eid,
'id': id,
'ep': ep,
'sourceKey': sourceKey,
'epName': epName,
};
}
ImageFavorite.fromJson(Map<String, dynamic> json)
: page = json['page'],
imageKey = json['imageKey'],
isAutoFavorite = json['isAutoFavorite'],
eid = json['eid'],
id = json['id'],
ep = json['ep'],
sourceKey = json['sourceKey'],
epName = json['epName'];
ImageFavorite copyWith({
int? page,
String? imageKey,
bool? isAutoFavorite,
String? eid,
String? id,
int? ep,
String? sourceKey,
String? epName,
}) {
return ImageFavorite(
page ?? this.page,
imageKey ?? this.imageKey,
isAutoFavorite ?? this.isAutoFavorite,
eid ?? this.eid,
id ?? this.id,
ep ?? this.ep,
sourceKey ?? this.sourceKey,
epName ?? this.epName,
);
}
@override
bool operator ==(Object other) {
return other is ImageFavorite &&
other.id == id &&
other.sourceKey == sourceKey &&
other.page == page &&
other.eid == eid &&
other.ep == ep;
}
@override
int get hashCode => Object.hash(id, sourceKey, page, eid, ep);
}
class ImageFavoritesEp {
// 小心拷贝等多章节的可能更新章节顺序
String eid;
final int ep;
int maxPage;
String epName;
List<ImageFavorite> imageFavorites;
ImageFavoritesEp(
this.eid, this.ep, this.imageFavorites, this.epName, this.maxPage);
// 是否有封面
bool get isHasFirstPage {
return imageFavorites[0].page == firstPage;
}
// 是否都有imageKey
bool get isHasImageKey {
return imageFavorites.every((e) => e.imageKey != "");
}
Map<String, dynamic> toJson() {
return {
'eid': eid,
'ep': ep,
'maxPage': maxPage,
'epName': epName,
'imageFavorites': imageFavorites.map((e) => e.toJson()).toList(),
};
}
}
class ImageFavoritesComic {
final String id;
final String title;
String subTitle;
String author;
final String sourceKey;
// 不一定是真的这本漫画的所有页数, 如果是多章节的时候
int maxPage;
List<String> tags;
List<String> translatedTags;
final DateTime time;
List<ImageFavoritesEp> imageFavoritesEp;
final Map<String, dynamic> other;
ImageFavoritesComic(
this.id,
this.imageFavoritesEp,
this.title,
this.sourceKey,
this.tags,
this.translatedTags,
this.time,
this.author,
this.other,
this.subTitle,
this.maxPage,
);
// 是否都有imageKey
bool get isAllHasImageKey {
return imageFavoritesEp
.every((e) => e.imageFavorites.every((j) => j.imageKey != ""));
}
int get maxPageFromEp {
int temp = 0;
for (var e in imageFavoritesEp) {
temp += e.maxPage;
}
return temp;
}
// 是否都有封面
bool get isAllHasFirstPage {
return imageFavoritesEp.every((e) => e.isHasFirstPage);
}
Iterable<ImageFavorite> get images sync*{
for (var e in imageFavoritesEp) {
yield* e.imageFavorites;
}
}
@override
bool operator ==(Object other) {
return other is ImageFavoritesComic &&
other.id == id &&
other.sourceKey == sourceKey;
}
@override
int get hashCode => Object.hash(id, sourceKey);
factory ImageFavoritesComic.fromRow(Row r) {
var tempImageFavoritesEp = jsonDecode(r["image_favorites_ep"]);
List<ImageFavoritesEp> finalImageFavoritesEp = [];
tempImageFavoritesEp.forEach((i) {
List<ImageFavorite> temp = [];
i["imageFavorites"].forEach((j) {
temp.add(ImageFavorite(
j["page"],
j["imageKey"],
j["isAutoFavorite"],
i["eid"],
r["id"],
i["ep"],
r["source_key"],
i["epName"],
));
});
finalImageFavoritesEp.add(ImageFavoritesEp(
i["eid"], i["ep"], temp, i["epName"], i["maxPage"] ?? 1));
});
return ImageFavoritesComic(
r["id"],
finalImageFavoritesEp,
r["title"],
r["source_key"],
r["tags"].split(","),
r["translated_tags"].split(","),
DateTime.fromMillisecondsSinceEpoch(r["time"]),
r["author"],
jsonDecode(r["other"]),
r["sub_title"],
r["max_page"],
);
}
}
class ImageFavoriteManager with ChangeNotifier {
Database get _db => HistoryManager()._db;
List<ImageFavoritesComic> get comics => getAll();
static ImageFavoriteManager? _cache;
ImageFavoriteManager._();
factory ImageFavoriteManager() => (_cache ??= ImageFavoriteManager._());
/// 检查表image_favorites是否存在, 不存在则创建
void init() {
_db.execute("CREATE TABLE IF NOT EXISTS image_favorites ("
"id TEXT,"
"title TEXT NOT NULL,"
"sub_title TEXT,"
"author TEXT,"
"tags TEXT,"
"translated_tags TEXT,"
"time int,"
"max_page int,"
"source_key TEXT NOT NULL,"
"image_favorites_ep TEXT NOT NULL,"
"other TEXT NOT NULL,"
"PRIMARY KEY (id,source_key)"
");");
}
// 做排序和去重的操作
void addOrUpdateOrDelete(ImageFavoritesComic favorite, [bool notify = true]) {
// 没有章节了就删掉
if (favorite.imageFavoritesEp.isEmpty) {
_db.execute("""
delete from image_favorites
where id == ? and source_key == ?;
""", [favorite.id, favorite.sourceKey]);
} else {
// 去重章节
List<ImageFavoritesEp> tempImageFavoritesEp = [];
for (var e in favorite.imageFavoritesEp) {
int index = tempImageFavoritesEp.indexWhere((i) {
return i.ep == e.ep;
});
// 再做一层保险, 防止出现ep为0的脏数据
if (index == -1 && e.ep > 0) {
tempImageFavoritesEp.add(e);
}
}
tempImageFavoritesEp.sort((a, b) => a.ep.compareTo(b.ep));
List<dynamic> finalImageFavoritesEp =
jsonDecode(jsonEncode(tempImageFavoritesEp));
for (var e in tempImageFavoritesEp) {
List<Map> finalImageFavorites = [];
int epIndex = tempImageFavoritesEp.indexOf(e);
for (ImageFavorite j in e.imageFavorites) {
int index =
finalImageFavorites.indexWhere((i) => i["page"] == j.page);
if (index == -1 && j.page > 0) {
// isAutoFavorite 为 null 不写入数据库, 同时只保留需要的属性, 避免增加太多重复字段在数据库里
if (j.isAutoFavorite != null) {
finalImageFavorites.add({
"page": j.page,
"imageKey": j.imageKey,
"isAutoFavorite": j.isAutoFavorite
});
} else {
finalImageFavorites.add({"page": j.page, "imageKey": j.imageKey});
}
}
}
finalImageFavorites.sort((a, b) => a["page"].compareTo(b["page"]));
finalImageFavoritesEp[epIndex]["imageFavorites"] = finalImageFavorites;
}
if (tempImageFavoritesEp.isEmpty) {
throw "Error: No ImageFavoritesEp";
}
_db.execute("""
insert or replace into image_favorites(id, title, sub_title, author, tags, translated_tags, time, max_page, source_key, image_favorites_ep, other)
values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [
favorite.id,
favorite.title,
favorite.subTitle,
favorite.author,
favorite.tags.join(","),
favorite.translatedTags.join(","),
favorite.time.millisecondsSinceEpoch,
favorite.maxPage,
favorite.sourceKey,
jsonEncode(finalImageFavoritesEp),
jsonEncode(favorite.other)
]);
}
if (notify) {
notifyListeners();
}
}
bool has(String id, String sourceKey, String eid, int page, int ep) {
var comic = find(id, sourceKey);
if (comic == null) {
return false;
}
var epIndex = comic.imageFavoritesEp.where((e) => e.eid == eid).firstOrNull;
if (epIndex == null) {
return false;
}
return epIndex.imageFavorites.any((e) => e.page == page && e.ep == ep);
}
List<ImageFavoritesComic> getAll([String? keyword]) {
ResultSet res;
if (keyword == null || keyword == "") {
res = _db.select("select * from image_favorites;");
} else {
res = _db.select(
"""
select * from image_favorites
WHERE title LIKE ?
OR sub_title LIKE ?
OR LOWER(tags) LIKE LOWER(?)
OR LOWER(translated_tags) LIKE LOWER(?)
OR author LIKE ?;
""",
['%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%'],
);
}
try {
return res.map((e) => ImageFavoritesComic.fromRow(e)).toList();
} catch (e, stackTrace) {
Log.error("Unhandled Exception", e.toString(), stackTrace);
return [];
}
}
void deleteImageFavorite(Iterable<ImageFavorite> imageFavoriteList) {
if (imageFavoriteList.isEmpty) {
return;
}
for (var i in imageFavoriteList) {
ImageFavoritesProvider.deleteFromCache(i);
}
var comics = <ImageFavoritesComic>{};
for (var i in imageFavoriteList) {
var comic = comics
.where((c) => c.id == i.id && c.sourceKey == i.sourceKey)
.firstOrNull ??
find(i.id, i.sourceKey);
if (comic == null) {
continue;
}
var ep = comic.imageFavoritesEp.firstWhereOrNull((e) => e.ep == i.ep);
if (ep == null) {
continue;
}
ep.imageFavorites.remove(i);
if (ep.imageFavorites.isEmpty) {
comic.imageFavoritesEp.remove(ep);
}
comics.add(comic);
}
for (var i in comics) {
addOrUpdateOrDelete(i, false);
}
notifyListeners();
}
int get length {
var res = _db.select("select count(*) from image_favorites;");
return res.first.values.first! as int;
}
List<ImageFavoritesComic> search(String keyword) {
if (keyword == "") {
return [];
}
return getAll(keyword);
}
static Future<ImageFavoritesComputed> computeImageFavorites() {
var token = ServicesBinding.rootIsolateToken!;
var count = ImageFavoriteManager().length;
if (count == 0) {
return Future.value(ImageFavoritesComputed([], [], [], 0));
} else if (count > 100) {
return Isolate.run(() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
await App.init();
await HistoryManager().init();
return _computeImageFavorites();
});
} else {
return Future.value(_computeImageFavorites());
}
}
static ImageFavoritesComputed _computeImageFavorites() {
const maxLength = 20;
var comics = ImageFavoriteManager().getAll();
// 去掉这些没有意义的标签
const List<String> exceptTags = [
'連載中',
'',
'translated',
'chinese',
'sole male',
'sole female',
'original',
'doujinshi',
'manga',
'multi-work series',
'mosaic censorship',
'dilf',
'bbm',
'uncensored',
'full censorship'
];
Map<String, int> tagCount = {};
Map<String, int> authorCount = {};
Map<ImageFavoritesComic, int> comicImageCount = {};
Map<ImageFavoritesComic, int> comicMaxPages = {};
int count = 0;
for (var comic in comics) {
count += comic.images.length;
for (var tag in comic.tags) {
String finalTag = tag;
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
}
if (comic.author != "") {
String finalAuthor = comic.author;
authorCount[finalAuthor] =
(authorCount[finalAuthor] ?? 0) + comic.images.length;
}
// 小于10页的漫画不统计
if (comic.maxPageFromEp < 10) {
continue;
}
comicImageCount[comic] =
(comicImageCount[comic] ?? 0) + comic.images.length;
comicMaxPages[comic] = (comicMaxPages[comic] ?? 0) + comic.maxPageFromEp;
}
// 按数量排序标签
List<String> sortedTags = tagCount.keys.toList()
..sort((a, b) => tagCount[b]!.compareTo(tagCount[a]!));
// 按数量排序作者
List<String> sortedAuthors = authorCount.keys.toList()
..sort((a, b) => authorCount[b]!.compareTo(authorCount[a]!));
// 按收藏数量排序漫画
List<MapEntry<ImageFavoritesComic, int>> sortedComicsByNum =
comicImageCount.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
validateTag(String tag) {
if (tag.startsWith("Category:")) {
return false;
}
return !exceptTags.contains(tag.split(":").last.toLowerCase()) &&
!tag.isNum;
}
return ImageFavoritesComputed(
sortedTags
.where(validateTag)
.map((tag) => TextWithCount(tag, tagCount[tag]!))
.take(maxLength)
.toList(),
sortedAuthors
.map((author) => TextWithCount(author, authorCount[author]!))
.take(maxLength)
.toList(),
sortedComicsByNum
.map((comic) => TextWithCount(comic.key.title, comic.value))
.take(maxLength)
.toList(),
count,
);
}
ImageFavoritesComic? find(String id, String sourceKey) {
var row = _db.select("""
select * from image_favorites
where id == ? and source_key == ?;
""", [id, sourceKey]);
if (row.isEmpty) {
return null;
}
return ImageFavoritesComic.fromRow(row.first);
}
}
class TextWithCount {
final String text;
final int count;
const TextWithCount(this.text, this.count);
}
class ImageFavoritesComputed {
/// 基于收藏的标签数排序
final List<TextWithCount> tags;
/// 基于收藏的作者数排序
final List<TextWithCount> authors;
/// 基于喜欢的图片数排序
final List<TextWithCount> comics;
final int count;
/// 计算后的图片收藏数据
const ImageFavoritesComputed(
this.tags,
this.authors,
this.comics,
this.count,
);
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;
}

View File

@@ -1,16 +1,48 @@
import 'dart:async' show Future, StreamController, scheduleMicrotask; import 'dart:async' show Future, StreamController, scheduleMicrotask;
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/log.dart';
abstract class BaseImageProvider<T extends BaseImageProvider<T>> abstract class BaseImageProvider<T extends BaseImageProvider<T>>
extends ImageProvider<T> { extends ImageProvider<T> {
const BaseImageProvider(); const BaseImageProvider();
static double? _effectiveScreenWidth;
static const double _normalComicImageRatio = 0.72;
static const double _minComicImageWidth = 1920 * _normalComicImageRatio;
static TargetImageSize _getTargetSize(width, height) {
if (_effectiveScreenWidth == null) {
final screens = PlatformDispatcher.instance.displays;
for (var screen in screens) {
if (screen.size.width > screen.size.height) {
_effectiveScreenWidth = max(
_effectiveScreenWidth ?? 0,
screen.size.height * _normalComicImageRatio,
);
} else {
_effectiveScreenWidth =
max(_effectiveScreenWidth ?? 0, screen.size.width);
}
}
if (_effectiveScreenWidth! < _minComicImageWidth) {
_effectiveScreenWidth = _minComicImageWidth;
}
}
if (width > _effectiveScreenWidth!) {
height = (height * _effectiveScreenWidth! / width).round();
width = _effectiveScreenWidth!.round();
}
return TargetImageSize(width: width, height: height);
}
@override @override
ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>(); final chunkEvents = StreamController<ImageChunkEvent>();
@@ -46,19 +78,18 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
while (data == null && !stop) { while (data == null && !stop) {
try { try {
if(_cache.containsKey(key.key)){ data = await load(chunkEvents, () {
data = _cache[key.key]; if (stop) {
} else { throw const _ImageLoadingStopException();
data = await load(chunkEvents); }
_checkCacheSize(); });
_cache[key.key] = data; } on _ImageLoadingStopException {
_cacheSize += data.length; rethrow;
}
} catch (e) { } catch (e) {
if(e.toString().contains("Invalid Status Code: 404")) { if (e.toString().contains("Invalid Status Code: 404")) {
rethrow; rethrow;
} }
if(e.toString().contains("Invalid Status Code: 403")) { if (e.toString().contains("Invalid Status Code: 403")) {
rethrow; rethrow;
} }
if (e.toString().contains("handshake")) { if (e.toString().contains("handshake")) {
@@ -74,23 +105,27 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
} }
if(stop) { if (stop) {
throw Exception("Image loading is stopped"); throw const _ImageLoadingStopException();
} }
if(data!.isEmpty) { if (data!.isEmpty) {
throw Exception("Empty image data"); throw Exception("Empty image data");
} }
try { try {
final buffer = await ImmutableBuffer.fromUint8List(data); final buffer = await ImmutableBuffer.fromUint8List(data);
return await decode(buffer); return await decode(
buffer,
getTargetSize: enableResize ? _getTargetSize : null,
);
} catch (e) { } catch (e) {
await CacheManager().delete(this.key); await CacheManager().delete(this.key);
if (data.length < 2 * 1024) { if (data.length < 2 * 1024) {
// data is too short, it's likely that the data is text, not image // data is too short, it's likely that the data is text, not image
try { try {
var text = const Utf8Codec(allowMalformed: false).decoder.convert(data); var text =
const Utf8Codec(allowMalformed: false).decoder.convert(data);
throw Exception("Expected image data, but got text: $text"); throw Exception("Expected image data, but got text: $text");
} catch (e) { } catch (e) {
// ignore // ignore
@@ -98,41 +133,23 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
rethrow; rethrow;
} }
} catch (e) { } on _ImageLoadingStopException {
rethrow;
} catch (e, s) {
scheduleMicrotask(() { scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key); PaintingBinding.instance.imageCache.evict(key);
}); });
Log.error("Image Loading", e, s);
rethrow; rethrow;
} finally { } finally {
chunkEvents.close(); chunkEvents.close();
} }
} }
static final _cache = LinkedHashMap<String, Uint8List>(); Future<Uint8List> load(
StreamController<ImageChunkEvent> chunkEvents,
static var _cacheSize = 0; void Function() checkStop,
);
static var _cacheSizeLimit = 50 * 1024 * 1024;
static void _checkCacheSize(){
while (_cacheSize > _cacheSizeLimit){
var firstKey = _cache.keys.first;
_cacheSize -= _cache[firstKey]!.length;
_cache.remove(firstKey);
}
}
static void clearCache(){
_cache.clear();
_cacheSize = 0;
}
static void setCacheSizeLimit(int size){
_cacheSizeLimit = size;
_checkCacheSize();
}
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents);
String get key; String get key;
@@ -148,6 +165,12 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
String toString() { String toString() {
return "$runtimeType($key)"; return "$runtimeType($key)";
} }
bool get enableResize => false;
} }
typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List); typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List);
class _ImageLoadingStopException implements Exception {
const _ImageLoadingStopException();
}

View File

@@ -1,14 +1,17 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart'; import 'base_image_provider.dart';
import 'cached_image.dart' as image_provider; import 'cached_image.dart' as image_provider;
class CachedImageProvider class CachedImageProvider
extends BaseImageProvider<image_provider.CachedImageProvider> { extends BaseImageProvider<image_provider.CachedImageProvider> {
/// Image provider for normal image. /// Image provider for normal image.
const CachedImageProvider(this.url, {this.headers, this.sourceKey}); ///
/// [url] is the url of the image. Local file path is also supported.
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
final String url; final String url;
@@ -16,18 +19,39 @@ class CachedImageProvider
final String? sourceKey; final String? sourceKey;
final String? cid;
static int loadingCount = 0;
static const _kMaxLoadingCount = 8;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) { while(loadingCount > _kMaxLoadingCount) {
chunkEvents.add(ImageChunkEvent( await Future.delayed(const Duration(milliseconds: 100));
cumulativeBytesLoaded: progress.currentBytes, checkStop();
expectedTotalBytes: progress.totalBytes, }
)); loadingCount++;
if(progress.imageBytes != null) { try {
return progress.imageBytes!; if(url.startsWith("file://")) {
} var file = File(url.substring(7));
return file.readAsBytes();
}
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
checkStop();
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,
));
if(progress.imageBytes != null) {
return progress.imageBytes!;
}
}
throw "Error: Empty response body.";
}
finally {
loadingCount--;
} }
throw "Error: Empty response body.";
} }
@override @override
@@ -36,5 +60,5 @@ class CachedImageProvider
} }
@override @override
String get key => url; String get key => url + (sourceKey ?? "") + (cid ?? "");
} }

View File

@@ -0,0 +1,59 @@
import 'dart:async' show Future;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/images.dart';
import '../history.dart';
import 'base_image_provider.dart';
import 'history_image_provider.dart' as image_provider;
class HistoryImageProvider
extends BaseImageProvider<image_provider.HistoryImageProvider> {
/// Image provider for normal image.
///
/// [url] is the url of the image. Local file path is also supported.
const HistoryImageProvider(this.history);
final History history;
@override
Future<Uint8List> load(chunkEvents, checkStop) async {
var url = history.cover;
if (!url.contains('/')) {
var localComic = LocalManager().find(history.id, history.type);
if (localComic != null) {
return localComic.coverFile.readAsBytes();
}
var comicSource =
history.type.comicSource ?? (throw "Comic source not found.");
var comic = await comicSource.loadComicInfo!(history.id);
checkStop();
url = comic.data.cover;
history.cover = url;
HistoryManager().addHistory(history);
}
await for (var progress in ImageDownloader.loadThumbnail(
url,
history.type.sourceKey,
history.id,
)) {
checkStop();
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,
));
if (progress.imageBytes != null) {
return progress.imageBytes!;
}
}
throw "Error: Empty response body.";
}
@override
Future<HistoryImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
String get key => "history${history.id}${history.type.value}";
}

View File

@@ -0,0 +1,155 @@
import 'dart:async' show Future, StreamController;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import '../history.dart';
import 'base_image_provider.dart';
import 'image_favorites_provider.dart' as image_provider;
class ImageFavoritesProvider
extends BaseImageProvider<image_provider.ImageFavoritesProvider> {
/// Image provider for imageFavorites
const ImageFavoritesProvider(this.imageFavorite);
final ImageFavorite imageFavorite;
int get page => imageFavorite.page;
String get sourceKey => imageFavorite.sourceKey;
String get cid => imageFavorite.id;
String get eid => imageFavorite.eid;
@override
Future<Uint8List> load(
StreamController<ImageChunkEvent>? chunkEvents,
void Function()? checkStop,
) async {
var imageKey = imageFavorite.imageKey;
var localImage = await getImageFromLocal();
checkStop?.call();
if (localImage != null) {
return localImage;
}
var cacheImage = await readFromCache();
checkStop?.call();
if (cacheImage != null) {
return cacheImage;
}
var gotImageKey = false;
if (imageKey == "") {
imageKey = await getImageKey();
checkStop?.call();
gotImageKey = true;
}
Uint8List image;
try {
image = await getImageFromNetwork(imageKey, chunkEvents, checkStop);
} catch (e) {
if (gotImageKey) {
rethrow;
} else {
imageKey = await getImageKey();
image = await getImageFromNetwork(imageKey, chunkEvents, checkStop);
}
}
await writeToCache(image);
return image;
}
Future<void> writeToCache(Uint8List image) async {
var fileName = md5.convert(key.codeUnits).toString();
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
if (!file.existsSync()) {
file.createSync(recursive: true);
}
await file.writeAsBytes(image);
}
Future<Uint8List?> readFromCache() async {
var fileName = md5.convert(key.codeUnits).toString();
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
if (!file.existsSync()) {
return null;
}
return await file.readAsBytes();
}
/// Delete a image favorite cache
static Future<void> deleteFromCache(ImageFavorite imageFavorite) async {
var fileName = md5.convert(imageFavorite.imageKey.codeUnits).toString();
var file = File(FilePath.join(App.cachePath, 'image_favorites', fileName));
if (file.existsSync()) {
await file.delete();
}
}
Future<Uint8List?> getImageFromLocal() async {
var localComic =
LocalManager().find(sourceKey, ComicType.fromKey(sourceKey));
if (localComic == null) {
return null;
}
var epIndex = localComic.chapters?.ids.toList().indexOf(eid) ?? -1;
if (epIndex == -1 && localComic.hasChapters) {
return null;
}
var images = await LocalManager().getImages(
sourceKey,
ComicType.fromKey(sourceKey),
epIndex,
);
var data = await File(images[page]).readAsBytes();
return data;
}
Future<Uint8List> getImageFromNetwork(
String imageKey,
StreamController<ImageChunkEvent>? chunkEvents,
void Function()? checkStop,
) async {
await for (var progress
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
checkStop?.call();
if (chunkEvents != null) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,
));
}
if (progress.imageBytes != null) {
return progress.imageBytes!;
}
}
throw "Error: Empty response body.";
}
Future<String> getImageKey() async {
String sourceKey = imageFavorite.sourceKey;
String cid = imageFavorite.id;
String eid = imageFavorite.eid;
var page = imageFavorite.page;
var comicSource = ComicSource.find(sourceKey);
if (comicSource == null) {
throw "Error: Comic source not found.";
}
var res = await comicSource.loadComicPages!(cid, eid);
return res.data[page - 1];
}
@override
Future<ImageFavoritesProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
String get key =>
"ImageFavorites ${imageFavorite.imageKey}@${imageFavorite.sourceKey}@${imageFavorite.id}@${imageFavorite.eid}";
}

View File

@@ -0,0 +1,67 @@
import 'dart:async' show Future;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart';
import 'local_comic_image.dart' as image_provider;
class LocalComicImageProvider
extends BaseImageProvider<image_provider.LocalComicImageProvider> {
/// Image provider for normal image.
///
/// [url] is the url of the image. Local file path is also supported.
const LocalComicImageProvider(this.comic);
final LocalComic comic;
@override
Future<Uint8List> load(chunkEvents, checkStop) async {
File? file = comic.coverFile;
if(! await file.exists()) {
file = null;
var dir = Directory(comic.directory);
if (! await dir.exists()) {
throw "Error: Comic not found.";
}
Directory? firstDir;
await for (var entity in dir.list()) {
if(entity is File) {
if(["jpg", "jpeg", "png", "webp", "gif", "jpe", "jpeg"].contains(entity.extension)) {
file = entity;
break;
}
} else if(entity is Directory) {
firstDir ??= entity;
}
}
if(file == null && firstDir != null) {
await for (var entity in firstDir.list()) {
if(entity is File) {
if(["jpg", "jpeg", "png", "webp", "gif", "jpe", "jpeg"].contains(entity.extension)) {
file = entity;
break;
}
}
}
}
}
if(file == null) {
throw "Error: Cover not found.";
}
checkStop();
var data = await file.readAsBytes();
if(data.isEmpty) {
throw "Exception: Empty file(${file.path}).";
}
return data;
}
@override
Future<LocalComicImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
String get key => "local${comic.id}${comic.comicType.value}";
}

View File

@@ -1,4 +1,4 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -22,13 +22,13 @@ class LocalFavoriteImageProvider
static void delete(String id, int intKey) { static void delete(String id, int intKey) {
var fileName = (id + intKey.toString()).hashCode.toString(); var fileName = (id + intKey.toString()).hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName)); var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
if(file.existsSync()) { if (file.existsSync()) {
file.delete(); file.delete();
} }
} }
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
var sourceKey = ComicSource.fromIntKey(intKey)?.key; var sourceKey = ComicSource.fromIntKey(intKey)?.key;
var fileName = key.hashCode.toString(); var fileName = key.hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName)); var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
@@ -37,12 +37,14 @@ class LocalFavoriteImageProvider
} else { } else {
await file.create(recursive: true); await file.create(recursive: true);
} }
checkStop();
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
checkStop();
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes, expectedTotalBytes: progress.totalBytes,
)); ));
if(progress.imageBytes != null) { if (progress.imageBytes != null) {
var data = progress.imageBytes!; var data = progress.imageBytes!;
await file.writeAsBytes(data); await file.writeAsBytes(data);
return data; return data;
@@ -52,7 +54,8 @@ class LocalFavoriteImageProvider
} }
@override @override
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFavoriteImageProvider> obtainKey(
ImageConfiguration configuration) {
return SynchronousFuture(this); return SynchronousFuture(this);
} }

View File

@@ -1,14 +1,18 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/js_engine.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart'; import 'base_image_provider.dart';
import 'reader_image.dart' as image_provider; import 'reader_image.dart' as image_provider;
import 'package:venera/foundation/appdata.dart';
class ReaderImageProvider class ReaderImageProvider
extends BaseImageProvider<image_provider.ReaderImageProvider> { extends BaseImageProvider<image_provider.ReaderImageProvider> {
/// Image provider for normal image. /// Image provider for normal image.
const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid); const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid, this.page);
final String imageKey; final String imageKey;
@@ -18,19 +22,95 @@ class ReaderImageProvider
final String eid; final String eid;
final int page;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
await for (var event Uint8List? imageBytes;
if (imageKey.startsWith('file://')) {
var file = File(imageKey);
if (await file.exists()) {
imageBytes = await file.readAsBytes();
} else {
throw "Error: File not found.";
}
} else {
await for (var event
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) { in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
chunkEvents.add(ImageChunkEvent( checkStop();
cumulativeBytesLoaded: event.currentBytes, chunkEvents.add(ImageChunkEvent(
expectedTotalBytes: event.totalBytes, cumulativeBytesLoaded: event.currentBytes,
)); expectedTotalBytes: event.totalBytes,
if (event.imageBytes != null) { ));
return event.imageBytes!; if (event.imageBytes != null) {
imageBytes = event.imageBytes;
break;
}
} }
} }
throw "Error: Empty response body."; if (imageBytes == null) {
throw "Error: Empty response body.";
}
if (appdata.settings['enableCustomImageProcessing']) {
var script = appdata.settings['customImageProcessing'].toString();
if (!script.contains('async function processImage')) {
return imageBytes;
}
var func = JsEngine().runCode('''
(() => {
$script
return processImage;
})()
''');
if (func is JSInvokable) {
var autoFreeFunc = JSAutoFreeFunction(func);
var result = autoFreeFunc([imageBytes, cid, eid, page, sourceKey]);
if (result is Uint8List) {
imageBytes = result;
} else if (result is Future) {
var futureResult = await result;
if (futureResult is Uint8List) {
imageBytes = futureResult;
}
} else if (result is Map) {
var image = result['image'];
if (image is Uint8List) {
imageBytes = image;
} else if (image is Future) {
JSAutoFreeFunction? onCancel;
if (result['onCancel'] is JSInvokable) {
onCancel = JSAutoFreeFunction(result['onCancel']);
}
if (onCancel == null) {
var futureImage = await image;
if (futureImage is Uint8List) {
imageBytes = futureImage;
}
} else {
dynamic futureImage;
image.then((value) {
futureImage = value;
futureImage ??= Uint8List(0);
});
while (futureImage == null) {
try {
checkStop();
}
catch(e) {
onCancel([]);
rethrow;
}
await Future.delayed(Duration(milliseconds: 50));
}
if (futureImage is Uint8List) {
imageBytes = futureImage;
}
}
}
}
}
}
return imageBytes!;
} }
@override @override
@@ -40,4 +120,7 @@ class ReaderImageProvider
@override @override
String get key => "$imageKey@$sourceKey@$cid@$eid"; String get key => "$imageKey@$sourceKey@$cid@$eid";
@override
bool get enableResize => true;
} }

View File

@@ -2,6 +2,8 @@ import 'dart:convert';
import 'dart:io'; 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: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;
@@ -19,8 +21,11 @@ import 'package:pointycastle/block/modes/cfb.dart';
import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ecb.dart';
import 'package:pointycastle/block/modes/ofb.dart'; import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:venera/components/js_ui.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/utils/init.dart';
import 'comic_source/comic_source.dart'; import 'comic_source/comic_source.dart';
import 'consts.dart'; import 'consts.dart';
@@ -37,7 +42,7 @@ class JavaScriptRuntimeException implements Exception {
} }
} }
class JsEngine with _JSEngineApi { class JsEngine with _JSEngineApi, JsUiApi, Init {
factory JsEngine() => _cache ?? (_cache = JsEngine._create()); factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache; static JsEngine? _cache;
@@ -56,7 +61,14 @@ class JsEngine with _JSEngineApi {
JsEngine().init(); JsEngine().init();
} }
Future<void> init() async { void resetDio() {
_dio = AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true));
}
@override
@protected
Future<void> doInit() async {
if (!_closed) { if (!_closed) {
return; return;
} }
@@ -70,6 +82,7 @@ class JsEngine with _JSEngineApi {
var setGlobalFunc = var setGlobalFunc =
_engine!.evaluate("(key, value) => { this[key] = value; }"); _engine!.evaluate("(key, value) => { this[key] = value; }");
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]); (setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
setGlobalFunc(["appVersion", App.version]);
setGlobalFunc.free(); setGlobalFunc.free();
var jsInit = await rootBundle.load("assets/init.js"); var jsInit = await rootBundle.load("assets/init.js");
_engine! _engine!
@@ -85,85 +98,71 @@ class JsEngine with _JSEngineApi {
String method = message["method"] as String; String method = message["method"] as String;
switch (method) { switch (method) {
case "log": case "log":
{ String level = message["level"];
String level = message["level"]; Log.addLog(
Log.addLog( switch (level) {
switch (level) { "error" => LogLevel.error,
"error" => LogLevel.error, "warning" => LogLevel.warning,
"warning" => LogLevel.warning, "info" => LogLevel.info,
"info" => LogLevel.info, _ => LogLevel.warning
_ => LogLevel.warning },
}, message["title"],
message["title"], message["content"].toString());
message["content"].toString());
}
case 'load_data': case 'load_data':
{ String key = message["key"];
String key = message["key"]; String dataKey = message["data_key"];
String dataKey = message["data_key"]; return ComicSource.find(key)?.data[dataKey];
return ComicSource.find(key)?.data[dataKey];
}
case 'save_data': case 'save_data':
{ String key = message["key"];
String key = message["key"]; String dataKey = message["data_key"];
String dataKey = message["data_key"]; if (dataKey == 'setting') {
if (dataKey == 'setting') { throw "setting is not allowed to be saved";
throw "setting is not allowed to be saved";
}
var data = message["data"];
var source = ComicSource.find(key)!;
source.data[dataKey] = data;
source.saveData();
} }
var data = message["data"];
var source = ComicSource.find(key)!;
source.data[dataKey] = data;
source.saveData();
case 'delete_data': case 'delete_data':
{ String key = message["key"];
String key = message["key"]; String dataKey = message["data_key"];
String dataKey = message["data_key"]; var source = ComicSource.find(key);
var source = ComicSource.find(key); source?.data.remove(dataKey);
source?.data.remove(dataKey); source?.saveData();
source?.saveData();
}
case 'http': case 'http':
{ return _http(Map.from(message));
return _http(Map.from(message));
}
case 'html': case 'html':
{ return handleHtmlCallback(Map.from(message));
return handleHtmlCallback(Map.from(message));
}
case 'convert': case 'convert':
{ return _convert(Map.from(message));
return _convert(Map.from(message));
}
case "random": case "random":
{ return _random(
return _random( message["min"] ?? 0,
message["min"] ?? 0, message["max"] ?? 1,
message["max"] ?? 1, message["type"],
message["type"], );
);
}
case "cookie": case "cookie":
{ return handleCookieCallback(Map.from(message));
return handleCookieCallback(Map.from(message));
}
case "uuid": case "uuid":
{ return const Uuid().v1();
return const Uuid().v1();
}
case "load_setting": case "load_setting":
{ String key = message["key"];
String key = message["key"]; String settingKey = message["setting_key"];
String settingKey = message["setting_key"]; var source = ComicSource.find(key)!;
var source = ComicSource.find(key)!; return source.data["settings"]?[settingKey] ??
return source.data["settings"]?[settingKey] ?? source.settings?[settingKey]!['default'] ??
source.settings?[settingKey]['default'] ?? (throw "Setting not found: $settingKey");
(throw "Setting not found: $settingKey");
}
case "isLogged": case "isLogged":
{ return ComicSource.find(message["key"])!.isLogged;
return ComicSource.find(message["key"])!.isLogged; // temporary solution for [setTimeout] function
} // TODO: implement [setTimeout] in quickjs project
case "delay":
return Future.delayed(Duration(milliseconds: message["time"]));
case "UI":
return handleUIMessage(Map.from(message));
case "getLocale":
return "${App.locale.languageCode}_${App.locale.countryCode}";
case "getPlatform":
return Platform.operatingSystem;
} }
} }
return null; return null;
@@ -182,7 +181,24 @@ class JsEngine with _JSEngineApi {
if (headers["user-agent"] == null && headers["User-Agent"] == null) { if (headers["user-agent"] == null && headers["User-Agent"] == null) {
headers["User-Agent"] = webUA; headers["User-Agent"] = webUA;
} }
response = await _dio!.request(req["url"], var dio = _dio;
if (headers['http_client'] == "dart:io") {
dio = Dio(BaseOptions(
responseType: ResponseType.plain,
validateStatus: (status) => true,
));
var proxy = await AppDio.getProxy();
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
);
dio.interceptors
.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
dio.interceptors.add(LogInterceptor());
}
response = await dio!.request(req["url"],
data: req["data"], data: req["data"],
options: Options( options: Options(
method: req['http_method'], method: req['http_method'],
@@ -663,3 +679,21 @@ class DocumentWrapper {
return elements.length - 1; return elements.length - 1;
} }
} }
class JSAutoFreeFunction {
final JSInvokable func;
/// Automatically free the function when it's not used anymore
JSAutoFreeFunction(this.func) {
func.dup();
finalizer.attach(this, func);
}
dynamic call(List<dynamic> args) {
return func(args);
}
static final finalizer = Finalizer<JSInvokable>((func) {
func.destroy();
});
}

View File

@@ -5,9 +5,10 @@ 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';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.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';
@@ -32,7 +33,9 @@ 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;
/// relative path to the cover image /// relative path to the cover image
@override @override
@@ -63,25 +66,27 @@ 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)),
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int); createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
File get coverFile => File(FilePath.join( File get coverFile => File(FilePath.join(
LocalManager().path, baseDir,
directory,
cover, cover,
)); ));
String get baseDir => (directory.contains('/') || directory.contains('\\'))
? directory
: FilePath.join(LocalManager().path, directory);
@override @override
String get description => ""; String get description => "";
@override @override
String get sourceKey => comicType == ComicType.local String get sourceKey =>
? "local" comicType == ComicType.local ? "local" : comicType.sourceKey;
: comicType.sourceKey;
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@@ -93,14 +98,15 @@ class LocalComic with HistoryMixin implements Comic {
"tags": tags, "tags": tags,
"description": description, "description": description,
"sourceKey": sourceKey, "sourceKey": sourceKey,
"chapters": chapters?.toJson(),
}; };
} }
@override @override
int? get maxPage => null; int? get maxPage => null;
void read() async { void read() {
var history = await HistoryManager().find(id, comicType); var history = HistoryManager().find(id, comicType);
App.rootContext.to( App.rootContext.to(
() => Reader( () => Reader(
type: comicType, type: comicType,
@@ -109,11 +115,15 @@ class LocalComic with HistoryMixin implements Comic {
chapters: chapters, chapters: chapters,
initialChapter: history?.ep, initialChapter: history?.ep,
initialPage: history?.page, initialPage: history?.page,
history: history ?? History.fromModel( initialChapterGroup: history?.group,
model: this, history: history ??
ep: 0, History.fromModel(
page: 0, model: this,
), ep: 0,
page: 0,
),
author: subtitle,
tags: tags,
), ),
); );
} }
@@ -148,6 +158,17 @@ class LocalManager with ChangeNotifier {
/// path to the directory where all the comics are stored /// path to the directory where all the comics are stored
late String path; late String path;
Directory get directory => Directory(path);
void _checkNoMedia() {
if (App.isAndroid) {
var file = File(FilePath.join(path, '.nomedia'));
if (!file.existsSync()) {
file.createSync();
}
}
}
// return error message if failed // return error message if failed
Future<String?> setNewPath(String newPath) async { Future<String?> setNewPath(String newPath) async {
var newDir = Directory(newPath); var newDir = Directory(newPath);
@@ -158,19 +179,56 @@ class LocalManager with ChangeNotifier {
return "Directory is not empty"; return "Directory is not empty";
} }
try { try {
await copyDirectory( await copyDirectoryIsolate(
Directory(path), directory,
newDir, newDir,
); );
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(path); await File(FilePath.join(App.dataPath, 'local_path'))
} catch (e) { .writeAsString(newPath);
} catch (e, s) {
Log.error("IO", e, s);
return e.toString(); return e.toString();
} }
await Directory(path).deleteIgnoreError(recursive:true); await directory.deleteContents(recursive: true);
path = newPath; path = newPath;
_checkNoMedia();
return null; return null;
} }
Future<String> findDefaultPath() async {
if (App.isAndroid) {
var external = await getExternalStorageDirectories();
if (external != null && external.isNotEmpty) {
return FilePath.join(external.first.path, 'local');
} else {
return FilePath.join(App.dataPath, 'local');
}
} else if (App.isIOS) {
var oldPath = FilePath.join(App.dataPath, 'local');
if (Directory(oldPath).existsSync() &&
Directory(oldPath).listSync().isNotEmpty) {
return oldPath;
} else {
var directory = await getApplicationDocumentsDirectory();
return FilePath.join(directory.path, 'local');
}
} else {
return FilePath.join(App.dataPath, 'local');
}
}
Future<void> _checkPathValidation() async {
var testFile = File(FilePath.join(path, 'venera_test'));
try {
testFile.createSync();
testFile.deleteSync();
} catch (e) {
Log.error("IO",
"Failed to create test file in local path: $e\nUsing default path instead.");
path = await findDefaultPath();
}
}
Future<void> init() async { Future<void> init() async {
_db = sqlite3.open( _db = sqlite3.open(
'${App.dataPath}/local.db', '${App.dataPath}/local.db',
@@ -192,21 +250,22 @@ class LocalManager with ChangeNotifier {
'''); ''');
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) { if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync(); path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
} else { if (!directory.existsSync()) {
if (App.isAndroid) { path = await findDefaultPath();
var external = await getExternalStorageDirectories();
if (external != null && external.isNotEmpty) {
path = FilePath.join(external.first.path, 'local');
} else {
path = FilePath.join(App.dataPath, 'local');
}
} else {
path = FilePath.join(App.dataPath, 'local');
} }
} else {
path = await findDefaultPath();
} }
if (!Directory(path).existsSync()) { try {
await Directory(path).create(); if (!directory.existsSync()) {
await directory.create();
}
} catch (e, s) {
Log.error("IO", "Failed to create local folder: $e", s);
} }
_checkPathValidation();
_checkNoMedia();
await ComicSourceManager().ensureInit();
restoreDownloadingTasks(); restoreDownloadingTasks();
} }
@@ -216,7 +275,8 @@ class LocalManager with ChangeNotifier {
SELECT id FROM comics WHERE comic_type = ? SELECT id FROM comics WHERE comic_type = ?
ORDER BY CAST(id AS INTEGER) DESC ORDER BY CAST(id AS INTEGER) DESC
LIMIT 1; LIMIT 1;
''', [type.value], ''',
[type.value],
); );
if (res.isEmpty) { if (res.isEmpty) {
return '1'; return '1';
@@ -326,22 +386,26 @@ class LocalManager with ChangeNotifier {
} }
Future<List<String>> getImages(String id, ComicType type, Object ep) async { Future<List<String>> getImages(String id, ComicType type, Object ep) async {
if(ep is! String && ep is! int) { if (ep is! String && ep is! int) {
throw "Invalid ep"; throw "Invalid ep";
} }
var comic = find(id, type) ?? (throw "Comic Not Found"); var comic = find(id, type) ?? (throw "Comic Not Found");
var directory = Directory(FilePath.join(path, comic.directory)); var directory = Directory(comic.baseDir);
if (comic.chapters != null) { if (comic.hasChapters) {
var cid = ep is int var cid =
? comic.chapters!.keys.elementAt(ep - 1) ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
: (ep as String);
directory = Directory(FilePath.join(directory.path, cid)); directory = Directory(FilePath.join(directory.path, cid));
} }
var files = <File>[]; var files = <File>[];
await for (var entity in directory.list()) { await for (var entity in directory.list()) {
if (entity is File) { if (entity is File) {
if (entity.absolute.path.replaceFirst(path, '').substring(1) == // Do not exclude comic.cover, since it may be the first page of the chapter.
comic.cover) { // A file with name starting with 'cover.' is not a comic page.
if (entity.name.startsWith('cover.')) {
continue;
}
//Hidden file in some file system
if (entity.name.startsWith('.')) {
continue; continue;
} }
files.add(entity); files.add(entity);
@@ -358,12 +422,12 @@ class LocalManager with ChangeNotifier {
return files.map((e) => "file://${e.path}").toList(); return files.map((e) => "file://${e.path}").toList();
} }
Future<bool> isDownloaded(String id, ComicType type, int ep) async { bool isDownloaded(String id, ComicType type, [int? ep]) {
var comic = find(id, type); var comic = find(id, type);
if (comic == null) return false; if (comic == null) return false;
if (comic.chapters == null) return true; if (comic.chapters == null || ep == null) return true;
return comic.downloadedChapters return comic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(ep-1)); .contains(comic.chapters!.ids.elementAt(ep - 1));
} }
List<DownloadTask> downloadingTasks = []; List<DownloadTask> downloadingTasks = [];
@@ -420,12 +484,17 @@ class LocalManager with ChangeNotifier {
void restoreDownloadingTasks() { void restoreDownloadingTasks() {
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json')); var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
if (file.existsSync()) { if (file.existsSync()) {
var tasks = jsonDecode(file.readAsStringSync()); try {
for (var e in tasks) { var tasks = jsonDecode(file.readAsStringSync());
var task = DownloadTask.fromJson(e); for (var e in tasks) {
if (task != null) { var task = DownloadTask.fromJson(e);
downloadingTasks.add(task); if (task != null) {
downloadingTasks.add(task);
}
} }
} catch (e) {
file.delete();
Log.error("LocalManager", "Failed to restore downloading tasks: $e");
} }
} }
} }
@@ -437,9 +506,21 @@ class LocalManager with ChangeNotifier {
downloadingTasks.first.resume(); downloadingTasks.first.resume();
} }
void deleteComic(LocalComic c) { void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
var dir = Directory(FilePath.join(path, c.directory)); if (removeFileOnDisk) {
dir.deleteIgnoreError(recursive: true); var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true);
}
// Deleting a local comic means that it's no longer available, thus both favorite and history should be deleted.
if (c.comicType == ComicType.local) {
if (HistoryManager().find(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType);
}
var folders = LocalFavoritesManager().find(c.id, c.comicType);
for (var f in folders) {
LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType);
}
}
remove(c.id, c.comicType); remove(c.id, c.comicType);
notifyListeners(); notifyListeners();
} }

View File

@@ -32,11 +32,11 @@ class Log {
static const String? logFile = null; static const String? logFile = null;
static void printWarning(String text) { static void printWarning(String text) {
print('\x1B[33m$text\x1B[0m'); debugPrint('\x1B[33m$text\x1B[0m');
} }
static void printError(String text) { static void printError(String text) {
print('\x1B[31m$text\x1B[0m'); debugPrint('\x1B[31m$text\x1B[0m');
} }
static void addLog(LogLevel level, String title, String content) { static void addLog(LogLevel level, String title, String content) {
@@ -44,15 +44,15 @@ class Log {
content = "${content.substring(0, maxLogLength)}..."; content = "${content.substring(0, maxLogLength)}...";
} }
if (kDebugMode) { switch (level) {
switch (level) { case LogLevel.error:
case LogLevel.error: printError(content);
printError(content); case LogLevel.warning:
case LogLevel.warning: printWarning(content);
printWarning(content); case LogLevel.info:
case LogLevel.info: if(kDebugMode) {
print(content); debugPrint(content);
} }
} }
var newLog = LogItem(level, title, content); var newLog = LogItem(level, title, content);
@@ -82,11 +82,12 @@ class Log {
addLog(LogLevel.warning, title, content); addLog(LogLevel.warning, title, content);
} }
static error(String title, String content, [Object? stackTrace]) { static error(String title, Object content, [Object? stackTrace]) {
var info = content.toString();
if(stackTrace != null) { if(stackTrace != null) {
content += "\n${stackTrace.toString()}"; info += "\n${stackTrace.toString()}";
} }
addLog(LogLevel.error, title, content); addLog(LogLevel.error, title, info);
} }
static void clear() => _logs.clear(); static void clear() => _logs.clear();

View File

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

View File

@@ -112,3 +112,9 @@ extension StyledText on TextStyle {
TextStyle withColor(Color? color) => copyWith(color: color); TextStyle withColor(Color? color) => copyWith(color: color);
} }
extension ColorExt on Color {
Color toOpacity(double opacity) {
return withValues(alpha: opacity);
}
}

View File

@@ -1,26 +1,93 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_saf/flutter_saf.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/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/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';
Future<void> init() async { extension _FutureInit<T> on Future<T> {
await AppTranslation.init(); /// Prevent unhandled exception
await appdata.init(); ///
await App.init(); /// A unhandled exception occurred in init() will cause the app to crash.
await HistoryManager().init(); Future<void> wait() async {
await LocalFavoritesManager().init(); try {
SingleInstanceCookieJar("${App.dataPath}/cookie.db"); await this;
await JsEngine().init(); } catch (e, s) {
await ComicSource.init(); Log.error("init", "$e\n$s");
await LocalManager().init(); }
await TagsTranslation.readData(); }
CacheManager().setLimitSize(appdata.settings['cacheSize']); }
Future<void> init() async {
await App.init().wait();
await SingleInstanceCookieJar.createInstance();
var futures = [
Rhttp.init(),
App.initComponents(),
SAFTaskWorker().init().wait(),
AppTranslation.init().wait(),
TagsTranslation.readData().wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
];
await Future.wait(futures);
CacheManager().setLimitSize(appdata.settings['cacheSize']);
_checkOldConfigs();
if (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
}
void _checkOldConfigs() {
if (appdata.settings['searchSources'] == null) {
appdata.settings['searchSources'] = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
}
if (appdata.implicitData['webdavAutoSync'] == null) {
var webdavConfig = appdata.settings['webdav'];
if (webdavConfig is List &&
webdavConfig.length == 3 &&
webdavConfig.whereType<String>().length == 3) {
appdata.implicitData['webdavAutoSync'] = true;
} else {
appdata.implicitData['webdavAutoSync'] = false;
}
appdata.writeImplicitData();
}
}
Future<void> _checkAppUpdates() async {
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) {
return;
}
appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
}
}
void checkUpdates() {
_checkAppUpdates();
FollowUpdatesService.initChecker();
} }

View File

@@ -1,15 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flex_seed_scheme/flex_seed_scheme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/pages/auth_page.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/main_page.dart'; import 'package:venera/pages/main_page.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/app_links.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'components/components.dart'; import 'components/components.dart';
import 'components/window_frame.dart'; import 'components/window_frame.dart';
@@ -18,43 +17,35 @@ import 'foundation/appdata.dart';
import 'init.dart'; import 'init.dart';
void main(List<String> args) { void main(List<String> args) {
if (runWebViewTitleBarWidget(args)) { if (runWebViewTitleBarWidget(args)) return;
return; overrideIO(() {
} runZonedGuarded(() async {
runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized();
await Rhttp.init(); await init();
WidgetsFlutterBinding.ensureInitialized(); runApp(const MyApp());
await init(); if (App.isDesktop) {
if (App.isAndroid) { await windowManager.ensureInitialized();
handleLinks(); windowManager.waitUntilReadyToShow().then((_) async {
} await windowManager.setTitleBarStyle(
FlutterError.onError = (details) { TitleBarStyle.hidden,
Log.error( windowButtonVisibility: App.isMacOS,
"Unhandled Exception", "${details.exception}\n${details.stack}"); );
}; if (App.isLinux) {
runApp(const MyApp()); await windowManager.setBackgroundColor(Colors.transparent);
if (App.isDesktop) { }
await windowManager.ensureInitialized(); await windowManager.setMinimumSize(const Size(500, 600));
windowManager.waitUntilReadyToShow().then((_) async { if (!App.isLinux) {
await windowManager.setTitleBarStyle( // https://github.com/leanflutter/window_manager/issues/460
TitleBarStyle.hidden, var placement = await WindowPlacement.loadFromFile();
windowButtonVisibility: App.isMacOS, await placement.applyToWindow();
); await windowManager.show();
if (App.isLinux) { WindowPlacement.loop();
await windowManager.setBackgroundColor(Colors.transparent); }
} });
await windowManager.setMinimumSize(const Size(500, 600)); }
if (!App.isLinux) { }, (error, stack) {
// https://github.com/leanflutter/window_manager/issues/460 Log.error("Unhandled Exception", error, stack);
var placement = await WindowPlacement.loadFromFile(); });
await placement.applyToWindow();
await windowManager.show();
WindowPlacement.loop();
}
});
}
}, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack");
}); });
} }
@@ -65,15 +56,59 @@ class MyApp extends StatefulWidget {
State<MyApp> createState() => _MyAppState(); State<MyApp> createState() => _MyAppState();
} }
class _MyAppState extends State<MyApp> { class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
checkUpdates();
App.registerForceRebuild(forceRebuild); App.registerForceRebuild(forceRebuild);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addObserver(this);
checkUpdates();
super.initState(); super.initState();
} }
bool isAuthPageActive = false;
OverlayEntry? hideContentOverlay;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!App.isMobile || !appdata.settings['authorizationRequired']) {
return;
}
if (state == AppLifecycleState.inactive && hideContentOverlay == null) {
hideContentOverlay = OverlayEntry(
builder: (context) {
return Positioned.fill(
child: Container(
width: double.infinity,
height: double.infinity,
color: App.rootContext.colorScheme.surface,
),
);
},
);
Overlay.of(App.rootContext).insert(hideContentOverlay!);
} else if (hideContentOverlay != null &&
state == AppLifecycleState.resumed) {
hideContentOverlay!.remove();
hideContentOverlay = null;
}
if (state == AppLifecycleState.hidden &&
!isAuthPageActive &&
!IO.isSelectingFiles) {
isAuthPageActive = true;
App.rootContext.to(
() => AuthPage(
onSuccessfulAuth: () {
App.rootContext.pop();
isAuthPageActive = false;
},
),
);
}
super.didChangeAppLifecycleState(state);
}
void forceRebuild() { void forceRebuild() {
void rebuild(Element el) { void rebuild(Element el) {
el.markNeedsBuild(); el.markNeedsBuild();
@@ -84,106 +119,151 @@ class _MyAppState extends State<MyApp> {
setState(() {}); setState(() {});
} }
@override Color translateColorSetting() {
Widget build(BuildContext context) { return switch (appdata.settings['color']) {
return MaterialApp( 'red' => Colors.red,
home: const MainPage(), 'pink' => Colors.pink,
debugShowCheckedModeBanner: false, 'purple' => Colors.purple,
theme: ThemeData( 'green' => Colors.green,
colorScheme: ColorScheme.fromSeed( 'orange' => Colors.orange,
seedColor: App.mainColor, 'blue' => Colors.blue,
surface: Colors.white, 'yellow' => Colors.yellow,
primary: App.mainColor.shade600, 'cyan' => Colors.cyan,
background: Colors.white, _ => Colors.blue,
), };
fontFamily: App.isWindows ? "Microsoft YaHei" : null, }
ThemeData getTheme(
Color primary,
Color? secondary,
Color? tertiary,
Brightness brightness,
) {
String? font;
List<String>? fallback;
if (App.isWindows) {
font = 'Segoe UI';
fallback = [
'Segoe UI',
'Microsoft YaHei',
'PingFang SC',
'Noto Sans CJK',
'Arial',
'sans-serif'
];
}
if (App.isLinux) {
font = 'Noto Sans CJK';
fallback = [
'Segoe UI',
'Microsoft YaHei',
'PingFang SC',
'Noto Sans CJK',
'Arial',
'sans-serif'
];
}
return ThemeData(
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
brightness: brightness,
tones: FlexTones.vividBackground(brightness),
), ),
navigatorKey: App.rootNavigatorKey, fontFamily: font,
darkTheme: ThemeData( fontFamilyFallback: fallback,
colorScheme: ColorScheme.fromSeed(
seedColor: App.mainColor,
brightness: Brightness.dark,
surface: Colors.black,
primary: App.mainColor.shade400,
background: Colors.black,
),
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
),
themeMode: switch (appdata.settings['theme_mode']) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system
},
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
locale: () {
var lang = appdata.settings['language'];
if (lang == 'system') {
return null;
}
return switch (lang) {
'zh-CN' => const Locale('zh', 'CN'),
'zh-TW' => const Locale('zh', 'TW'),
'en-US' => const Locale('en'),
_ => null
};
}(),
supportedLocales: const [
Locale('en'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
],
builder: (context, widget) {
ErrorWidget.builder = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");
return Material(
child: Center(
child: Text(details.exception.toString()),
),
);
};
if (widget != null) {
widget = OverlayWidget(widget);
if (App.isDesktop) {
widget = Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.escape): VoidCallbackIntent(
App.pop,
),
},
child: MouseBackDetector(
onTapDown: App.pop,
child: WindowFrame(widget),
),
);
}
return _SystemUiProvider(Material(
child: widget,
));
}
throw ('widget is null');
},
); );
} }
void checkUpdates() async { @override
if(!appdata.settings['checkUpdateOnStart']) { Widget build(BuildContext context) {
return; Widget home;
if (appdata.settings['authorizationRequired']) {
home = AuthPage(
onSuccessfulAuth: () {
App.rootContext.toReplacement(() => const MainPage());
},
);
} else {
home = const MainPage();
} }
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; return DynamicColorBuilder(builder: (light, dark) {
var now = DateTime.now().millisecondsSinceEpoch; Color? primary, secondary, tertiary;
if(now - lastCheck < 24 * 60 * 60 * 1000) { if (appdata.settings['color'] != 'system' ||
return; light == null ||
} dark == null) {
appdata.implicitData['lastCheckUpdate'] = now; primary = translateColorSetting();
appdata.writeImplicitData(); } else {
await Future.delayed(const Duration(milliseconds: 300)); primary = light.primary;
await checkUpdateUi(false); secondary = light.secondary;
await ComicSourcePage.checkComicSourceUpdate(true); tertiary = light.tertiary;
}
return MaterialApp(
home: home,
debugShowCheckedModeBanner: false,
theme: getTheme(primary, secondary, tertiary, Brightness.light),
navigatorKey: App.rootNavigatorKey,
darkTheme: getTheme(primary, secondary, tertiary, Brightness.dark),
themeMode: switch (appdata.settings['theme_mode']) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system
},
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
locale: () {
var lang = appdata.settings['language'];
if (lang == 'system') {
return null;
}
return switch (lang) {
'zh-CN' => const Locale('zh', 'CN'),
'zh-TW' => const Locale('zh', 'TW'),
'en-US' => const Locale('en'),
_ => null
};
}(),
supportedLocales: const [
Locale('zh', 'CN'),
Locale('zh', 'TW'),
Locale('en'),
],
builder: (context, widget) {
ErrorWidget.builder = (details) {
Log.error("Unhandled Exception",
"${details.exception}\n${details.stack}");
return Material(
child: Center(
child: Text(details.exception.toString()),
),
);
};
if (widget != null) {
widget = OverlayWidget(widget);
if (App.isDesktop) {
widget = Shortcuts(
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.escape): VoidCallbackIntent(
App.pop,
),
},
child: MouseBackDetector(
onTapDown: App.pop,
child: WindowFrame(widget),
),
);
}
return _SystemUiProvider(Material(
child: widget,
));
}
throw ('widget is null');
},
);
});
} }
} }

View File

@@ -1,5 +1,5 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -96,6 +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"
"headers:\n${options.headers}\n"
"data:\n${options.data}");
options.connectTimeout = const Duration(seconds: 15); options.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15); options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15); options.sendTimeout = const Duration(seconds: 15);
@@ -108,29 +111,15 @@ class AppDio with DioMixin {
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
interceptors.add(MyLogInterceptor()); httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
httpClientAdapter = RHttpAdapter(const rhttp.ClientSettings()); 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());
static HttpClient createHttpClient() {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
client.findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
client.idleTimeout = const Duration(seconds: 100);
client.badCertificateCallback =
(X509Certificate cert, String host, int port) {
if (host.contains("cdn")) return true;
final ipv4RegExp = RegExp(
r'^((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})$');
if (ipv4RegExp.hasMatch(host)) {
return true;
}
return false;
};
return client;
} }
static String? proxy; static String? proxy;
@@ -176,6 +165,8 @@ class AppDio with DioMixin {
return res; return res;
} }
static final Map<String, bool> _requests = {};
@override @override
Future<Response<T>> request<T>( Future<Response<T>> request<T>(
String path, { String path, {
@@ -186,6 +177,13 @@ class AppDio with DioMixin {
ProgressCallback? onSendProgress, ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress, ProgressCallback? onReceiveProgress,
}) async { }) async {
if (options?.headers?['prevent-parallel'] == 'true') {
while (_requests.containsKey(path)) {
await Future.delayed(const Duration(milliseconds: 20));
}
_requests[path] = true;
options!.headers!.remove('prevent-parallel');
}
proxy = await getProxy(); proxy = await getProxy();
if (_proxy != proxy) { if (_proxy != proxy) {
Log.info("Network", "Proxy changed to $proxy"); Log.info("Network", "Proxy changed to $proxy");
@@ -196,22 +194,44 @@ class AppDio with DioMixin {
: rhttp.ProxySettings.proxy(proxy!), : rhttp.ProxySettings.proxy(proxy!),
)); ));
} }
return super.request( try {
path, return super.request<T>(
data: data, path,
queryParameters: queryParameters, data: data,
cancelToken: cancelToken, queryParameters: queryParameters,
options: options, cancelToken: cancelToken,
onSendProgress: onSendProgress, options: options,
onReceiveProgress: onReceiveProgress, onSendProgress: onSendProgress,
); onReceiveProgress: onReceiveProgress,
);
} finally {
if (_requests.containsKey(path)) {
_requests.remove(path);
}
}
} }
} }
class RHttpAdapter implements HttpClientAdapter { class RHttpAdapter implements HttpClientAdapter {
rhttp.ClientSettings settings; rhttp.ClientSettings settings;
RHttpAdapter(this.settings) { static Map<String, List<String>> _getOverrides() {
if (!appdata.settings['enableDnsOverrides'] == true) {
return {};
}
var config = appdata.settings["dnsOverrides"];
var result = <String, List<String>>{};
if (config is Map) {
for (var entry in config.entries) {
if (entry.key is String && entry.value is String) {
result[entry.key] = [entry.value];
}
}
}
return result;
}
RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
settings = settings.copyWith( settings = settings.copyWith(
redirectSettings: const rhttp.RedirectSettings.limited(5), redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings( timeoutSettings: const rhttp.TimeoutSettings(
@@ -220,6 +240,10 @@ class RHttpAdapter implements HttpClientAdapter {
keepAlivePing: Duration(seconds: 30), keepAlivePing: Duration(seconds: 30),
), ),
throwOnStatusCode: false, throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
),
); );
} }
@@ -232,25 +256,8 @@ class RHttpAdapter implements HttpClientAdapter {
Stream<Uint8List>? requestStream, Stream<Uint8List>? requestStream,
Future<void>? cancelFuture, Future<void>? cancelFuture,
) async { ) async {
Log.info(
"Network",
"${options.method} ${options.uri}\n"
"Headers: ${options.headers}\n"
"Data: ${options.data}\n",
);
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: settings,
expectBody: rhttp.HttpExpectBody.stream, expectBody: rhttp.HttpExpectBody.stream,
@@ -272,13 +279,8 @@ class RHttpAdapter implements HttpClientAdapter {
headers[key] ??= []; headers[key] ??= [];
headers[key]!.add(entry.$2); headers[key]!.add(entry.$2);
} }
var data = res.body;
if(headers['content-encoding']?.contains('gzip') ?? false) {
// rhttp does not support gzip decoding
data = gzip.decoder.bind(data).map((data) => Uint8List.fromList(data));
}
return ResponseBody( return ResponseBody(
data, res.body,
res.statusCode, res.statusCode,
statusMessage: null, statusMessage: null,
isRedirect: false, isRedirect: false,

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:typed_data';
import 'package:venera/network/app_dio.dart';
import 'package:dio/dio.dart';
class NetworkCache { class NetworkCache {
final Uri uri; final Uri uri;
@@ -43,7 +42,10 @@ class NetworkCacheManager implements Interceptor {
static const _maxCacheSize = 10 * 1024 * 1024; static const _maxCacheSize = 10 * 1024 * 1024;
void setCache(NetworkCache cache) { void setCache(NetworkCache cache) {
while(size > _maxCacheSize){ if (_cache.containsKey(cache.uri)) {
size -= _cache[cache.uri]!.size;
}
while (size > _maxCacheSize) {
size -= _cache.values.first.size; size -= _cache.values.first.size;
_cache.remove(_cache.keys.first); _cache.remove(_cache.keys.first);
} }
@@ -53,7 +55,7 @@ class NetworkCacheManager implements Interceptor {
void removeCache(Uri uri) { void removeCache(Uri uri) {
var cache = _cache[uri]; var cache = _cache[uri];
if(cache != null){ if (cache != null) {
size -= cache.size; size -= cache.size;
} }
_cache.remove(uri); _cache.remove(uri);
@@ -64,41 +66,29 @@ class NetworkCacheManager implements Interceptor {
size = 0; size = 0;
} }
var preventParallel = <Uri, Completer>{};
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
if(err.requestOptions.method != "GET"){ if (err.requestOptions.method != "GET") {
return handler.next(err); return handler.next(err);
} }
if(preventParallel[err.requestOptions.uri] != null){
preventParallel[err.requestOptions.uri]!.complete();
preventParallel.remove(err.requestOptions.uri);
}
return handler.next(err); return handler.next(err);
} }
@override @override
void onRequest( void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async { RequestOptions options, RequestInterceptorHandler handler) async {
if(options.method != "GET"){ if (options.method != "GET") {
return handler.next(options); return handler.next(options);
} }
if(preventParallel[options.uri] != null){
await preventParallel[options.uri]!.future;
}
var cache = getCache(options.uri); var cache = getCache(options.uri);
if (cache == null || !compareHeaders(options.headers, cache.requestHeaders)) { if (cache == null ||
if(options.headers['cache-time'] != null){ !compareHeaders(options.headers, cache.requestHeaders)) {
if (options.headers['cache-time'] != null) {
options.headers.remove('cache-time'); options.headers.remove('cache-time');
} }
if(options.headers['prevent-parallel'] != null){
options.headers.remove('prevent-parallel');
preventParallel[options.uri] = Completer();
}
return handler.next(options); return handler.next(options);
} else { } else {
if(options.headers['cache-time'] == 'no'){ if (options.headers['cache-time'] == 'no') {
options.headers.remove('cache-time'); options.headers.remove('cache-time');
removeCache(options.uri); removeCache(options.uri);
return handler.next(options); return handler.next(options);
@@ -106,34 +96,36 @@ class NetworkCacheManager implements Interceptor {
} }
var time = DateTime.now(); var time = DateTime.now();
var diff = time.difference(cache.time); var diff = time.difference(cache.time);
if (options.headers['cache-time'] == 'long' if (options.headers['cache-time'] == 'long' &&
&& diff < const Duration(hours: 2)) { diff < const Duration(hours: 6)) {
return handler.resolve(Response( return handler.resolve(Response(
requestOptions: options, requestOptions: options,
data: cache.data, data: cache.data,
headers: Headers.fromMap(cache.responseHeaders), headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200, statusCode: 200,
)); ));
} } else if (diff < const Duration(seconds: 5)) {
else if (diff < const Duration(seconds: 5)) {
return handler.resolve(Response( return handler.resolve(Response(
requestOptions: options, requestOptions: options,
data: cache.data, data: cache.data,
headers: Headers.fromMap(cache.responseHeaders), headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200, statusCode: 200,
)); ));
} else if (diff < const Duration(hours: 1)) { } else if (diff < const Duration(hours: 2)) {
var o = options.copyWith( var o = options.copyWith(
method: "HEAD", method: "HEAD",
); );
var dio = Dio(); var dio = AppDio();
var response = await dio.fetch(o); var response = await dio.fetch(o);
if (response.statusCode == 200 && if (response.statusCode == 200 &&
compareHeaders(cache.responseHeaders, response.headers.map)) { compareHeaders(cache.responseHeaders, response.headers.map)) {
return handler.resolve(Response( return handler.resolve(Response(
requestOptions: options, requestOptions: options,
data: cache.data, data: cache.data,
headers: Headers.fromMap(cache.responseHeaders), headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200, statusCode: 200,
)); ));
} }
@@ -143,11 +135,44 @@ class NetworkCacheManager implements Interceptor {
} }
static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) { static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) {
a = Map.from(a);
b = Map.from(b);
const shouldIgnore = [
'cache-time',
'prevent-parallel',
'date',
'x-varnish',
'cf-ray',
'connection',
'vary',
'content-encoding',
'report-to',
'server-timing',
'token',
'set-cookie',
'cf-cache-status',
'cf-request-id',
'cf-ray',
'authorization',
];
for (var key in shouldIgnore) {
a.remove(key);
b.remove(key);
}
if (a.length != b.length) { if (a.length != b.length) {
return false; return false;
} }
for (var key in a.keys) { for (var key in a.keys) {
if (a[key] != b[key]) { if (a[key] is List && b[key] is List) {
if (a[key].length != b[key].length) {
return false;
}
for (var i = 0; i < a[key].length; i++) {
if (a[key][i] != b[key][i]) {
return false;
}
}
} else if (a[key] != b[key]) {
return false; return false;
} }
} }
@@ -160,45 +185,44 @@ class NetworkCacheManager implements Interceptor {
if (response.requestOptions.method != "GET") { if (response.requestOptions.method != "GET") {
return handler.next(response); return handler.next(response);
} }
if(response.statusCode != null && response.statusCode! >= 400){ if (response.statusCode != null && response.statusCode! >= 400) {
return handler.next(response); return handler.next(response);
} }
var size = _calculateSize(response.data); var size = _calculateSize(response.data);
if(size != null && size < 1024 * 1024 && size > 0) { if (size != null && size < 1024 * 1024 && size > 0) {
var cache = NetworkCache( var cache = NetworkCache(
uri: response.requestOptions.uri, uri: response.requestOptions.uri,
requestHeaders: response.requestOptions.headers, requestHeaders: response.requestOptions.headers,
responseHeaders: response.headers.map, responseHeaders: Map.from(response.headers.map),
data: response.data, data: response.data,
time: DateTime.now(), time: DateTime.now(),
size: size, size: size,
); );
setCache(cache); setCache(cache);
} }
if(preventParallel[response.requestOptions.uri] != null){
preventParallel[response.requestOptions.uri]!.complete();
preventParallel.remove(response.requestOptions.uri);
}
handler.next(response); handler.next(response);
} }
static int? _calculateSize(Object? data){ static int? _calculateSize(Object? data) {
if(data == null){ if (data == null) {
return 0; return 0;
} }
if(data is List<int>) { if (data is List<int>) {
return data.length; return data.length;
} }
if(data is String) { if (data is Uint8List) {
if(data.trim().isEmpty){ return data.length;
}
if (data is String) {
if (data.trim().isEmpty) {
return 0; return 0;
} }
if(data.length < 512 && data.contains("IP address")){ if (data.length < 512 && data.contains("IP address")) {
return 0; return 0;
} }
return data.length * 4; return data.length * 4;
} }
if(data is Map) { if (data is Map) {
return data.toString().length * 4; return data.toString().length * 4;
} }
return null; return null;

View File

@@ -1,10 +1,11 @@
import 'dart:io' as io; import 'dart:io' as io;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.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/consts.dart'; import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/webview.dart'; import 'package:venera/pages/webview.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -13,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() {
@@ -54,12 +55,15 @@ 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 {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if(options.headers['cookie'].toString().contains('cf_clearance')) { if (options.headers['cookie'].toString().contains('cf_clearance')) {
options.headers['user-agent'] = appdata.implicitData['ua'] ?? webUA; options.headers['user-agent'] = appdata.implicitData['ua'] ?? webUA;
} }
handler.next(options); handler.next(options);
@@ -117,20 +121,29 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
// windows version of package `flutter_inappwebview` cannot get some cookies // windows version of package `flutter_inappwebview` cannot get some cookies
// Using DesktopWebview instead // Using DesktopWebview instead
if (App.isLinux || App.isWindows) { if (App.isLinux) {
var webview = DesktopWebview( var webview = DesktopWebview(
initialUrl: url, initialUrl: url,
onTitleChange: (title, controller) async { onTitleChange: (title, controller) async {
var res = await controller.evaluateJavascript( var head =
"document.head.innerHTML.includes('#challenge-success-text')"); await controller.evaluateJavascript("document.head.innerHTML") ??
if (res == 'false') { "";
Log.info("Cloudflare", "Checking head: $head");
var isChallenging = head.contains('#challenge-success-text') ||
head.contains("#challenge-error-text") ||
head.contains("#challenge-form");
if (!isChallenging) {
Log.info(
"Cloudflare",
"Cloudflare is passed due to there is no challenge css",
);
var ua = controller.userAgent; var ua = controller.userAgent;
if (ua != null) { if (ua != null) {
appdata.implicitData['ua'] = ua; appdata.implicitData['ua'] = ua;
appdata.writeImplicitData(); appdata.writeImplicitData();
} }
var cookiesMap = await controller.getCookies(url); var cookiesMap = await controller.getCookies(url);
if(cookiesMap['cf_clearance'] == null) { if (cookiesMap['cf_clearance'] == null) {
return; return;
} }
saveCookies(cookiesMap); saveCookies(cookiesMap);
@@ -138,30 +151,51 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
onFinished(); onFinished();
} }
}, },
onClose: onFinished,
); );
webview.open(); webview.open();
} else { } else {
bool success = false;
void check(InAppWebViewController controller) async {
var head = await controller.evaluateJavascript(
source: "document.head.innerHTML") as String;
Log.info("Cloudflare", "Checking head: $head");
var isChallenging = head.contains('#challenge-success-text') ||
head.contains("#challenge-error-text") ||
head.contains("#challenge-form");
if (!isChallenging) {
Log.info(
"Cloudflare",
"Cloudflare is passed due to there is no challenge css",
);
var ua = await controller.getUA();
if (ua != null) {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookies = await controller.getCookies(url) ?? [];
if (cookies.firstWhereOrNull(
(element) => element.name == 'cf_clearance') ==
null) {
return;
}
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
if (!success) {
App.rootPop();
success = true;
}
}
}
await App.rootContext.to( await App.rootContext.to(
() => AppWebview( () => AppWebview(
initialUrl: url, initialUrl: url,
singlePage: true, singlePage: true,
onTitleChange: (title, controller) async {
check(controller);
},
onLoadStop: (controller) async { onLoadStop: (controller) async {
var res = await controller.platform.evaluateJavascript( check(controller);
source:
"document.head.innerHTML.includes('#challenge-success-text')");
if (res == false) {
var ua = await controller.getUA();
if (ua != null) {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookies = await controller.getCookies(url) ?? [];
if(cookies.firstWhereOrNull((element) => element.name == 'cf_clearance') == null) {
return;
}
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
App.rootPop();
}
}, },
onStarted: (controller) async { onStarted: (controller) async {
var ua = await controller.getUA(); var ua = await controller.getUA();

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

@@ -1,6 +1,9 @@
import 'dart:async'; import 'dart:async';
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';
@@ -11,13 +14,14 @@ import 'package:venera/network/images.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';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:zip_flutter/zip_flutter.dart';
import 'file_downloader.dart';
abstract class DownloadTask with ChangeNotifier { abstract class DownloadTask with ChangeNotifier {
/// 0-1 /// 0-1
double get progress; double get progress;
bool get isComplete;
bool get isError; bool get isError;
bool get isPaused; bool get isPaused;
@@ -57,6 +61,16 @@ abstract class DownloadTask with ChangeNotifier {
return null; return null;
} }
} }
@override
bool operator ==(Object other) {
return other is DownloadTask &&
other.id == id &&
other.comicType == comicType;
}
@override
int get hashCode => Object.hash(id, comicType);
} }
class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
@@ -76,11 +90,14 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
@override @override
ComicType get comicType => ComicType(source.key.hashCode); ComicType get comicType => ComicType(source.key.hashCode);
String? comicTitle;
ImagesDownloadTask({ ImagesDownloadTask({
required this.source, required this.source,
required this.comicId, required this.comicId,
this.comic, this.comic,
this.chapters, this.chapters,
this.comicTitle,
}); });
@override @override
@@ -103,10 +120,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} }
@override @override
String? get cover => _cover; String? get cover => _cover ?? comic?.cover;
@override
bool get isComplete => _totalCount == _downloadedCount;
@override @override
String get message => _message; String get message => _message;
@@ -144,19 +158,25 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
String? _cover; String? _cover;
/// All images to download, key is chapter name
Map<String, List<String>>? _images; Map<String, List<String>>? _images;
/// Downloaded image count
int _downloadedCount = 0; int _downloadedCount = 0;
/// Total image count
int _totalCount = 0; int _totalCount = 0;
/// Current downloading image index
int _index = 0; int _index = 0;
/// Current downloading chapter, index of [_images]
int _chapter = 0; int _chapter = 0;
var tasks = <int, _ImageDownloadWrapper>{}; var tasks = <int, _ImageDownloadWrapper>{};
int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt(); int get _maxConcurrentTasks =>
(appdata.settings["downloadThreads"] as num).toInt();
void _scheduleTasks() { void _scheduleTasks() {
var images = _images![_images!.keys.elementAt(_chapter)]!; var images = _images![_images!.keys.elementAt(_chapter)]!;
@@ -177,10 +197,10 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (comic!.chapters != null) { if (comic!.chapters != null) {
saveTo = Directory(FilePath.join( saveTo = Directory(FilePath.join(
path!, path!,
comic!.chapters!.keys.elementAt(_chapter), _images!.keys.elementAt(_chapter),
)); ));
if (!saveTo.existsSync()) { if (!saveTo.existsSync()) {
saveTo.createSync(); saveTo.createSync(recursive: true);
} }
} else { } else {
saveTo = Directory(path!); saveTo = Directory(path!);
@@ -212,7 +232,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
runRecorder(); runRecorder();
if (comic == null) { if (comic == null) {
var res = await runWithRetry(() async { _message = "Fetching comic info...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicInfo!(comicId); var r = await source.loadComicInfo!(comicId);
if (r.error) { if (r.error) {
throw r.errorMessage!; throw r.errorMessage!;
@@ -232,26 +254,29 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} }
if (path == null) { if (path == null) {
var dir = await LocalManager().findValidDirectory( try {
comicId, var dir = await LocalManager().findValidDirectory(
comicType, comicId,
comic!.title, comicType,
); comic!.title,
if (!(await dir.exists())) { );
try { if (!(await dir.exists())) {
await dir.create(); await dir.create();
} catch (e) {
_setError("Error: $e");
return;
} }
path = dir.path;
} catch (e, s) {
Log.error("Download", e.toString(), s);
_setError("Error: $e");
return;
} }
path = dir.path;
} }
await LocalManager().saveCurrentDownloadingTasks(); await LocalManager().saveCurrentDownloadingTasks();
if (cover == null) { if (_cover == null) {
var res = await runWithRetry(() async { _message = "Downloading cover...";
notifyListeners();
var res = await _runWithRetry(() async {
Uint8List? data; Uint8List? data;
await for (var progress await for (var progress
in ImageDownloader.loadThumbnail(comic!.cover, source.key)) { in ImageDownloader.loadThumbnail(comic!.cover, source.key)) {
@@ -265,9 +290,10 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
var fileType = detectFileType(data); var fileType = detectFileType(data);
var file = File(FilePath.join(path!, "cover${fileType.ext}")); var file = File(FilePath.join(path!, "cover${fileType.ext}"));
file.writeAsBytesSync(data); file.writeAsBytesSync(data);
return file.path; return "file://${file.path}";
}); });
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -279,7 +305,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (_images == null) { if (_images == null) {
if (comic!.chapters == null) { if (comic!.chapters == null) {
var res = await runWithRetry(() async { _message = "Fetching image list...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicPages!(comicId, null); var r = await source.loadComicPages!(comicId, null);
if (r.error) { if (r.error) {
throw r.errorMessage!; throw r.errorMessage!;
@@ -291,6 +319,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -300,7 +329,10 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} else { } else {
_images = {}; _images = {};
_totalCount = 0; _totalCount = 0;
for (var i in comic!.chapters!.keys) { int cpCount = 0;
int totalCpCount =
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;
} }
@@ -308,7 +340,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_totalCount += _images![i]!.length; _totalCount += _images![i]!.length;
continue; continue;
} }
var res = await runWithRetry(() async { _message = "Fetching image list ($cpCount/$totalCpCount)...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicPages!(comicId, i); var r = await source.loadComicPages!(comicId, i);
if (r.error) { if (r.error) {
throw r.errorMessage!; throw r.errorMessage!;
@@ -320,6 +354,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -344,6 +379,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (task.error != null) { if (task.error != null) {
Log.error("Download", task.error.toString());
_setError("Error: ${task.error}"); _setError("Error: ${task.error}");
return; return;
} }
@@ -357,6 +393,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} }
LocalManager().completeTask(this); LocalManager().completeTask(this);
stopRecorder();
} }
@override @override
@@ -371,14 +408,13 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_message = message; _message = message;
notifyListeners(); notifyListeners();
stopRecorder(); stopRecorder();
Log.error("Download", message);
} }
@override @override
int get speed => currentSpeed; int get speed => currentSpeed;
@override @override
String get title => comic?.title ?? "Loading..."; String get title => comic?.title ?? comicTitle ?? "Loading...";
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@@ -389,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,
@@ -444,7 +480,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}).toList(), }).toList(),
directory: Directory(path!).name, directory: Directory(path!).name,
chapters: comic!.chapters, chapters: comic!.chapters,
cover: File(_cover!).uri.pathSegments.last, cover: File(_cover!.split("file://").last).name,
comicType: ComicType(source.key.hashCode), comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [], downloadedChapters: chapters ?? [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
@@ -463,7 +499,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
int get hashCode => Object.hash(comicId, source.key); int get hashCode => Object.hash(comicId, source.key);
} }
Future<Res<T>> runWithRetry<T>(Future<T> Function() task, Future<Res<T>> _runWithRetry<T>(Future<T> Function() task,
{int retry = 3}) async { {int retry = 3}) async {
for (var i = 0; i < retry; i++) { for (var i = 0; i < retry; i++) {
try { try {
@@ -472,6 +508,7 @@ Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
if (i == retry - 1) { if (i == retry - 1) {
return Res.error(e.toString()); return Res.error(e.toString());
} }
await Future.delayed(Duration(seconds: i + 1));
} }
} }
throw UnimplementedError(); throw UnimplementedError();
@@ -534,6 +571,9 @@ class _ImageDownloadWrapper {
} }
} }
} catch (e, s) { } catch (e, s) {
if (isCancelled) {
return;
}
Log.error("Download", e.toString(), s); Log.error("Download", e.toString(), s);
retry--; retry--;
if (retry > 0) { if (retry > 0) {
@@ -570,7 +610,7 @@ abstract mixin class _TransferSpeedMixin {
void onData(int length) { void onData(int length) {
if (timer == null) return; if (timer == null) return;
if(length < 0) { if (length < 0) {
return; return;
} }
_bytesSinceLastSecond += length; _bytesSinceLastSecond += length;
@@ -596,3 +636,228 @@ abstract mixin class _TransferSpeedMixin {
_bytesSinceLastSecond = 0; _bytesSinceLastSecond = 0;
} }
} }
class ArchiveDownloadTask extends DownloadTask {
final String archiveUrl;
final ComicDetails comic;
late ComicSource source;
/// Download comic by archive url
///
/// Currently only support zip file and comics without chapters
ArchiveDownloadTask(this.archiveUrl, this.comic) {
source = ComicSource.find(comic.sourceKey)!;
}
FileDownloader? _downloader;
String _message = "Fetching comic info...";
bool _isRunning = false;
bool _isError = false;
void _setError(String message) {
_isRunning = false;
_isError = true;
_message = message;
notifyListeners();
Log.error("Download", message);
}
@override
void cancel() async {
_isRunning = false;
await _downloader?.stop();
if (path != null) {
Directory(path!).deleteIgnoreError(recursive: true);
}
path = null;
LocalManager().removeTask(this);
}
@override
ComicType get comicType => ComicType(source.key.hashCode);
@override
String? get cover => comic.cover;
@override
String get id => comic.id;
@override
bool get isError => _isError;
@override
bool get isPaused => !_isRunning;
@override
String get message => _message;
int _currentBytes = 0;
int _expectedBytes = 0;
int _speed = 0;
@override
void pause() {
_isRunning = false;
_message = "Paused";
_downloader?.stop();
notifyListeners();
}
@override
double get progress =>
_expectedBytes == 0 ? 0 : _currentBytes / _expectedBytes;
@override
void resume() async {
if (_isRunning) {
return;
}
_isError = false;
_isRunning = true;
notifyListeners();
_message = "Downloading...";
if (path == null) {
var dir = await LocalManager().findValidDirectory(
comic.id,
comicType,
comic.title,
);
if (!(await dir.exists())) {
try {
await dir.create();
} catch (e) {
_setError("Error: $e");
return;
}
}
path = dir.path;
}
var archiveFile =
File(FilePath.join(App.dataPath, "archive_downloading.zip"));
Log.info("Download", "Downloading $archiveUrl");
_downloader = FileDownloader(archiveUrl, archiveFile.path);
bool isDownloaded = false;
try {
await for (var status in _downloader!.start()) {
_currentBytes = status.downloadedBytes;
_expectedBytes = status.totalBytes;
_message =
"${bytesToReadableString(_currentBytes)}/${bytesToReadableString(_expectedBytes)}";
_speed = status.bytesPerSecond;
isDownloaded = status.isFinished;
notifyListeners();
}
} catch (e) {
_setError("Error: $e");
return;
}
if (!_isRunning) {
return;
}
if (!isDownloaded) {
_setError("Error: Download failed");
return;
}
try {
await _extractArchive(archiveFile.path, path!);
} catch (e) {
_setError("Failed to extract archive: $e");
return;
}
await archiveFile.deleteIgnoreError();
LocalManager().completeTask(this);
}
static Future<void> _extractArchive(String archive, String outDir) async {
var out = Directory(outDir);
if (out is AndroidDirectory) {
// Saf directory can't be accessed by native code.
var cacheDir = FilePath.join(App.cachePath, "archive_downloading");
Directory(cacheDir).forceCreateSync();
await Isolate.run(() {
ZipFile.openAndExtract(archive, cacheDir);
});
await copyDirectoryIsolate(Directory(cacheDir), Directory(outDir));
await Directory(cacheDir).deleteIgnoreError(recursive: true);
} else {
await Isolate.run(() {
ZipFile.openAndExtract(archive, outDir);
});
}
}
@override
int get speed => _speed;
@override
String get title => comic.title;
@override
Map<String, dynamic> toJson() {
return {
"type": "ArchiveDownloadTask",
"archiveUrl": archiveUrl,
"comic": comic.toJson(),
"path": path,
};
}
static ArchiveDownloadTask? fromJson(Map<String, dynamic> json) {
if (json["type"] != "ArchiveDownloadTask") {
return null;
}
return ArchiveDownloadTask(
json["archiveUrl"],
ComicDetails.fromJson(json["comic"]),
)..path = json["path"];
}
String _findCover() {
var files = Directory(path!).listSync();
for (var f in files) {
if (f.name.startsWith('cover')) {
return f.name;
}
}
files.sort((a, b) {
return a.name.compareTo(b.name);
});
return files.first.name;
}
@override
LocalComic toLocalComic() {
return LocalComic(
id: comic.id,
title: title,
subtitle: comic.subTitle ?? '',
tags: comic.tags.entries.expand((e) {
return e.value.map((v) => "${e.key}:$v");
}).toList(),
directory: Directory(path!).name,
chapters: null,
cover: _findCover(),
comicType: ComicType(source.key.hashCode),
downloadedChapters: [],
createdAt: DateTime.now(),
);
}
}

View File

@@ -0,0 +1,298 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/io.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/ext.dart';
class FileDownloader {
final String url;
final String savePath;
final int maxConcurrent;
FileDownloader(this.url, this.savePath, {this.maxConcurrent = 4});
int _currentBytes = 0;
int _lastBytes = 0;
late int _fileSize;
final _dio = Dio();
RandomAccessFile? _file;
bool _isWriting = false;
int _kChunkSize = 16 * 1024 * 1024;
bool _canceled = false;
late List<_DownloadBlock> _blocks;
Future<void> _writeStatus() async {
var file = File("$savePath.download");
await file.writeAsString(_blocks.map((e) => e.toString()).join("\n"));
}
Future<void> _readStatus() async {
var file = File("$savePath.download");
if (!await file.exists()) {
return;
}
var lines = await file.readAsLines();
_blocks = lines.map((e) => _DownloadBlock.fromString(e)).toList();
}
/// create file and write empty bytes
Future<void> _prepareFile() async {
var file = File(savePath);
if (await file.exists()) {
if (file.lengthSync() == _fileSize &&
File("$savePath.download").existsSync()) {
_file = await file.open(mode: FileMode.append);
return;
} else {
await file.delete();
}
}
await file.create(recursive: true);
_file = await file.open(mode: FileMode.append);
await _file!.truncate(_fileSize);
}
Future<void> _createTasks() async {
var res = await _dio.head(url);
var length = res.headers["content-length"]?.first;
_fileSize = length == null ? 0 : int.parse(length);
await _prepareFile();
if (File("$savePath.download").existsSync()) {
await _readStatus();
_currentBytes = _blocks.fold<int>(0,
(previousValue, element) => previousValue + element.downloadedBytes);
} else {
if (_fileSize > 1024 * 1024 * 1024) {
_kChunkSize = 64 * 1024 * 1024;
} else if (_fileSize > 512 * 1024 * 1024) {
_kChunkSize = 32 * 1024 * 1024;
}
_blocks = [];
for (var i = 0; i < _fileSize; i += _kChunkSize) {
var end = i + _kChunkSize;
if (end > _fileSize) {
_blocks.add(_DownloadBlock(i, _fileSize, 0, false));
} else {
_blocks.add(_DownloadBlock(i, i + _kChunkSize, 0, false));
}
}
}
}
Stream<DownloadingStatus> start() {
var stream = StreamController<DownloadingStatus>();
_download(stream);
return stream.stream;
}
void _reportStatus(StreamController<DownloadingStatus> stream) {
stream.add(DownloadingStatus(_currentBytes, _fileSize, 0));
}
void _download(StreamController<DownloadingStatus> resultStream) async {
try {
var proxy = await AppDio.getProxy();
_dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
);
// get file size
await _createTasks();
if (_canceled) return;
// check if file is downloaded
if (_currentBytes >= _fileSize) {
await _file!.close();
_file = null;
_reportStatus(resultStream);
resultStream.close();
return;
}
_reportStatus(resultStream);
Timer.periodic(const Duration(seconds: 1), (timer) {
if (_canceled || _currentBytes >= _fileSize) {
timer.cancel();
return;
}
resultStream.add(DownloadingStatus(
_currentBytes, _fileSize, _currentBytes - _lastBytes));
_lastBytes = _currentBytes;
});
// start downloading
await _scheduleDownload();
if (_canceled) {
resultStream.close();
return;
}
await _file!.close();
_file = null;
await File("$savePath.download").delete();
// check if download is finished
if (_currentBytes < _fileSize) {
resultStream
.addError(Exception("Download failed: Expected $_fileSize bytes, "
"but only $_currentBytes bytes downloaded."));
resultStream.close();
}
resultStream.add(DownloadingStatus(_currentBytes, _fileSize, 0, true));
resultStream.close();
} catch (e, s) {
await _file?.close();
_file = null;
resultStream.addError(e, s);
resultStream.close();
}
}
Future<void> _scheduleDownload() async {
var tasks = <Future>[];
while (true) {
if (_canceled) return;
if (tasks.length >= maxConcurrent) {
await Future.any(tasks);
}
final block = _blocks.firstWhereOrNull((element) =>
!element.downloading &&
element.end - element.start > element.downloadedBytes);
if (block == null) {
break;
}
block.downloading = true;
var task = _fetchBlock(block);
task.then((value) => tasks.remove(task), onError: (e) {
if(_canceled) return;
throw e;
});
tasks.add(task);
}
await Future.wait(tasks);
}
Future<void> _fetchBlock(_DownloadBlock block) async {
final start = block.start;
final end = block.end;
if (start > _fileSize) {
return;
}
var options = Options(
responseType: ResponseType.stream,
headers: {
"Range": "bytes=${start + block.downloadedBytes}-${end - 1}",
"Accept": "*/*",
"Accept-Encoding": "deflate, gzip",
},
preserveHeaderCase: true,
);
var res = await _dio.get<ResponseBody>(url, options: options);
if (_canceled) return;
if (res.data == null) {
throw Exception("Failed to block $start-$end");
}
var buffer = <int>[];
await for (var data in res.data!.stream) {
if (_canceled) return;
buffer.addAll(data);
if (buffer.length > 16 * 1024) {
if (_isWriting) continue;
_currentBytes += buffer.length;
_isWriting = true;
await _file!.setPosition(start + block.downloadedBytes);
await _file!.writeFrom(buffer);
block.downloadedBytes += buffer.length;
buffer.clear();
await _writeStatus();
_isWriting = false;
}
}
if (buffer.isNotEmpty) {
while (_isWriting) {
await Future.delayed(const Duration(milliseconds: 10));
}
_isWriting = true;
_currentBytes += buffer.length;
await _file!.setPosition(start + block.downloadedBytes);
await _file!.writeFrom(buffer);
block.downloadedBytes += buffer.length;
await _writeStatus();
_isWriting = false;
}
block.downloading = false;
}
Future<void> stop() async {
_canceled = true;
await _file?.close();
_file = null;
}
}
class DownloadingStatus {
/// The current downloaded bytes
final int downloadedBytes;
/// The total bytes of the file
final int totalBytes;
/// Whether the download is finished
final bool isFinished;
/// The download speed in bytes per second
final int bytesPerSecond;
const DownloadingStatus(
this.downloadedBytes, this.totalBytes, this.bytesPerSecond,
[this.isFinished = false]);
@override
String toString() {
return "Downloaded: $downloadedBytes/$totalBytes ${isFinished ? "Finished" : ""}";
}
}
class _DownloadBlock {
final int start;
final int end;
int downloadedBytes;
bool downloading;
_DownloadBlock(this.start, this.end, this.downloadedBytes, this.downloading);
@override
String toString() {
return "$start-$end-$downloadedBytes";
}
_DownloadBlock.fromString(String str)
: start = int.parse(str.split("-")[0]),
end = int.parse(str.split("-")[1]),
downloadedBytes = int.parse(str.split("-")[2]),
downloading = false;
}

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_qjs/flutter_qjs.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/consts.dart'; import 'package:venera/foundation/consts.dart';
@@ -9,8 +10,9 @@ import 'app_dio.dart';
class ImageDownloader { class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail( static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey) async* { String url, String? sourceKey,
final cacheKey = "$url@$sourceKey"; [String? cid]) async* {
final cacheKey = "$url@$sourceKey${cid != null ? '@$cid' : ''}";
final cache = await CacheManager().findCache(cacheKey); final cache = await CacheManager().findCache(cacheKey);
if (cache != null) { if (cache != null) {
@@ -33,6 +35,16 @@ class ImageDownloader {
configs['headers']['user-agent'] = webUA; configs['headers']['user-agent'] = webUA;
} }
if (((configs['url'] as String?) ?? url).startsWith('cover.') &&
sourceKey != null) {
var comicSource = ComicSource.find(sourceKey);
if(comicSource != null) {
var comicInfo = await comicSource.loadComicInfo!(cid!);
yield* loadThumbnail(comicInfo.data.cover, sourceKey);
return;
}
}
var dio = AppDio(BaseOptions( var dio = AppDio(BaseOptions(
headers: Map<String, dynamic>.from(configs['headers']), headers: Map<String, dynamic>.from(configs['headers']),
method: configs['method'] ?? 'GET', method: configs['method'] ?? 'GET',
@@ -57,8 +69,9 @@ class ImageDownloader {
} }
} }
if (configs['onResponse'] != null) { if (configs['onResponse'] is JSInvokable) {
buffer = configs['onResponse'](buffer); buffer = (configs['onResponse'] as JSInvokable)([buffer]);
(configs['onResponse'] as JSInvokable).free();
} }
await CacheManager().writeCache(cacheKey, buffer); await CacheManager().writeCache(cacheKey, buffer);
@@ -83,68 +96,103 @@ class ImageDownloader {
); );
} }
Future<Map<String, dynamic>?> Function()? onLoadFailed;
var configs = <String, dynamic>{}; var configs = <String, dynamic>{};
if (sourceKey != null) { if (sourceKey != null) {
var comicSource = ComicSource.find(sourceKey); var comicSource = ComicSource.find(sourceKey);
configs = (await comicSource!.getImageLoadingConfig configs = (await comicSource!.getImageLoadingConfig
?.call(imageKey, cid, eid)) ?? {}; ?.call(imageKey, cid, eid)) ??
{};
} }
configs['headers'] ??= { var retryLimit = 5;
'user-agent': webUA, while (true) {
}; try {
configs['headers'] ??= {
'user-agent': webUA,
};
var dio = AppDio(BaseOptions( if (configs['onLoadFailed'] is JSInvokable) {
headers: configs['headers'], onLoadFailed = () async {
method: configs['method'] ?? 'GET', dynamic result = (configs['onLoadFailed'] as JSInvokable)([]);
responseType: ResponseType.stream, if (result is Future) {
)); result = await result;
}
if (result is! Map<String, dynamic>) return null;
return result;
};
}
var req = await dio.request<ResponseBody>(configs['url'] ?? imageKey, var dio = AppDio(BaseOptions(
data: configs['data']); headers: configs['headers'],
var stream = req.data?.stream ?? (throw "Error: Empty response body."); method: configs['method'] ?? 'GET',
int? expectedBytes = req.data!.contentLength; responseType: ResponseType.stream,
if (expectedBytes == -1) { ));
expectedBytes = null;
} var req = await dio.request<ResponseBody>(configs['url'] ?? imageKey,
var buffer = <int>[]; data: configs['data']);
await for (var data in stream) { var stream = req.data?.stream ?? (throw "Error: Empty response body.");
buffer.addAll(data); int? expectedBytes = req.data!.contentLength;
if (expectedBytes != null) { if (expectedBytes == -1) {
expectedBytes = null;
}
var buffer = <int>[];
await for (var data in stream) {
buffer.addAll(data);
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: expectedBytes,
);
}
if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
(configs['onResponse'] as JSInvokable).free();
}
var data = Uint8List.fromList(buffer);
buffer.clear();
if (configs['modifyImage'] != null) {
var newData = await modifyImageWithScript(
data,
configs['modifyImage'],
);
data = newData;
}
await CacheManager().writeCache(cacheKey, data);
yield ImageDownloadProgress( yield ImageDownloadProgress(
currentBytes: buffer.length, currentBytes: data.length,
totalBytes: expectedBytes, totalBytes: data.length,
imageBytes: data,
); );
return;
} catch (e) {
if (retryLimit < 0 || onLoadFailed == null) {
rethrow;
}
var newConfig = await onLoadFailed();
(configs['onLoadFailed'] as JSInvokable).free();
onLoadFailed = null;
if (newConfig == null) {
rethrow;
}
configs = newConfig;
retryLimit--;
} finally {
if (onLoadFailed != null) {
(configs['onLoadFailed'] as JSInvokable).free();
}
} }
} }
if (configs['onResponse'] != null) {
buffer = configs['onResponse'](buffer);
}
var data = Uint8List.fromList(buffer);
buffer.clear();
if (configs['modifyImage'] != null) {
var newData = await modifyImageWithScript(
data,
configs['modifyImage'],
);
data = newData;
}
await CacheManager().writeCache(cacheKey, data);
yield ImageDownloadProgress(
currentBytes: data.length,
totalBytes: data.length,
imageBytes: data,
);
} }
} }
class ImageDownloadProgress { class ImageDownloadProgress {
final int currentBytes; final int currentBytes;
final int totalBytes; final int? totalBytes;
final Uint8List? imageBytes; final Uint8List? imageBytes;

View File

@@ -1,349 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/webview.dart';
import 'package:venera/utils/translations.dart';
class AccountsPageLogic extends StateController {
final _reLogin = <String, bool>{};
}
class AccountsPage extends StatelessWidget {
const AccountsPage({super.key});
AccountsPageLogic get logic => StateController.find<AccountsPageLogic>();
@override
Widget build(BuildContext context) {
var body = StateBuilder<AccountsPageLogic>(
init: AccountsPageLogic(),
builder: (logic) {
return CustomScrollView(
slivers: [
SliverAppbar(title: Text("Accounts".tl)),
SliverList(
delegate: SliverChildListDelegate(
buildContent(context).toList(),
),
),
SliverPadding(
padding: EdgeInsets.only(bottom: context.padding.bottom),
)
],
);
},
);
return Scaffold(
body: body,
);
}
Iterable<Widget> buildContent(BuildContext context) sync* {
var sources = ComicSource.all().where((element) => element.account != null);
if (sources.isEmpty) return;
for (var element in sources) {
final bool logged = element.isLogged;
yield Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
element.name,
style: const TextStyle(fontSize: 16),
),
);
if (!logged) {
yield ListTile(
title: Text("Log in".tl),
trailing: const Icon(Icons.arrow_right),
onTap: () async {
await context.to(
() => _LoginPage(
config: element.account!,
source: element,
),
);
element.saveData();
ComicSource.notifyListeners();
logic.update();
},
);
}
if (logged) {
for (var item in element.account!.infoItems) {
if (item.builder != null) {
yield item.builder!(context);
} else {
yield ListTile(
title: Text(item.title.tl),
subtitle: item.data == null ? null : Text(item.data!()),
onTap: item.onTap,
);
}
}
if (element.data["account"] is List) {
bool loading = logic._reLogin[element.key] == true;
yield ListTile(
title: Text("Re-login".tl),
subtitle: Text("Click if login expired".tl),
onTap: () async {
if (element.data["account"] == null) {
context.showMessage(message: "No data".tl);
return;
}
logic._reLogin[element.key] = true;
logic.update();
final List account = element.data["account"];
var res = await element.account!.login!(account[0], account[1]);
if (res.error) {
context.showMessage(message: res.errorMessage!);
} else {
context.showMessage(message: "Success".tl);
}
logic._reLogin[element.key] = false;
logic.update();
},
trailing: loading
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh),
);
}
yield ListTile(
title: Text("Log out".tl),
onTap: () {
element.data["account"] = null;
element.account?.logout();
element.saveData();
ComicSource.notifyListeners();
logic.update();
},
trailing: const Icon(Icons.logout),
);
}
yield const Divider(thickness: 0.6);
}
}
void setClipboard(String text) {
Clipboard.setData(ClipboardData(text: text));
showToast(
message: "Copied".tl,
icon: const Icon(Icons.check),
context: App.rootContext,
);
}
}
class _LoginPage extends StatefulWidget {
const _LoginPage({required this.config, required this.source});
final AccountConfig config;
final ComicSource source;
@override
State<_LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<_LoginPage> {
String username = "";
String password = "";
bool loading = false;
final Map<String, String> _cookies = {};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const Appbar(
title: Text(''),
),
body: Center(
child: Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Login".tl, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
if (widget.config.cookieFields == null)
TextField(
decoration: InputDecoration(
labelText: "Username".tl,
border: const OutlineInputBorder(),
),
enabled: widget.config.login != null,
onChanged: (s) {
username = s;
},
autofillHints: const [AutofillHints.username],
).paddingBottom(16),
if (widget.config.cookieFields == null)
TextField(
decoration: InputDecoration(
labelText: "Password".tl,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.login != null,
onChanged: (s) {
password = s;
},
onSubmitted: (s) => login(),
autofillHints: const [AutofillHints.password],
).paddingBottom(16),
for (var field in widget.config.cookieFields ?? <String>[])
TextField(
decoration: InputDecoration(
labelText: field,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.validateCookies != null,
onChanged: (s) {
_cookies[field] = s;
},
).paddingBottom(16),
if (widget.config.login == null &&
widget.config.cookieFields == null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Text("Login with password is disabled".tl),
],
)
else
Button.filled(
isLoading: loading,
onPressed: login,
child: Text("Continue".tl),
),
const SizedBox(height: 24),
if (widget.config.loginWebsite != null)
TextButton(
onPressed: loginWithWebview,
child: Text("Login with webview".tl),
),
const SizedBox(height: 8),
if (widget.config.registerWebsite != null)
TextButton(
onPressed: () =>
launchUrlString(widget.config.registerWebsite!),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.link),
const SizedBox(width: 8),
Text("Create Account".tl),
],
),
),
],
),
),
),
),
);
}
void login() {
if (widget.config.login != null) {
if (username.isEmpty || password.isEmpty) {
showToast(
message: "Cannot be empty".tl,
icon: const Icon(Icons.error_outline),
context: context,
);
return;
}
setState(() {
loading = true;
});
widget.config.login!(username, password).then((value) {
if (value.error) {
context.showMessage(message: value.errorMessage!);
setState(() {
loading = false;
});
} else {
if (mounted) {
context.pop();
}
}
});
} else if (widget.config.validateCookies != null) {
setState(() {
loading = true;
});
var cookies =
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
widget.config.validateCookies!(cookies).then((value) {
if (value) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
} else {
context.showMessage(message: "Invalid cookies".tl);
setState(() {
loading = false;
});
}
});
}
}
void loginWithWebview() async {
var url = widget.config.loginWebsite!;
var title = '';
bool success = false;
void validate(InAppWebViewController c) async {
if (widget.config.checkLoginStatus != null
&& widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
widget.config.onLoginWithWebviewSuccess?.call();
App.mainNavigatorKey?.currentContext?.pop();
}
}
await context.to(
() => AppWebview(
initialUrl: widget.config.loginWebsite!,
onNavigation: (u, c) {
url = u;
validate(c);
return false;
},
onTitleChange: (t, c) {
title = t;
validate(c);
},
),
);
if (success) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
}
}
}

View File

@@ -0,0 +1,246 @@
import "package:flutter/material.dart";
import 'package:shimmer_animation/shimmer_animation.dart';
import "package:venera/components/components.dart";
import "package:venera/foundation/app.dart";
import "package:venera/foundation/appdata.dart";
import "package:venera/foundation/comic_source/comic_source.dart";
import "package:venera/pages/search_result_page.dart";
import "package:venera/utils/translations.dart";
class AggregatedSearchPage extends StatefulWidget {
const AggregatedSearchPage({super.key, required this.keyword});
final String keyword;
@override
State<AggregatedSearchPage> createState() => _AggregatedSearchPageState();
}
class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
late final List<ComicSource> sources;
late final SearchBarController controller;
var _keyword = "";
@override
void initState() {
var all = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
var settings = appdata.settings['searchSources'] as List;
var sources = <String>[];
for (var source in settings) {
if (all.contains(source)) {
sources.add(source);
}
}
this.sources = sources.map((e) => ComicSource.find(e)!).toList();
_keyword = widget.keyword;
controller = SearchBarController(
currentText: widget.keyword,
onSearch: (text) {
setState(() {
_keyword = text;
});
},
);
super.initState();
}
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(slivers: [
SliverSearchBar(controller: controller),
SliverList(
key: ValueKey(_keyword),
delegate: SliverChildBuilderDelegate(
(context, index) {
final source = sources[index];
return _SliverSearchResult(
key: ValueKey(source.key),
source: source,
keyword: _keyword,
);
},
childCount: sources.length,
),
),
]);
}
}
class _SliverSearchResult extends StatefulWidget {
const _SliverSearchResult({
required this.source,
required this.keyword,
super.key,
});
final ComicSource source;
final String keyword;
@override
State<_SliverSearchResult> createState() => _SliverSearchResultState();
}
class _SliverSearchResultState extends State<_SliverSearchResult>
with AutomaticKeepAliveClientMixin {
bool isLoading = true;
static const _kComicHeight = 132.0;
get _comicWidth => _kComicHeight * 0.7;
static const _kLeftPadding = 16.0;
List<Comic>? comics;
String? error;
void load() async {
final data = widget.source.searchPageData!;
var options =
(data.searchOptions ?? []).map((e) => e.defaultValue).toList();
if (data.loadPage != null) {
var res = await data.loadPage!(widget.keyword, 1, options);
if (!res.error) {
setState(() {
comics = res.data;
isLoading = false;
});
} else {
setState(() {
error = res.errorMessage ?? "Unknown error".tl;
isLoading = false;
});
}
} else if (data.loadNext != null) {
var res = await data.loadNext!(widget.keyword, null, options);
if (!res.error) {
setState(() {
comics = res.data;
isLoading = false;
});
} else {
setState(() {
error = res.errorMessage ?? "Unknown error".tl;
isLoading = false;
});
}
}
}
@override
void initState() {
super.initState();
load();
}
Widget buildPlaceHolder() {
return Container(
height: _kComicHeight,
width: _comicWidth,
margin: const EdgeInsets.only(left: _kLeftPadding),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(8),
),
);
}
Widget buildComic(Comic c) {
return SimpleComicTile(comic: c)
.paddingLeft(_kLeftPadding)
.paddingBottom(2);
}
@override
Widget build(BuildContext context) {
if (error != null && error!.startsWith("CloudflareException")) {
error = "Cloudflare verification required".tl;
}
super.build(context);
return InkWell(
onTap: () {
context.to(
() => SearchResultPage(
text: widget.keyword,
sourceKey: widget.source.key,
),
);
},
child: Column(
children: [
ListTile(
mouseCursor: SystemMouseCursors.click,
title: Text(widget.source.name),
),
if (isLoading)
SizedBox(
height: _kComicHeight,
width: double.infinity,
child: Shimmer(
child: LayoutBuilder(builder: (context, constrains) {
var itemWidth = _comicWidth + _kLeftPadding;
var items = (constrains.maxWidth / itemWidth).ceil();
return Stack(
children: [
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Row(
children: List.generate(
items,
(index) => buildPlaceHolder(),
),
),
)
],
);
}),
),
)
else if (error != null || comics == null || comics!.isEmpty)
SizedBox(
height: _kComicHeight,
child: Column(
children: [
Row(
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Expanded(
child: Text(
error ?? "No search results found".tl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
],
),
const Spacer(),
],
).paddingHorizontal(16),
)
else
SizedBox(
height: _kComicHeight,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (var c in comics!) buildComic(c),
],
),
),
],
).paddingBottom(16),
);
}
@override
bool get wantKeepAlive => true;
}

71
lib/pages/auth_page.dart Normal file
View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:venera/utils/translations.dart';
class AuthPage extends StatefulWidget {
const AuthPage({super.key, this.onSuccessfulAuth});
final void Function()? onSuccessfulAuth;
@override
State<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
@override
void initState() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if(SchedulerBinding.instance.lifecycleState != AppLifecycleState.paused) {
auth();
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
SystemNavigator.pop();
}
},
child: Material(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.security, size: 36),
const SizedBox(height: 16),
Text("Authentication Required".tl),
const SizedBox(height: 16),
FilledButton(
onPressed: auth,
child: Text("Continue".tl),
),
],
),
),
),
);
}
void auth() async {
var localAuth = LocalAuthentication();
var canCheckBiometrics = await localAuth.canCheckBiometrics;
if (!canCheckBiometrics && !await localAuth.isDeviceSupported()) {
widget.onSuccessfulAuth?.call();
return;
}
var isAuthorized = await localAuth.authenticate(
localizedReason: "Please authenticate to continue".tl,
);
if (isAuthorized) {
widget.onSuccessfulAuth?.call();
}
}
}

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