319 Commits

Author SHA1 Message Date
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
nyne
42ded1221a Merge pull request #22 from venera-app/dev
v 1.0.3
2024-11-07 10:26:40 +08:00
a9a22ace14 update README.md 2024-11-07 10:20:50 +08:00
99bbea80dc update version code 2024-11-07 09:56:10 +08:00
26fa41f503 improve translation 2024-11-07 09:41:14 +08:00
082aa36316 improve reader; fix #21 2024-11-07 09:31:57 +08:00
5a14ea48c1 fix changing search target in search result page 2024-11-07 09:02:03 +08:00
5d43f5c556 fix favorites page 2024-11-07 08:59:49 +08:00
e51a58ba4f fix deleting files when canceling a task 2024-11-07 08:49:32 +08:00
5234de434a improve network log 2024-11-06 22:08:23 +08:00
22f2ac99ad fix http 2024-11-06 18:06:20 +08:00
b08b5d0abe update action 2024-11-06 17:43:36 +08:00
nyne
96c6323c07 Merge pull request #18 from venera-app/dev
v1.0.2-patch
2024-11-06 09:21:29 +08:00
ae80715db1 update windows build script 2024-11-06 08:57:06 +08:00
3d7f30af00 update .gitignore 2024-11-06 08:53:57 +08:00
f12cb55bbc update windows build script 2024-11-06 08:51:20 +08:00
150 changed files with 14256 additions and 3039 deletions

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,10 +1,13 @@
name: Build IOS
run-name: Build IOS
name: Build ALL
run-name: Build ALL
on:
workflow_dispatch: {}
release:
types: [published]
jobs:
Build_MacOS:
runs-on: macos-13
runs-on: macos-15
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
@@ -12,7 +15,7 @@ jobs:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_14.3.1.app
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
- run: flutter pub get
# Step 1: Decode and install the certificate
- name: Decode and install certificate
@@ -38,12 +41,12 @@ jobs:
# Step 4: Attach and upload artifacts (optional)
- name: Upload DMG
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: venera.dmg
path: dist/venera.dmg
Build_IOS:
runs-on: macos-13
runs-on: macos-15
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
@@ -51,7 +54,7 @@ jobs:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_14.3.1.app
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
- run: flutter pub get
- run: flutter build ios --release --no-codesign
- run: |
@@ -63,3 +66,121 @@ jobs:
with:
name: app-ios.ipa
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa
Build_Android:
runs-on: ubuntu-latest
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'
- 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-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/
Release:
runs-on: ubuntu-latest
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux]
if: github.event_name == 'release' # 仅在 push 事件时执行
steps:
- uses: actions/download-artifact@v4
with:
name: venera.dmg
path: outputs
- uses: actions/download-artifact@v4
with:
name: app-ios.ipa
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: 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
env:
GITHUB_TOKEN: ${{ secrets.ACTION_GITHUB_TOKEN }}

6
.gitignore vendored
View File

@@ -15,6 +15,7 @@ migrate_working_dir/
*.ipr
*.iws
.idea/
.vscode/
# 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
@@ -42,4 +43,7 @@ app.*.map.json
/android/app/profile
/android/app/release
./add_translation.py
add_translation.py
*/*/generated_*
*/*/Generated*

View File

@@ -1,12 +1,17 @@
# venera
[![flutter](https://img.shields.io/badge/flutter-3.24.4-blue)](https://flutter.dev/)
[![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/)
[![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![stars](https://img.shields.io/github/stars/venera-app/venera)](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)
A comic reader that support reading local and network comics.
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
## Features
- Read local comics

View File

@@ -1,31 +0,0 @@
import re
import json
path='./assets/translation.json'
with open(path, 'r',encoding='utf-8') as file:
translations=json.load(file)
while True:
line=input()
if line=="q":
break
words=line.split('-')
if len(words)!=3:
print("invalid entry:",line,"(len(words) != 3)"
continue
en=words[0]
cn=words[1]
tw=words[2]
translations["zh_CN"][en]=cn
translations["zh_TW"][en]=tw
with open(path, 'w',encoding='utf-8') as file:
json.dump(translations, file, indent=2,ensure_ascii=False)

1
android/.gitignore vendored
View File

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

View File

@@ -5,6 +5,8 @@ plugins {
id "dev.flutter.flutter-gradle-plugin"
}
ext.abiCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86_64": 3]
def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
@@ -34,6 +36,8 @@ android {
splits{
abi {
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
enable true
universalApk true
}
@@ -76,7 +80,7 @@ android {
buildTypes {
release {
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
}
signingConfig signingConfigs.release
applicationVariants.all { variant ->
@@ -84,13 +88,25 @@ android {
def abi = output.getFilter(com.android.build.OutputFile.ABI)
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
}
}
}
}
}
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
}
flutter {
@@ -100,4 +116,4 @@ flutter {
dependencies {
implementation "androidx.activity:activity-ktx:1.9.2"
implementation 'androidx.documentfile:documentfile:1.0.1'
}
}

View File

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

View File

@@ -1,49 +1,69 @@
package com.github.wgh136.venera
import android.Manifest
import android.app.Activity
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.PackageManager
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 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 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.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant
import java.io.File
import java.io.FileOutputStream
import java.lang.Exception
import java.util.concurrent.atomic.AtomicInteger
class MainActivity : FlutterActivity() {
class MainActivity : FlutterFragmentActivity() {
var volumeListen = VolumeListen()
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?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == pickDirectoryCode) {
if(resultCode != Activity.RESULT_OK) {
result.success(null)
return
}
val pickedDirectoryUri = data?.data
if (pickedDirectoryUri == null) {
result.success(null)
return
}
Thread {
try {
result.success(onPickedDirectory(pickedDirectoryUri))
private fun <I, O> startContractForResult(
contract: ActivityResultContract<I, O>,
input: I,
callback: ActivityResultCallback<O>
) {
val key = "activity_rq_for_result#${nextLocalRequestCode.getAndIncrement()}"
val registry = activityResultRegistry
var launcher: ActivityResultLauncher<I>? = null
val observer = object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (Lifecycle.Event.ON_DESTROY == event) {
launcher?.unregister()
lifecycle.removeObserver(this)
}
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) {
@@ -63,12 +83,23 @@ class MainActivity : FlutterActivity() {
}
res.success(null)
}
"getDirectoryPath" -> {
this.result = res
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)
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()
}
}
@@ -85,10 +116,24 @@ class MainActivity : FlutterActivity() {
events.success(2)
}
}
override fun onCancel(arguments: Any?) {
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 {
@@ -102,12 +147,13 @@ class MainActivity : FlutterActivity() {
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if(listening){
if (listening) {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
volumeListen.down()
return true
}
KeyEvent.KEYCODE_VOLUME_UP -> {
volumeListen.up()
return true
@@ -117,43 +163,199 @@ class MainActivity : FlutterActivity() {
return super.onKeyDown(keyCode, event)
}
/// copy the directory to tmp directory, return copied directory
private fun onPickedDirectory(uri: Uri): String {
val contentResolver = context.contentResolver
var tmp = context.cacheDir
tmp = File(tmp, "getDirectoryPathTemp")
/// Ensure that the directory is accessible by dart:io
private fun onPickedDirectory(uri: Uri, result: MethodChannel.Result) {
if (hasStoragePermission()) {
var plain = uri.toString()
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()
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) {
val src = DocumentFile.fromTreeUri(context, srcUri) ?: return
val src = DocumentFile.fromTreeUri(this, srcUri) ?: return
for (file in src.listFiles()) {
if(file.isDirectory) {
if (file.isDirectory) {
val newDir = File(destDir, file.name!!)
newDir.mkdir()
copyDirectory(resolver, file.uri, newDir)
} else {
val newFile = File(destDir, file.name!!)
val inputStream = resolver.openInputStream(file.uri) ?: return
val outputStream = FileOutputStream(newFile)
inputStream.copyTo(outputStream)
inputStream.close()
outputStream.close()
resolver.openInputStream(file.uri)?.use { input ->
FileOutputStream(newFile).use { output ->
input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE)
output.flush()
}
}
}
}
}
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 onDown = fun() {}
fun up(){
fun up() {
onUp()
}
fun down(){
fun down() {
onDown()
}
}

View File

@@ -3,4 +3,4 @@ android.useAndroidX=true
android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
android.nonFinalResIds=false

View File

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

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins {
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
}

View File

@@ -4,6 +4,13 @@ Venera JavaScript Library
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
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.
*/
@@ -699,7 +737,7 @@ class HtmlElement {
doc: this.doc,
})
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 title {string}
* @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string}
* @param tags {string[]}
* @param description {string}
@@ -859,10 +898,11 @@ let console = {
* @param stars {number?} - 0-5, double
* @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.title = title;
this.subtitle = subtitle;
this.subTitle = subTitle;
this.cover = cover;
this.tags = tags;
this.description = description;
@@ -875,11 +915,13 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language
/**
* 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 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
@@ -892,10 +934,12 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language
* @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, 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.subtitle = subtitle ?? subTitle;
this.cover = cover;
this.description = description;
this.tags = tags;
@@ -913,6 +957,7 @@ function ComicDetails({title, cover, description, tags, chapters, isFavorite, su
this.url = url;
this.stars = stars;
this.maxPage = maxPage;
this.comments = comments;
}
/**
@@ -940,6 +985,33 @@ function Comment({userName, avatar, content, time, replyCount, id, isLiked, scor
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 {
name = ""
@@ -1132,3 +1204,45 @@ class Image {
return new Image(key);
}
}
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}[]}
*/
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,
})
},
}

View File

@@ -17,7 +17,8 @@
"Multiple Comics": "多个漫画",
"help": "帮助",
"Select": "选择",
"Imported @a comics": "已导入 @a 部漫画",
"Selected @a comics": "已选择 @a 部漫画",
"Imported @a comics, loaded @b pages, received @c comics": "已导入 @a 部漫画, 加载 @b 页, 接收到 @c 部漫画",
"Downloading": "下载中",
"Back": "后退",
"Delete": "删除",
@@ -40,11 +41,19 @@
"Select a folder": "选择一个文件夹",
"Folder": "文件夹",
"Confirm": "确认",
"Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?",
"Add comic source": "添加漫画来源",
"Reversed successfully": "反转成功",
"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": "选择文件",
"View list": "查看列表",
"Open help": "打开帮助",
"Open in Browser": "打开网页",
"Check updates": "检查更新",
"Edit": "编辑",
"Update": "更新",
@@ -97,10 +106,11 @@
"Continuous (Right to Left)": "连续(从右到左)",
"Continuous (Top to Bottom)": "连续(从上到下)",
"Auto page turning interval": "自动翻页间隔",
"The number of pic in screen (Only Gallery Mode)": "同屏幕图片数量(仅画廊模式)",
"Theme Mode": "主题模式",
"System": "系统",
"Light": "明亮",
"Dark": "黑暗",
"Light": "浅色",
"Dark": "深色",
"Theme Color": "主题颜色",
"Red": "红色",
"Pink": "粉色",
@@ -129,7 +139,8 @@
"Block": "屏蔽",
"Add new favorite to": "添加新收藏到",
"Move favorite after reading": "阅读后移动收藏",
"Are you sure you want to delete this folder?" : "确定要删除这个收藏夹吗",
"Delete folder?" : "刪除文件夾",
"Delete folder '@f' ?" : "删除文件夹 '@f' ",
"Import from file": "从文件导入",
"Failed to import": "导入失败",
"Cache Limit": "缓存限制",
@@ -141,10 +152,10 @@
"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." : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n",
"Export as cbz": "导出为cbz",
"Select a cbz file." : "选择一个cbz文件",
"A cbz file" : "一个cbz文件",
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一个归档文件",
"Fullscreen": "全屏",
"Exit": "退出",
"View more": "查看更多",
@@ -154,7 +165,7 @@
"Date Desc": "日期降序",
"Start": "开始",
"Export App Data": "导出应用数据",
"Import App Data": "导入应用数据",
"Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)",
"Export": "导出",
"Download Threads": "下载线程数",
"Update Time": "更新时间",
@@ -169,7 +180,145 @@
"minAppVersion @version is required": "需要最低App版本 @version",
"Remove": "移除",
"Long press to zoom": "长按缩放",
"Updates Available": "更新可用"
"Updates Available": "更新可用",
"Unselected": "未选择",
"Long press and drag to reorder.": "长按并拖动以重新排序。",
"Limit image width": "限制图片宽度",
"When using Continuous(Top to Bottom) mode": "当使用连续(从上到下)模式",
"Open link": "打开链接",
"Open comic": "打开漫画",
"Move To First": "移动到最前",
"Cancel": "取消",
"Paused": "已暂停",
"Pause": "暂停",
"Operation": "操作",
"Upload": "上传",
"Saved": "已保存",
"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": "没有探索页面",
"Add a comic source in home page": "在主页添加一个漫画源",
"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": "无更新"
},
"zh_TW": {
"Home": "首頁",
@@ -190,7 +339,8 @@
"Multiple Comics": "多部漫畫",
"help": "幫助",
"Select": "選擇",
"Imported @a comics": "已匯入 @a 部漫畫",
"Selected @a comics": "已選擇 @a 部漫畫",
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 加載 @b 頁, 接收到 @c 部漫畫",
"Downloading": "下載中",
"Back": "後退",
"Delete": "刪除",
@@ -214,11 +364,18 @@
"Select a folder": "選擇一個文件夾",
"Folder": "文件夾",
"Confirm": "確認",
"Are you sure you want to delete this comic?": "您確定要刪除這部漫畫",
"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": "選擇文件",
"View list": "查看列表",
"Open help": "打開幫助",
"Open in Browser": "打開網頁",
"Check updates": "檢查更新",
"Edit": "編輯",
"Update": "更新",
@@ -269,10 +426,11 @@
"Continuous (Right to Left)": "連續(從右到左)",
"Continuous (Top to Bottom)": "連續(從上到下)",
"Auto page turning interval": "自動翻頁間隔",
"The number of pic in screen (Only Gallery Mode)": "同螢幕圖片數量(僅畫廊模式)",
"Theme Mode": "主題模式",
"System": "系統",
"Light": "明亮",
"Dark": "黑暗",
"Light": "浅色",
"Dark": "深色",
"Theme Color": "主題顏色",
"Red": "紅色",
"Pink": "粉色",
@@ -301,7 +459,8 @@
"Block": "屏蔽",
"Add new favorite to": "添加新收藏到",
"Move favorite after reading": "閱讀後移動收藏",
"Are you sure you want to delete this folder?" : "確定要刪除這個收藏夾嗎",
"Delete folder?" : "刪除文件夾",
"Delete folder '@f' ?" : "刪除文件夾 '@f' ",
"Import from file": "從文件匯入",
"Failed to import": "匯入失敗",
"Cache Limit": "緩存限制",
@@ -313,10 +472,10 @@
"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." : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n",
"Export as cbz": "匯出為cbz",
"Select a cbz file." : "選擇一個cbz文件",
"A cbz file" : "一個cbz文件",
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一個歸檔文件",
"Fullscreen": "全螢幕",
"Exit": "退出",
"View more": "查看更多",
@@ -325,8 +484,9 @@
"Date": "日期",
"Date Desc": "日期降序",
"Start": "開始",
"Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據",
"Import App Data": "匯入應用數據",
"Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)",
"Export": "匯出",
"Download Threads": "下載線程數",
"Update Time": "更新時間",
@@ -341,6 +501,144 @@
"minAppVersion @version is required": "需要最低App版本 @version",
"Remove": "移除",
"Long press to zoom": "長按縮放",
"Updates Available": "更新可用"
"Updates Available": "更新可用",
"Unselected": "未選擇",
"Long press and drag to reorder.": "長按並拖動以重新排序。",
"Limit image width": "限制圖片寬度",
"When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式",
"Open link": "打開鏈接",
"Open comic": "打開漫畫",
"Move To First": "移動到最前",
"Cancel": "取消",
"Paused": "已暫停",
"Pause": "暫停",
"Operation": "操作",
"Upload": "上傳",
"Saved": "已保存",
"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": "沒有探索頁面",
"Add a comic source in home page": "在主頁添加一個漫畫源",
"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": "無更新"
}
}

View File

@@ -1,5 +1,4 @@
[Desktop Entry]
Version={{Version}}
Name=Venera
GenericName=Venera
Comment=venera

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
platform :ios, '14.0'
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -15,6 +15,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
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 */
/* Begin PBXContainerItemProxy section */
@@ -59,6 +60,7 @@
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>"; };
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>"; };
/* End PBXFileReference section */
@@ -133,6 +135,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */,
);
path = Runner;
sourceTree = "<group>";
@@ -144,7 +147,6 @@
730F73FE38E23FCF3E461640 /* Pods-Runner.release.xcconfig */,
29B89F848F26E839605E1D88 /* Pods-Runner.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -336,6 +338,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C0086D072CDEFE6E004596D9 /* DirectoryPicker.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);

View File

@@ -1,12 +1,16 @@
import Flutter
import UIKit
import UniformTypeIdentifiers
import Foundation //
@main
@objc class AppDelegate: FlutterAppDelegate, UIDocumentPickerDelegate {
var flutterResult: FlutterResult?
var directoryPath: URL!
//
private var directoryPicker: DirectoryPicker?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -42,6 +46,9 @@ import UniformTypeIdentifiers
self.directoryPath?.stopAccessingSecurityScopedResource()
self.directoryPath = nil
result(nil)
} else if call.method == "selectDirectory" {
self.directoryPicker = DirectoryPicker()
self.directoryPicker?.selectDirectory(result: result)
} else {
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>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose images</string>
<string>Choose images</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Ensure that the operation is being performed by the user themselves.</string>
</dict>
</plist>

View File

@@ -76,7 +76,7 @@ class _AppbarState extends State<Appbar> {
var content = Container(
decoration: BoxDecoration(
color: widget.backgroundColor ??
context.colorScheme.surface.withOpacity(0.72),
context.colorScheme.surface.toOpacity(0.72),
),
height: _kAppBarHeight + context.padding.top,
child: Row(
@@ -115,6 +115,11 @@ class _AppbarState extends State<Appbar> {
}
}
enum AppbarStyle {
blur,
shadow,
}
class SliverAppbar extends StatelessWidget {
const SliverAppbar({
super.key,
@@ -122,6 +127,7 @@ class SliverAppbar extends StatelessWidget {
this.leading,
this.actions,
this.radius = 0,
this.style = AppbarStyle.blur,
});
final Widget? leading;
@@ -132,6 +138,8 @@ class SliverAppbar extends StatelessWidget {
final double radius;
final AppbarStyle style;
@override
Widget build(BuildContext context) {
return SliverPersistentHeader(
@@ -142,6 +150,7 @@ class SliverAppbar extends StatelessWidget {
actions: actions,
topPadding: MediaQuery.of(context).padding.top,
radius: radius,
style: style,
),
);
}
@@ -160,57 +169,73 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
final double radius;
_MySliverAppBarDelegate(
{this.leading,
required this.title,
this.actions,
required this.topPadding,
this.radius = 0});
final AppbarStyle style;
_MySliverAppBarDelegate({
this.leading,
required this.title,
this.actions,
required this.topPadding,
this.radius = 0,
this.style = AppbarStyle.blur,
});
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return SizedBox.expand(
child: BlurEffect(
blur: 15,
child: Material(
color: context.colorScheme.surface.withOpacity(0.72),
elevation: 0,
borderRadius: BorderRadius.circular(radius),
child: Row(
children: [
const SizedBox(width: 8),
leading ??
(Navigator.of(context).canPop()
? Tooltip(
message: "Back".tl,
child: IconButton(
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),
var body = Row(
children: [
const SizedBox(width: 8),
leading ??
(Navigator.of(context).canPop()
? Tooltip(
message: "Back".tl,
child: IconButton(
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);
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
@@ -224,7 +249,10 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
return oldDelegate is! _MySliverAppBarDelegate ||
leading != oldDelegate.leading ||
title != oldDelegate.title ||
actions != oldDelegate.actions;
actions != oldDelegate.actions ||
topPadding != oldDelegate.topPadding ||
radius != oldDelegate.radius ||
style != oldDelegate.style;
}
}
@@ -269,12 +297,21 @@ class _FilledTabBarState extends State<FilledTabBar> {
super.dispose();
}
PageStorageBucket get bucket => PageStorage.of(context);
@override
void didChangeDependencies() {
_controller = widget.controller ?? DefaultTabController.of(context);
_controller.animation!.addListener(onTabChanged);
initPainter();
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
@@ -303,7 +340,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
animation: _controller.animation ?? _controller,
builder: buildTabBar,
);
}
@@ -318,6 +355,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
controller: scrollController,
builder: (context, controller, physics) {
return SingleChildScrollView(
key: const PageStorageKey('scroll'),
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
controller: controller,
@@ -358,6 +396,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
}
updateScrollOffset(i);
previousIndex = i;
bucket.writeState(context, i);
}
void updateScrollOffset(int i) {
@@ -369,10 +408,14 @@ class _FilledTabBarState extends State<FilledTabBar> {
final double tabWidth = tabRight - tabLeft;
final double tabCenter = tabLeft + tabWidth / 2;
final double tabBarWidth = tabBarBox.size.width;
final double scrollOffset = tabCenter - tabBarWidth / 2;
double scrollOffset = tabCenter - tabBarWidth / 2;
if (scrollOffset == scrollController.offset) {
return;
}
scrollOffset = scrollOffset.clamp(
0.0,
scrollController.position.maxScrollExtent,
);
scrollController.animateTo(
scrollOffset,
duration: const Duration(milliseconds: 200),
@@ -394,7 +437,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
padding: const EdgeInsets.symmetric(horizontal: 16),
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
color: i == _controller.index
color: i == _controller.animation?.value.round()
? context.colorScheme.primary
: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
@@ -534,6 +577,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);
_controller.addListener(updateIndex);
}
@override
void dispose() {
super.dispose();
_controller.removeListener(updateIndex);
}
@override
Widget build(BuildContext context) {
return widget.children[_currentIndex];
}
}
class SearchBarController {
_SearchBarMixin? _state;
@@ -691,6 +779,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate {
icon: const Icon(Icons.clear),
onPressed: () {
editingController.clear();
onChanged?.call("");
},
);
},

View File

@@ -156,7 +156,7 @@ class _ButtonState extends State<Button> {
@override
Widget build(BuildContext context) {
var padding = widget.padding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 4);
const EdgeInsets.symmetric(horizontal: 16);
var width = widget.width;
if (width != null) {
width = width - padding.horizontal;
@@ -206,6 +206,7 @@ class _ButtonState extends State<Button> {
padding: padding,
constraints: const BoxConstraints(
minWidth: 76,
minHeight: 32,
),
decoration: BoxDecoration(
color: buttonColor,
@@ -213,7 +214,7 @@ class _ButtonState extends State<Button> {
boxShadow: (isHover && !isLoading && (widget.type == ButtonType.filled || widget.type == ButtonType.normal))
? [
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Colors.black.toOpacity(0.1),
blurRadius: 2,
offset: const Offset(0, 1),
)
@@ -247,7 +248,7 @@ class _ButtonState extends State<Button> {
if (widget.type == ButtonType.filled) {
var color = widget.color ?? context.colorScheme.primary;
if (isHover) {
return color.withOpacity(0.9);
return color.toOpacity(0.9);
} else {
return color;
}
@@ -255,13 +256,13 @@ class _ButtonState extends State<Button> {
if (widget.type == ButtonType.normal) {
var color = widget.color ?? context.colorScheme.surfaceContainer;
if (isHover) {
return color.withOpacity(0.9);
return color.toOpacity(0.9);
} else {
return color;
}
}
if (isHover) {
return context.colorScheme.outline.withOpacity(0.2);
return context.colorScheme.outline.toOpacity(0.2);
}
return Colors.transparent;
}
@@ -344,7 +345,7 @@ class _IconButtonState extends State<_IconButton> {
? Theme.of(context)
.colorScheme
.outlineVariant
.withOpacity(0.4)
.toOpacity(0.4)
: null,
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: 32,
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,14 +1,36 @@
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 {
const ComicTile({
super.key,
required this.comic,
this.enableLongPressed = true,
this.badge,
this.menuOptions,
this.onTap,
});
const ComicTile(
{super.key,
required this.comic,
this.enableLongPressed = true,
this.badge,
this.menuOptions,
this.onTap,
this.onLongPressed});
final Comic comic;
@@ -20,20 +42,36 @@ class ComicTile extends StatelessWidget {
final VoidCallback? onTap;
final VoidCallback? onLongPressed;
void _onTap() {
if (onTap != null) {
onTap!();
return;
}
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
App.mainNavigatorKey?.currentContext?.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
),
);
}
void _onLongPressed(context) {
if (onLongPressed != null) {
onLongPressed!();
return;
}
onLongPress(context);
}
void onLongPress(BuildContext context) {
var renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
var location = renderBox.localToGlobal(
Offset(size.width / 2, size.height / 2),
Offset((size.width - 242) / 2, size.height / 2),
);
showMenu(location, context);
}
@@ -51,8 +89,14 @@ class ComicTile extends StatelessWidget {
icon: Icons.chrome_reader_mode_outlined,
text: 'Details'.tl,
onClick: () {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
App.mainNavigatorKey?.currentContext?.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
),
);
},
),
MenuEntry(
@@ -67,7 +111,7 @@ class ComicTile extends StatelessWidget {
icon: Icons.stars_outlined,
text: 'Add to favorites'.tl,
onClick: () {
addFavorite(comic);
addFavorite([comic]);
},
),
MenuEntry(
@@ -134,7 +178,7 @@ class ComicTile extends StatelessWidget {
if (history != null)
Container(
height: 24,
color: Colors.blue.withOpacity(0.9),
color: Colors.blue.toOpacity(0.9),
constraints: const BoxConstraints(minWidth: 24),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: CustomPaint(
@@ -151,16 +195,9 @@ class ComicTile extends StatelessWidget {
}
Widget buildImage(BuildContext context) {
ImageProvider image;
if (comic is LocalComic) {
image = FileImage((comic as LocalComic).coverFile);
} 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);
var image = _findImageProvider(comic);
if (image == null) {
return const SizedBox();
}
return AnimatedImage(
image: image,
@@ -176,21 +213,31 @@ class ComicTile extends StatelessWidget {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _onTap,
onLongPress: enableLongPressed ? () => onLongPress(context) : null,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row(
children: [
Container(
width: height * 0.68,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
width: height * 0.68,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
SizedBox.fromSize(
size: const Size(16, 5),
@@ -218,85 +265,169 @@ class ComicTile extends StatelessWidget {
}
Widget _buildBriefMode(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
elevation: 1,
child: Stack(
children: [
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.3),
Colors.black.withOpacity(0.5),
]),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Text(
comic.title.replaceAll("\n", ""),
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14.0,
color: Colors.white,
return LayoutBuilder(
builder: (context, constraints) {
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _onTap,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Column(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.toOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
)),
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(),
Align(
alignment: Alignment.bottomRight,
child: (() {
final subtitle =
comic.subtitle?.replaceAll('\n', '').trim();
final text = comic.description.isNotEmpty
? comic.description.split('|').join('\n')
: (subtitle?.isNotEmpty == true ? subtitle : null);
final fortSize = constraints.maxWidth < 80
? 8.0
: constraints.maxWidth < 150
? 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,
);
})(),
),
],
),
),
)
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
child: TextScroll(
comic.title.replaceAll('\n', ''),
mode: TextScrollMode.endless,
style: const TextStyle(
fontWeight: FontWeight.w500,
),
delayBefore: Duration(milliseconds: 500),
velocity: const Velocity(pixelsPerSecond: Offset(40, 0)),
),
),
],
).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) {
showDialog(
context: App.rootContext,
builder: (context) {
var words = <String>[];
var all = <String>[];
all.addAll(comic.title.split(' ').where((element) => element != ''));
all.addAll(_splitText(comic.title));
if (comic.subtitle != null && comic.subtitle != "") {
all.add(comic.subtitle!);
}
@@ -304,26 +435,33 @@ class ComicTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: 'Block'.tl,
content: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (var word in all)
OptionChip(
text: word,
isSelected: words.contains(word),
onTap: () {
setState(() {
if (!words.contains(word)) {
words.add(word);
} else {
words.remove(word);
}
});
},
),
],
).paddingHorizontal(16),
content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: math.min(400, context.height - 136),
),
child: SingleChildScrollView(
child: Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (var word in all)
OptionChip(
text: word,
isSelected: words.contains(word),
onTap: () {
setState(() {
if (!words.contains(word)) {
words.add(word);
} else {
words.remove(word);
}
});
},
),
],
),
).paddingHorizontal(16),
),
actions: [
Button.filled(
onPressed: () {
@@ -396,7 +534,7 @@ class _ComicDescription extends StatelessWidget {
subtitle,
style: TextStyle(
fontSize: 10.0,
color: context.colorScheme.onSurface.withOpacity(0.7)),
color: context.colorScheme.onSurface.toOpacity(0.7)),
maxLines: 1,
softWrap: true,
overflow: TextOverflow.ellipsis,
@@ -476,18 +614,17 @@ class _ComicDescription extends StatelessWidget {
),
if (badge != null)
Container(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Center(
child:Text(
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
style: const TextStyle(fontSize: 12),
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
)
),
child: Center(
child: Text(
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
style: const TextStyle(fontSize: 12),
),
)),
],
)
],
@@ -571,17 +708,20 @@ class _ReadingHistoryPainter extends CustomPainter {
}
class SliverGridComics extends StatefulWidget {
const SliverGridComics({
super.key,
required this.comics,
this.onLastItemBuild,
this.badgeBuilder,
this.menuBuilder,
this.onTap,
});
const SliverGridComics(
{super.key,
required this.comics,
this.onLastItemBuild,
this.badgeBuilder,
this.menuBuilder,
this.onTap,
this.onLongPressed,
this.selections});
final List<Comic> comics;
final Map<Comic, bool>? selections;
final void Function()? onLastItemBuild;
final String? Function(Comic)? badgeBuilder;
@@ -590,6 +730,8 @@ class SliverGridComics extends StatefulWidget {
final void Function(Comic)? onTap;
final void Function(Comic)? onLongPressed;
@override
State<SliverGridComics> createState() => _SliverGridComicsState();
}
@@ -617,9 +759,16 @@ class _SliverGridComicsState extends State<SliverGridComics> {
comics.add(comic);
}
}
HistoryManager().addListener(update);
super.initState();
}
@override
void dispose() {
HistoryManager().removeListener(update);
super.dispose();
}
void update() {
setState(() {
comics.clear();
@@ -635,10 +784,12 @@ class _SliverGridComicsState extends State<SliverGridComics> {
Widget build(BuildContext context) {
return _SliverGridComics(
comics: comics,
selection: widget.selections,
onLastItemBuild: widget.onLastItemBuild,
badgeBuilder: widget.badgeBuilder,
menuBuilder: widget.menuBuilder,
onTap: widget.onTap,
onLongPressed: widget.onLongPressed,
);
}
}
@@ -650,10 +801,14 @@ class _SliverGridComics extends StatelessWidget {
this.badgeBuilder,
this.menuBuilder,
this.onTap,
this.onLongPressed,
this.selection,
});
final List<Comic> comics;
final Map<Comic, bool>? selection;
final void Function()? onLastItemBuild;
final String? Function(Comic)? badgeBuilder;
@@ -662,6 +817,8 @@ class _SliverGridComics extends StatelessWidget {
final void Function(Comic)? onTap;
final void Function(Comic)? onLongPressed;
@override
Widget build(BuildContext context) {
return SliverGrid(
@@ -671,11 +828,33 @@ class _SliverGridComics extends StatelessWidget {
onLastItemBuild?.call();
}
var badge = badgeBuilder?.call(comics[index]);
return ComicTile(
var isSelected =
selection == null ? false : selection![comics[index]] ?? false;
var comic = ComicTile(
comic: comics[index],
badge: badge,
menuOptions: menuBuilder?.call(comics[index]),
onTap: onTap != null ? () => onTap!(comics[index]) : null,
onLongPressed: onLongPressed != null
? () => onLongPressed!(comics[index])
: null,
);
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,
@@ -721,6 +900,9 @@ class ComicList extends StatefulWidget {
this.trailingSliver,
this.errorLeading,
this.menuBuilder,
this.controller,
this.refreshHandlerCallback,
this.enablePageStorage = false,
});
final Future<Res<List<Comic>>> Function(int page)? loadPage;
@@ -735,6 +917,12 @@ class ComicList extends StatefulWidget {
final List<MenuEntry> Function(Comic)? menuBuilder;
final ScrollController? controller;
final void Function(VoidCallback c)? refreshHandlerCallback;
final bool enablePageStorage;
@override
State<ComicList> createState() => ComicListState();
}
@@ -752,6 +940,55 @@ class ComicListState extends State<ComicList> {
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) {
if (_data[_page] == null || !_data[_page]!.remove(c)) {
for (var page in _data.values) {
@@ -874,7 +1111,7 @@ class ComicListState extends State<ComicList> {
try {
if (widget.loadPage != null) {
var res = await widget.loadPage!(page);
if(!mounted) return;
if (!mounted) return;
if (res.success) {
if (res.data.isEmpty) {
_data[page] = const [];
@@ -899,15 +1136,20 @@ class ComicListState extends State<ComicList> {
while (_data[page] == null) {
await _fetchNext();
}
setState(() {});
if (mounted) {
setState(() {});
}
} catch (e) {
setState(() {
_error = e.toString();
});
if (mounted) {
setState(() {
_error = e.toString();
});
}
}
}
} finally {
_loading[page] = false;
storeState();
}
}
@@ -956,6 +1198,8 @@ class ComicListState extends State<ComicList> {
);
}
return SmoothCustomScrollView(
key: enablePageStorage ? PageStorageKey('scroll$_page') : null,
controller: widget.controller,
slivers: [
if (widget.leadingSliver != null) widget.leadingSliver!,
if (_maxPage != 1) _buildSliverPageSelector(),
@@ -1189,7 +1433,7 @@ class _RatingWidgetState extends State<RatingWidget> {
}
if (full < widget.count) {
children.add(ClipRect(
clipper: SMClipper(rating: star() * widget.size),
clipper: _SMClipper(rating: star() * widget.size),
child: Icon(
Icons.star,
size: widget.size,
@@ -1238,10 +1482,10 @@ class _RatingWidgetState extends State<RatingWidget> {
}
}
class SMClipper extends CustomClipper<Rect> {
class _SMClipper extends CustomClipper<Rect> {
final double rating;
SMClipper({required this.rating});
_SMClipper({required this.rating});
@override
Rect getClip(Size size) {
@@ -1249,7 +1493,52 @@ class SMClipper extends CustomClipper<Rect> {
}
@override
bool shouldReclip(SMClipper oldClipper) {
bool shouldReclip(_SMClipper oldClipper) {
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:collection';
import 'dart:math' as math;
@@ -10,6 +8,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:syntax_highlight/syntax_highlight.dart';
import 'package:text_scroll/text_scroll.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/app_page_route.dart';
import 'package:venera/foundation/appdata.dart';
@@ -19,13 +19,14 @@ import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.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/res.dart';
import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
@@ -45,4 +46,5 @@ part 'select.dart';
part 'side_bar.dart';
part 'comic.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
State<Flyout> createState() => FlyoutState();
static FlyoutState of(BuildContext context) {
return context.findAncestorStateOfType<FlyoutState>()!;
}
}
class FlyoutState extends State<Flyout> {
@@ -137,7 +141,7 @@ class FlyoutState extends State<Flyout> {
animation: animation,
builder: (context, builder) {
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(
borderRadius: BorderRadius.circular(8),
type: MaterialType.card,
color: context.colorScheme.surface.withOpacity(0.82),
color: context.colorScheme.surface.toOpacity(0.82),
child: Container(
constraints: const BoxConstraints(
minWidth: minFlyoutWidth,
),
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(
mainAxisSize: MainAxisSize.min,
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';
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;
@@ -20,3 +21,66 @@ 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: AnimatedContainer(
duration: _fastAnimationDuration,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.borderRadius),
boxShadow: isHovered
? [
BoxShadow(
color: context.colorScheme.outline,
blurRadius: 2,
offset: const Offset(0, 2),
),
]
: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: widget.child,
),
),
);
}
}

View File

@@ -22,6 +22,7 @@ class AnimatedImage extends StatefulWidget {
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
this.part,
this.onError,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
@@ -63,6 +64,8 @@ class AnimatedImage extends StatefulWidget {
final ImagePart? part;
final Function? onError;
static void clear() => _AnimatedImageState.clear();
@override
@@ -169,6 +172,8 @@ class _AnimatedImageState extends State<AnimatedImage>
_handleImageFrame,
onChunk: _handleImageChunk,
onError: (Object error, StackTrace? stackTrace) {
// 图片加错错误回调
widget.onError?.call(error, stackTrace);
setState(() {
_lastException = error;
});
@@ -271,7 +276,7 @@ class _AnimatedImageState extends State<AnimatedImage>
Widget result;
if (_imageInfo != null) {
if(widget.part != null) {
if (widget.part != null) {
return CustomPaint(
painter: ImagePainter(
image: _imageInfo!.image,

View File

@@ -2,10 +2,7 @@ part of 'components.dart';
class SliverGridViewWithFixedItemHeight extends StatelessWidget {
const SliverGridViewWithFixedItemHeight(
{required this.delegate,
required this.maxCrossAxisExtent,
required this.itemHeight,
super.key});
{required this.delegate, required this.maxCrossAxisExtent, required this.itemHeight, super.key});
final SliverChildDelegate delegate;
@@ -65,8 +62,7 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
@override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
oldDelegate.itemHeight != itemHeight) {
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || oldDelegate.itemHeight != itemHeight) {
return true;
}
return false;
@@ -95,8 +91,7 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
}
}
SliverGridLayout getDetailedModeLayout(
SliverConstraints constraints, double scale) {
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale) {
const minCrossAxisExtent = 360;
final itemHeight = 152 * scale;
final width = constraints.crossAxisExtent;
@@ -111,14 +106,11 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
reverseCrossAxis: false);
}
SliverGridLayout getBriefModeLayout(
SliverConstraints constraints, double scale) {
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale) {
final maxCrossAxisExtent = 192.0 * scale;
const childAspectRatio = 0.72;
const childAspectRatio = 0.68;
const crossAxisSpacing = 0.0;
int crossAxisCount =
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
.ceil();
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
// Ensure a minimum count of 1, can be zero and result in an infinite extent
// below when the window size is 0.
crossAxisCount = math.max(1, crossAxisCount);

View File

@@ -96,6 +96,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>
extends State<T> {
bool isLoading = false;
@@ -299,9 +313,7 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
Widget buildLoading(BuildContext context) {
return Center(
child: const CircularProgressIndicator(
strokeWidth: 2,
).fixWidth(32).fixHeight(32),
child: const CircularProgressIndicator().fixWidth(32).fixHeight(32),
);
}

View File

@@ -20,17 +20,22 @@ class _MenuRoute<T> extends PopupRoute<T> {
@override
String? get barrierLabel => "menu";
double get entryHeight => App.isMobile ? 42 : 36;
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
var width = entries.first.icon == null ? 216.0 : 242.0;
final size = MediaQuery.of(context).size;
var left = location.dx;
if (left < 10) {
left = 10;
}
if (left + width > size.width - 10) {
left = size.width - width - 10;
}
var top = location.dy;
var height = 16 + 32 * entries.length;
var height = 16 + entryHeight * entries.length;
if (top + height > size.height - 15) {
top = size.height - height - 15;
}
@@ -42,9 +47,12 @@ class _MenuRoute<T> extends PopupRoute<T> {
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
border: context.brightness == Brightness.dark
? Border.all(color: context.colorScheme.outlineVariant)
: null,
boxShadow: [
BoxShadow(
color: context.colorScheme.shadow.withOpacity(0.2),
color: context.colorScheme.shadow.toOpacity(0.2),
blurRadius: 8,
blurStyle: BlurStyle.outer,
),
@@ -53,9 +61,7 @@ class _MenuRoute<T> extends PopupRoute<T> {
child: BlurEffect(
borderRadius: BorderRadius.circular(4),
child: Material(
color: context.brightness == Brightness.light
? const Color(0xFFFAFAFA).withOpacity(0.82)
: const Color(0xFF090909).withOpacity(0.82),
color: context.colorScheme.surface.toOpacity(0.78),
borderRadius: BorderRadius.circular(4),
child: Container(
width: width,
@@ -83,7 +89,7 @@ class _MenuRoute<T> extends PopupRoute<T> {
entry.onClick();
},
child: SizedBox(
height: App.isMobile ? 42 : 36,
height: entryHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
@@ -92,9 +98,13 @@ class _MenuRoute<T> extends PopupRoute<T> {
Icon(
entry.icon,
size: 18,
color: entry.color
),
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 {
final String text;
final IconData? icon;
final Color? color;
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,
Widget? icon,
Widget? trailing,
int? seconds,
}) {
var newEntry = OverlayEntry(
builder: (context) => _ToastOverlay(
@@ -17,7 +18,7 @@ void showToast({
state?.addOverlay(newEntry);
Timer(const Duration(seconds: 2), () => state?.remove(newEntry));
Timer(Duration(seconds: seconds ?? 2), () => state?.remove(newEntry));
}
class _ToastOverlay extends StatelessWidget {
@@ -46,21 +47,29 @@ class _ToastOverlay extends StatelessWidget {
child: IconTheme(
data: IconThemeData(
color: Theme.of(context).colorScheme.onInverseSurface),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) icon!.paddingRight(8),
Text(
message,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (trailing != null) trailing!.paddingLeft(8)
],
child: IntrinsicWidth(
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
constraints: BoxConstraints(
maxWidth: context.width - 32,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) icon!.paddingRight(8),
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (trailing != null) trailing!.paddingLeft(8)
],
),
),
),
),
@@ -135,6 +144,7 @@ Future<void> showConfirmDialog({
required String content,
required void Function() onConfirm,
String confirmText = "Confirm",
Color? btnColor,
}) {
return showDialog(
context: context,
@@ -147,6 +157,9 @@ Future<void> showConfirmDialog({
context.pop();
onConfirm();
},
style: FilledButton.styleFrom(
backgroundColor: btnColor,
),
child: Text(confirmText.tl),
),
],
@@ -216,7 +229,7 @@ LoadingDialogController showLoadingDialog(BuildContext context,
);
});
var navigator = Navigator.of(context);
var navigator = Navigator.of(context, rootNavigator: true);
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
@@ -230,13 +243,13 @@ LoadingDialogController showLoadingDialog(BuildContext context,
class ContentDialog extends StatelessWidget {
const ContentDialog({
super.key,
required this.title,
this.title, // 如果不传 title 将不会展示
required this.content,
this.dismissible = true,
this.actions = const [],
});
final String title;
final String? title;
final Widget content;
@@ -250,14 +263,16 @@ class ContentDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title),
backgroundColor: Colors.transparent,
),
title != null
? Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title!),
backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content,
const SizedBox(height: 16),
Row(
@@ -279,6 +294,7 @@ class ContentDialog extends StatelessWidget {
: const EdgeInsets.symmetric(horizontal: 16),
elevation: 2,
shadowColor: context.colorScheme.shadow,
backgroundColor: context.colorScheme.surface,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter,
@@ -348,7 +364,7 @@ Future<void> showInputDialog({
} else {
result = futureOr;
}
if(result == null) {
if (result == null) {
context.pop();
} else {
setState(() => error = result.toString());

View File

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

View File

@@ -22,8 +22,15 @@ class PopUpWidget<T> extends PopupRoute<T> {
Widget body = PopupIndicatorWidget(
child: Container(
decoration: showPopUp
? const BoxDecoration(
? BoxDecoration(
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,
clipBehavior: showPopUp ? Clip.antiAlias : Clip.none,
@@ -86,7 +93,8 @@ class PopupIndicatorWidget extends InheritedWidget {
}
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 {
@@ -127,9 +135,8 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
message: "Back".tl,
child: IconButton(
icon: const Icon(Icons.arrow_back_sharp),
onPressed: () => context.canPop()
? context.pop()
: App.pop(),
onPressed: () =>
context.canPop() ? context.pop() : App.pop(),
),
),
const SizedBox(
@@ -148,6 +155,9 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
),
NotificationListener<ScrollNotification>(
onNotification: (notifications) {
if (notifications.metrics.axisDirection != AxisDirection.down) {
return false;
}
if (notifications.metrics.pixels ==
notifications.metrics.minScrollExtent &&
!top) {

View File

@@ -78,6 +78,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
},
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
if (HardwareKeyboard.instance.isShiftPressed) {
return;
}
if (pointerSignal.kind == PointerDeviceKind.mouse &&
!_isMouseScroll) {
setState(() {
@@ -99,13 +102,36 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
duration: _fastAnimationDuration, curve: Curves.linear);
}
},
child: widget.builder(
context,
_controller,
_isMouseScroll
? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(),
child: ScrollControllerProvider._(
controller: _controller,
child: widget.builder(
context,
_controller,
_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;
showMenu(
elevation: 3,
color: context.colorScheme.surface,
surfaceTintColor: Colors.transparent,
color: context.brightness == Brightness.light
? const Color(0xFFF6F6F6)
: const Color(0xFF1E1E1E),
context: context,
useRootNavigator: true,
constraints: BoxConstraints(
@@ -41,8 +42,8 @@ class Select extends StatelessWidget {
),
position: RelativeRect.fromLTRB(
offset.dx,
offset.dy + size.height,
offset.dx + size.height,
offset.dy + size.height + 2,
offset.dx + size.height + 2,
offset.dy,
),
items: values
@@ -266,13 +267,14 @@ class OptionChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
return AnimatedContainer(
duration: _fastAnimationDuration,
decoration: BoxDecoration(
color: isSelected
? context.colorScheme.primaryContainer
? context.colorScheme.secondaryContainer
: context.colorScheme.surface,
border: isSelected
? Border.all(color: context.colorScheme.primaryContainer)
? Border.all(color: context.colorScheme.secondaryContainer)
: Border.all(color: context.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),

View File

@@ -57,10 +57,18 @@ class SideBarRoute<T> extends PopupRoute<T> {
body = Container(
decoration: BoxDecoration(
borderRadius: showSideBar
? const BorderRadius.horizontal(left: Radius.circular(16))
: null,
color: Theme.of(context).colorScheme.surfaceTint),
borderRadius: showSideBar
? const BorderRadius.horizontal(left: Radius.circular(16))
: null,
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,
constraints: BoxConstraints(maxWidth: sideBarWidth),
height: MediaQuery.of(context).size.height,

View File

@@ -485,8 +485,15 @@ class WindowPlacement {
}
}
static Rect? lastValidRect;
static Future<WindowPlacement> get current async {
var rect = await windowManager.getBounds();
if(validate(rect)) {
lastValidRect = rect;
} else {
rect = lastValidRect ?? defaultPlacement.rect;
}
var isMaximized = await windowManager.isMaximized();
return WindowPlacement(rect, isMaximized);
}
@@ -501,9 +508,6 @@ class WindowPlacement {
static void loop() async {
timer ??= Timer.periodic(const Duration(milliseconds: 100), (timer) async {
var placement = await WindowPlacement.current;
if (!validate(placement.rect)) {
return;
}
if (placement.rect != cache.rect ||
placement.isMaximized != cache.isMaximized) {
cache = placement;
@@ -559,7 +563,7 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
boxShadow: <BoxShadow>[
if (!_isMaximized && !_isFullScreen)
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Colors.black.toOpacity(0.1),
offset: Offset(0.0, _isFocused ? 4 : 2),
blurRadius: 6,
)

View File

@@ -10,7 +10,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.0.2";
final version = "1.2.0";
bool get isAndroid => Platform.isAndroid;
@@ -63,22 +63,9 @@ class _App {
}
}
var mainColor = Colors.blue;
Future<void> init() async {
cachePath = (await getApplicationCacheDirectory()).path;
dataPath = (await getApplicationSupportDirectory()).path;
mainColor = switch (appdata.settings['color']) {
'red' => Colors.red,
'pink' => Colors.pink,
'purple' => Colors.purple,
'green' => Colors.green,
'orange' => Colors.orange,
'blue' => Colors.blue,
'yellow' => Colors.yellow,
'cyan' => Colors.cyan,
_ => Colors.blue,
};
}
Function? _forceRebuildHandler;

View File

@@ -19,7 +19,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
super.barrierDismissible = false,
this.enableIOSGesture = true,
this.preventRebuild = true,
this.isRootRoute = false,
}) {
assert(opaque);
}
@@ -50,9 +49,6 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
@override
final bool preventRebuild;
@override
final bool isRootRoute;
}
mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@@ -79,8 +75,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
bool get preventRebuild;
bool get isRootRoute;
Widget? _child;
@override
@@ -121,22 +115,6 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@override
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(
this,
context,

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/io.dart';
class _Appdata {
@@ -12,7 +13,7 @@ class _Appdata {
bool _isSavingData = false;
Future<void> saveData() async {
Future<void> saveData([bool sync = true]) async {
if (_isSavingData) {
await Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 20));
@@ -24,6 +25,9 @@ class _Appdata {
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
await file.writeAsString(data);
_isSavingData = false;
if (sync) {
DataSync().uploadData();
}
}
void addSearchHistory(String keyword) {
@@ -76,6 +80,26 @@ 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) {
for (var key in data.keys) {
if (_disableSync.contains(key)) {
continue;
}
settings[key] = data[key];
}
searchHistory = List.from(data['searchHistory']);
saveData();
}
var implicitData = <String, dynamic>{};
void writeImplicitData() {
@@ -92,7 +116,7 @@ class _Settings with ChangeNotifier {
final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief
'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
'newFavoriteAddTo': 'end', // start, end
'moveFavoriteAfterRead': 'none', // none, end, start
@@ -106,13 +130,29 @@ class _Settings with ChangeNotifier {
'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumber': 1, // 1 - 5
'enableTapToTurnPages': true,
'enablePageAnimation': true,
'language': 'system', // system, zh-CN, zh-TW, en-US
'cacheSize': 2048, // in MB
'downloadThreads': 5,
'enableLongPressToZoom': true,
'checkUpdateOnStart': true,
'checkUpdateOnStart': false,
'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
};
operator [](String key) {
@@ -129,3 +169,21 @@ class _Settings with ChangeNotifier {
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 image = new Promise((resolve, reject) => {
resolve(image);
});
return image;
}
''';

View File

@@ -1,4 +1,4 @@
part of comic_source;
part of 'comic_source.dart';
class CategoryData {
/// The title is displayed in the tab bar.

View File

@@ -1,4 +1,4 @@
library comic_source;
library;
import 'dart:async';
import 'dart:collection';
@@ -6,10 +6,12 @@ import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
@@ -135,6 +137,10 @@ class ComicSource {
notifyListeners();
}
static final availableUpdates = <String, String>{};
static bool get isEmpty => _sources.isEmpty;
/// Name of this source.
final String name;
@@ -198,7 +204,7 @@ class ComicSource {
final LikeCommentFunc? likeCommentFunc;
final Map<String, dynamic>? settings;
final Map<String, Map<String, dynamic>>? settings;
final Map<String, Map<String, String>>? translations;
@@ -212,6 +218,8 @@ class ComicSource {
final StarRatingFunc? starRatingFunc;
final ArchiveDownloader? archiveDownloader;
Future<void> loadData() async {
var file = File("${App.dataPath}/comic_source/$key.data");
if (await file.exists()) {
@@ -236,6 +244,7 @@ class ComicSource {
}
await file.writeAsString(jsonEncode(data));
_isSaving = false;
DataSync().uploadData();
}
Future<bool> reLogin() async {
@@ -280,6 +289,7 @@ class ComicSource {
this.enableTagsSuggestions,
this.enableTagsTranslate,
this.starRatingFunc,
this.archiveDownloader,
);
}
@@ -461,3 +471,11 @@ class LinkHandler {
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;
// 这个收藏时间新旧顺序, 是为了最小成本同步远端的收藏, 只拉取远程最新收藏的漫画, 就不需要全拉取一遍了
// 如果为 null, 当做从新到旧
final bool? isOldToNewSort;
final Future<Res<List<Comic>>> Function(int page, [String? folder])?
loadComic;
@@ -44,6 +48,7 @@ class FavoriteData {
this.addFolder,
this.allFavoritesId,
this.addOrDelFavorite,
this.isOldToNewSort,
});
}

View File

@@ -73,7 +73,8 @@ class Comic {
this.sourceKey,
this.maxPage,
this.language,
): favoriteId = null, stars = null;
) : favoriteId = null,
stars = null;
Map<String, dynamic> toJson() {
return {
@@ -92,7 +93,7 @@ class Comic {
Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
: title = json["title"],
subtitle = json["subTitle"] ?? "",
subtitle = json["subtitle"] ?? json["subTitle"] ?? "",
cover = json["cover"],
id = json["id"],
tags = List<String>.from(json["tags"] ?? []),
@@ -145,7 +146,7 @@ class ComicDetails with HistoryMixin {
final int? likesCount;
final int? commentsCount;
final int? commentCount;
final String? uploader;
@@ -160,6 +161,8 @@ class ComicDetails with HistoryMixin {
@override
final int? maxPage;
final List<Comment>? comments;
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
var res = <String, List<String>>{};
map.forEach((key, value) {
@@ -170,7 +173,7 @@ class ComicDetails with HistoryMixin {
ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"],
subTitle = json["subTitle"],
subTitle = json["subtitle"],
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
@@ -187,13 +190,16 @@ class ComicDetails with HistoryMixin {
subId = json["subId"],
likesCount = json["likesCount"],
isLiked = json["isLiked"],
commentsCount = json["commentsCount"],
commentCount = json["commentCount"],
uploader = json["uploader"],
uploadTime = json["uploadTime"],
updateTime = json["updateTime"],
url = json["url"],
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() {
return {
@@ -211,7 +217,7 @@ class ComicDetails with HistoryMixin {
"subId": subId,
"isLiked": isLiked,
"likesCount": likesCount,
"commentsCount": commentsCount,
"commentsCount": commentCount,
"uploader": uploader,
"uploadTime": uploadTime,
"updateTime": updateTime,
@@ -226,4 +232,43 @@ class ComicDetails with HistoryMixin {
String get id => comicId;
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;
}
}
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"];
}

View File

@@ -1,5 +1,6 @@
part of 'comic_source.dart';
/// return true if ver1 > ver2
bool compareSemVer(String ver1, String ver2) {
ver1 = ver1.replaceFirst("-", ".");
ver2 = ver2.replaceFirst("-", ".");
@@ -90,11 +91,10 @@ class ComicSourceParser {
var className = line1.split("class")[1].split("extends ComicSource").first;
className = className.trim();
JsEngine().runCode("""
(() => {
$js
(() => { $js
this['temp'] = new $className()
}).call()
""");
""", className);
_name = JsEngine().runCode("this['temp'].name") ??
(throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") ??
@@ -153,13 +153,16 @@ class ComicSourceParser {
_getValue("search.enableTagsSuggestions") ?? false,
_getValue("comic.enableTagsTranslate") ?? false,
_parseStarRatingFunc(),
_parseArchiveDownloader(),
);
await source.loadData();
Future.delayed(const Duration(milliseconds: 50), () {
JsEngine().runCode("ComicSource.sources.$_key.init()");
});
if (_checkExists("init")) {
Future.delayed(const Duration(milliseconds: 50), () {
JsEngine().runCode("ComicSource.sources.$_key.init()");
});
}
return source;
}
@@ -191,7 +194,7 @@ class ComicSourceParser {
login = (account, pwd) async {
try {
await JsEngine().runCode("""
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
${jsonEncode(pwd)})
""");
var source = ComicSource.find(_key!)!;
@@ -500,9 +503,9 @@ class ComicSourceParser {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load(
${jsonEncode(category)},
${jsonEncode(param)},
${jsonEncode(options)},
${jsonEncode(category)},
${jsonEncode(param)},
${jsonEncode(options)},
${jsonEncode(page)}
)
""");
@@ -616,6 +619,7 @@ class ComicSourceParser {
if (!_checkExists("favorites")) return null;
final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) {
@@ -768,6 +772,7 @@ class ComicSourceParser {
addFolder: addFolder,
deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc,
isOldToNewSort: isOldToNewSort,
);
}
@@ -918,8 +923,30 @@ class ComicSourceParser {
};
}
Map<String, dynamic> _parseSettings() {
return _getValue("settings") ?? {};
Map<String, Map<String, dynamic>> _parseSettings() {
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() {
@@ -986,4 +1013,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

@@ -28,4 +28,12 @@ class ComicType {
}
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;
/// 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;
/// Default user agent for http requests.
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;
bool get isDarkMode => brightness == Brightness.dark;
void showMessage({required String message}) {
showToast(message: message, context: this);
}

View File

@@ -1,20 +1,20 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/image_provider/local_favorite_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/tags_translation.dart';
import 'dart:io';
import 'app.dart';
import 'comic_source/comic_source.dart';
import 'comic_type.dart';
String _getCurTime() {
return DateTime.now()
.toIso8601String()
.replaceFirst("T", " ")
.substring(0, 19);
String _getTimeString(DateTime time) {
return time.toIso8601String().replaceFirst("T", " ").substring(0, 19);
}
class FavoriteItem implements Comic {
@@ -26,16 +26,19 @@ class FavoriteItem implements Comic {
@override
String id;
String coverPath;
String time = _getCurTime();
late String time;
FavoriteItem({
required this.id,
required this.name,
required this.coverPath,
required this.author,
required this.type,
required this.tags,
});
FavoriteItem(
{required this.id,
required this.name,
required this.coverPath,
required this.author,
required this.type,
required this.tags,
DateTime? favoriteTime}) {
var t = favoriteTime ?? DateTime.now();
time = _getTimeString(t);
}
FavoriteItem.fromRow(Row row)
: name = row["name"],
@@ -70,7 +73,10 @@ class FavoriteItem implements Comic {
@override
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
@@ -148,7 +154,7 @@ class FavoriteItemWithFolderInfo extends FavoriteItem {
);
}
class LocalFavoritesManager {
class LocalFavoritesManager with ChangeNotifier {
factory LocalFavoritesManager() =>
cache ?? (cache = LocalFavoritesManager._create());
@@ -166,6 +172,35 @@ class LocalFavoritesManager {
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) {
@@ -226,13 +261,14 @@ class LocalFavoritesManager {
return folders;
}
void updateOrder(Map<String, int> order) {
for (var folder in order.keys) {
void updateOrder(List<String> folders) {
for (int i = 0; i < folders.length; i++) {
_db.execute("""
insert or replace into folder_order (folder_name, order_value)
values (?, ?);
""", [folder, order[folder]]);
""", [folders[i], i]);
}
notifyListeners();
}
int count(String folderName) {
@@ -272,6 +308,7 @@ class LocalFavoritesManager {
set tags = '$tag,' || tags
where id == ?
""", [id]);
notifyListeners();
}
List<FavoriteItemWithFolderInfo> allComics() {
@@ -286,12 +323,16 @@ class LocalFavoritesManager {
return res;
}
bool existsFolder(String name) {
return folderNames.contains(name);
}
/// create a folder
String createFolder(String name, [bool renameWhenInvalidName = false]) {
if (name.isEmpty) {
if (renameWhenInvalidName) {
int i = 0;
while (folderNames.contains(i.toString())) {
while (existsFolder(i.toString())) {
i++;
}
name = i.toString();
@@ -299,11 +340,11 @@ class LocalFavoritesManager {
throw "name is empty!";
}
}
if (folderNames.contains(name)) {
if (existsFolder(name)) {
if (renameWhenInvalidName) {
var prevName = name;
int i = 0;
while (folderNames.contains(i.toString())) {
while (existsFolder(i.toString())) {
i++;
}
name = prevName + i.toString();
@@ -321,12 +362,41 @@ class LocalFavoritesManager {
cover_path TEXT,
time TEXT,
display_order int,
translated_tags TEXT,
primary key (id, type)
);
""");
notifyListeners();
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) {
var res = _db.select("""
select * from "$folder"
@@ -346,21 +416,32 @@ class LocalFavoritesManager {
return FavoriteItem.fromRow(res.first);
}
/// add comic to a folder
///
/// This method will download cover to local, to avoid problems like changing url
void addComic(String folder, FavoriteItem comic, [int? order]) async {
String _translateTags(List<String> tags) {
var res = <String>[];
for (var tag in tags) {
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]) {
_modifiedAfterLastCache = true;
if (!folderNames.contains(folder)) {
if (!existsFolder(folder)) {
throw Exception("Folder does not exists");
}
var res = _db.select("""
select * from "$folder"
where id == '${comic.id}';
""");
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
if (res.isNotEmpty) {
return;
return false;
}
var translatedTags = _translateTags(comic.tags);
final params = [
comic.id,
comic.name,
@@ -368,24 +449,62 @@ class LocalFavoritesManager {
comic.type.value,
comic.tags.join(","),
comic.coverPath,
comic.time
comic.time,
translatedTags
];
if (order != null) {
_db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?);
insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, order]);
} else if (appdata.settings['newFavoriteAddTo'] == "end") {
_db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?);
insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, maxValue(folder) + 1]);
} else {
_db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?);
insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, minValue(folder) - 1]);
}
notifyListeners();
return true;
}
void moveFavorite(
String sourceFolder, String targetFolder, String id, ComicType type) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
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
@@ -394,6 +513,11 @@ class LocalFavoritesManager {
_db.execute("""
drop table "$name";
""");
_db.execute("""
delete from folder_order
where folder_name == ?;
""", [name]);
notifyListeners();
}
void deleteComic(String folder, FavoriteItem comic) {
@@ -408,6 +532,24 @@ class LocalFavoritesManager {
delete from "$folder"
where id == ? and type == ?;
""", [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 {
@@ -417,7 +559,7 @@ class LocalFavoritesManager {
}
void reorder(List<FavoriteItem> newFolder, String folder) async {
if (!folderNames.contains(folder)) {
if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found");
}
deleteFolder(folder);
@@ -425,10 +567,11 @@ class LocalFavoritesManager {
for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], i);
}
notifyListeners();
}
void rename(String before, String after) {
if (folderNames.contains(after)) {
if (existsFolder(after)) {
throw "Name already exists!";
}
if (after.contains('"')) {
@@ -438,9 +581,23 @@ class LocalFavoritesManager {
ALTER TABLE "$before"
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") {
return;
}
_modifiedAfterLastCache = true;
for (final folder in folderNames) {
var rows = _db.select("""
@@ -475,6 +632,34 @@ class LocalFavoritesManager {
""", [newTime, id]);
}
}
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) {
@@ -485,8 +670,8 @@ class LocalFavoritesManager {
keyword = "%$keyword%";
var res = _db.select("""
SELECT * FROM "$table"
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ?;
""", [keyword, keyword, keyword]);
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword, keyword]);
for (var comic in res) {
comics.add(
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
@@ -521,6 +706,7 @@ class LocalFavoritesManager {
set tags = ?
where id == ?;
""", [tags.join(","), id]);
notifyListeners();
}
final _cachedFavoritedIds = <String, bool>{};
@@ -560,6 +746,7 @@ class LocalFavoritesManager {
comic.id,
comic.type.value
]);
notifyListeners();
}
String folderToJson(String folder) {
@@ -579,9 +766,9 @@ class LocalFavoritesManager {
if (folder == null || folder is! String) {
throw "Invalid data";
}
if (folderNames.contains(folder)) {
if (existsFolder(folder)) {
int i = 0;
while (folderNames.contains("$folder($i)")) {
while (existsFolder("$folder($i)")) {
i++;
}
folder = "$folder($i)";

View File

@@ -1,10 +1,23 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/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 'consts.dart';
part "image_favorites.dart";
typedef HistoryType = ComicType;
@@ -22,21 +35,25 @@ abstract mixin class HistoryMixin {
HistoryType get historyType;
}
class History {
class History implements Comic {
HistoryType type;
DateTime time;
@override
String title;
@override
String subtitle;
@override
String cover;
int ep;
int page;
@override
String id;
/// readEpisode is a set of episode numbers that have been read.
@@ -44,6 +61,7 @@ class History {
/// The number of episodes is 1-based.
Set<int> readEpisode;
@override
int? maxPage;
History.fromModel(
@@ -137,6 +155,47 @@ class History {
@override
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 {
@@ -153,9 +212,12 @@ class HistoryManager with ChangeNotifier {
Map<String, bool>? _cachedHistory;
static const _kMaxHistoryLength = 200;
bool isInitialized = false;
Future<void> init() async {
if (isInitialized) {
return;
}
_db = sqlite3.open("${App.dataPath}/history.db");
_db.execute("""
@@ -174,18 +236,14 @@ class HistoryManager with ChangeNotifier {
""");
notifyListeners();
ImageFavoriteManager().init();
isInitialized = true;
}
/// add history. if exists, update time.
///
/// This function would be called when user start reading.
Future<void> addHistory(History newItem) async {
while(count() >= _kMaxHistoryLength) {
_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
@@ -235,7 +293,7 @@ class HistoryManager with ChangeNotifier {
}
History? findSync(String id, ComicType type) {
if(_cachedHistory == null) {
if (_cachedHistory == null) {
updateCache();
}
if (!_cachedHistory!.containsKey(id)) {
@@ -279,6 +337,7 @@ class HistoryManager with ChangeNotifier {
}
void close() {
isInitialized = false;
_db.dispose();
}
}

View File

@@ -0,0 +1,535 @@
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([], [], []));
} 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 = {};
for (var comic in comics) {
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(),
);
}
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;
/// 计算后的图片收藏数据
const ImageFavoritesComputed(
this.tags,
this.authors,
this.comics,
);
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;
}

View File

@@ -1,16 +1,48 @@
import 'dart:async' show Future, StreamController, scheduleMicrotask;
import 'dart:collection';
import 'dart:convert';
import 'dart:math';
import 'dart:ui' as ui show Codec;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/log.dart';
abstract class BaseImageProvider<T extends BaseImageProvider<T>>
extends ImageProvider<T> {
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
ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
@@ -46,19 +78,18 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
while (data == null && !stop) {
try {
if(_cache.containsKey(key.key)){
data = _cache[key.key];
} else {
data = await load(chunkEvents);
_checkCacheSize();
_cache[key.key] = data;
_cacheSize += data.length;
}
data = await load(chunkEvents, () {
if (stop) {
throw const _ImageLoadingStopException();
}
});
} on _ImageLoadingStopException {
rethrow;
} catch (e) {
if(e.toString().contains("Invalid Status Code: 404")) {
if (e.toString().contains("Invalid Status Code: 404")) {
rethrow;
}
if(e.toString().contains("Invalid Status Code: 403")) {
if (e.toString().contains("Invalid Status Code: 403")) {
rethrow;
}
if (e.toString().contains("handshake")) {
@@ -74,23 +105,27 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
}
}
if(stop) {
throw Exception("Image loading is stopped");
if (stop) {
throw const _ImageLoadingStopException();
}
if(data!.isEmpty) {
if (data!.isEmpty) {
throw Exception("Empty image data");
}
try {
final buffer = await ImmutableBuffer.fromUint8List(data);
return await decode(buffer);
return await decode(
buffer,
getTargetSize: enableResize ? _getTargetSize : null,
);
} catch (e) {
await CacheManager().delete(this.key);
if (data.length < 2 * 1024) {
// data is too short, it's likely that the data is text, not image
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");
} catch (e) {
// ignore
@@ -98,41 +133,23 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
}
rethrow;
}
} catch (e) {
} on _ImageLoadingStopException {
rethrow;
} catch (e, s) {
scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key);
});
Log.error("Image Loading", e, s);
rethrow;
} finally {
chunkEvents.close();
}
}
static final _cache = LinkedHashMap<String, Uint8List>();
static var _cacheSize = 0;
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);
Future<Uint8List> load(
StreamController<ImageChunkEvent> chunkEvents,
void Function() checkStop,
);
String get key;
@@ -148,6 +165,12 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
String toString() {
return "$runtimeType($key)";
}
bool get enableResize => false;
}
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/material.dart';
import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart';
import 'cached_image.dart' as image_provider;
class CachedImageProvider
extends BaseImageProvider<image_provider.CachedImageProvider> {
/// 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;
@@ -16,18 +19,39 @@ class CachedImageProvider
final String? sourceKey;
final String? cid;
static int loadingCount = 0;
static const _kMaxLoadingCount = 8;
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,
));
if(progress.imageBytes != null) {
return progress.imageBytes!;
}
Future<Uint8List> load(chunkEvents, checkStop) async {
while(loadingCount > _kMaxLoadingCount) {
await Future.delayed(const Duration(milliseconds: 100));
checkStop();
}
loadingCount++;
try {
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
@@ -36,5 +60,5 @@ class CachedImageProvider
}
@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?.keys.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/material.dart';
import 'package:venera/foundation/app.dart';
@@ -22,13 +22,13 @@ class LocalFavoriteImageProvider
static void delete(String id, int intKey) {
var fileName = (id + intKey.toString()).hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
if(file.existsSync()) {
if (file.existsSync()) {
file.delete();
}
}
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
Future<Uint8List> load(chunkEvents, checkStop) async {
var sourceKey = ComicSource.fromIntKey(intKey)?.key;
var fileName = key.hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
@@ -37,12 +37,14 @@ class LocalFavoriteImageProvider
} else {
await file.create(recursive: true);
}
checkStop();
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
checkStop();
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,
));
if(progress.imageBytes != null) {
if (progress.imageBytes != null) {
var data = progress.imageBytes!;
await file.writeAsBytes(data);
return data;
@@ -52,7 +54,8 @@ class LocalFavoriteImageProvider
}
@override
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) {
Future<LocalFavoriteImageProvider> obtainKey(
ImageConfiguration configuration) {
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/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/utils/io.dart';
import 'base_image_provider.dart';
import 'reader_image.dart' as image_provider;
import 'package:venera/foundation/appdata.dart';
class ReaderImageProvider
extends BaseImageProvider<image_provider.ReaderImageProvider> {
/// 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;
@@ -18,19 +22,98 @@ class ReaderImageProvider
final String eid;
final int page;
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
await for (var event
Future<Uint8List> load(chunkEvents, checkStop) async {
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)) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: event.currentBytes,
expectedTotalBytes: event.totalBytes,
));
if (event.imageBytes != null) {
return event.imageBytes!;
checkStop();
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: event.currentBytes,
expectedTotalBytes: event.totalBytes,
));
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 result = func.invoke([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) {
JSInvokable? onCancel;
if (result['onCancel'] is JSInvokable) {
onCancel = 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.invoke([]);
onCancel.free();
func.free();
rethrow;
}
await Future.delayed(Duration(milliseconds: 50));
}
if (futureImage is Uint8List) {
imageBytes = futureImage;
}
}
onCancel?.free();
}
}
func.free();
}
}
return imageBytes!;
}
@override
@@ -40,4 +123,7 @@ class ReaderImageProvider
@override
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:math' as math;
import 'package:crypto/crypto.dart';
import 'package:dio/io.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html;
import 'package:html/dom.dart' as dom;
@@ -18,7 +20,10 @@ import 'package:pointycastle/block/modes/cbc.dart';
import 'package:pointycastle/block/modes/cfb.dart';
import 'package:pointycastle/block/modes/ecb.dart';
import 'package:pointycastle/block/modes/ofb.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:uuid/uuid.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
@@ -37,7 +42,7 @@ class JavaScriptRuntimeException implements Exception {
}
}
class JsEngine with _JSEngineApi {
class JsEngine with _JSEngineApi, _JsUiApi {
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache;
@@ -56,6 +61,11 @@ class JsEngine with _JSEngineApi {
JsEngine().init();
}
void resetDio() {
_dio = AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true));
}
Future<void> init() async {
if (!_closed) {
return;
@@ -70,6 +80,7 @@ class JsEngine with _JSEngineApi {
var setGlobalFunc =
_engine!.evaluate("(key, value) => { this[key] = value; }");
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
setGlobalFunc(["appVersion", App.version]);
setGlobalFunc.free();
var jsInit = await rootBundle.load("assets/init.js");
_engine!
@@ -85,85 +96,67 @@ class JsEngine with _JSEngineApi {
String method = message["method"] as String;
switch (method) {
case "log":
{
String level = message["level"];
Log.addLog(
switch (level) {
"error" => LogLevel.error,
"warning" => LogLevel.warning,
"info" => LogLevel.info,
_ => LogLevel.warning
},
message["title"],
message["content"].toString());
}
String level = message["level"];
Log.addLog(
switch (level) {
"error" => LogLevel.error,
"warning" => LogLevel.warning,
"info" => LogLevel.info,
_ => LogLevel.warning
},
message["title"],
message["content"].toString());
case 'load_data':
{
String key = message["key"];
String dataKey = message["data_key"];
return ComicSource.find(key)?.data[dataKey];
}
String key = message["key"];
String dataKey = message["data_key"];
return ComicSource.find(key)?.data[dataKey];
case 'save_data':
{
String key = message["key"];
String dataKey = message["data_key"];
if (dataKey == 'setting') {
throw "setting is not allowed to be saved";
}
var data = message["data"];
var source = ComicSource.find(key)!;
source.data[dataKey] = data;
source.saveData();
String key = message["key"];
String dataKey = message["data_key"];
if (dataKey == 'setting') {
throw "setting is not allowed to be saved";
}
var data = message["data"];
var source = ComicSource.find(key)!;
source.data[dataKey] = data;
source.saveData();
case 'delete_data':
{
String key = message["key"];
String dataKey = message["data_key"];
var source = ComicSource.find(key);
source?.data.remove(dataKey);
source?.saveData();
}
String key = message["key"];
String dataKey = message["data_key"];
var source = ComicSource.find(key);
source?.data.remove(dataKey);
source?.saveData();
case 'http':
{
return _http(Map.from(message));
}
return _http(Map.from(message));
case 'html':
{
return handleHtmlCallback(Map.from(message));
}
return handleHtmlCallback(Map.from(message));
case 'convert':
{
return _convert(Map.from(message));
}
return _convert(Map.from(message));
case "random":
{
return _random(
message["min"] ?? 0,
message["max"] ?? 1,
message["type"],
);
}
return _random(
message["min"] ?? 0,
message["max"] ?? 1,
message["type"],
);
case "cookie":
{
return handleCookieCallback(Map.from(message));
}
return handleCookieCallback(Map.from(message));
case "uuid":
{
return const Uuid().v1();
}
return const Uuid().v1();
case "load_setting":
{
String key = message["key"];
String settingKey = message["setting_key"];
var source = ComicSource.find(key)!;
return source.data["settings"]?[settingKey] ??
source.settings?[settingKey]['default'] ??
(throw "Setting not found: $settingKey");
}
String key = message["key"];
String settingKey = message["setting_key"];
var source = ComicSource.find(key)!;
return source.data["settings"]?[settingKey] ??
source.settings?[settingKey]!['default'] ??
(throw "Setting not found: $settingKey");
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":
handleUIMessage(Map.from(message));
}
}
return null;
@@ -182,7 +175,24 @@ class JsEngine with _JSEngineApi {
if (headers["user-agent"] == null && headers["User-Agent"] == null) {
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"],
options: Options(
method: req['http_method'],
@@ -663,3 +673,62 @@ class DocumentWrapper {
return elements.length - 1;
}
}
class JSAutoFreeFunction {
final JSInvokable func;
/// Automatically free the function when it's not used anymore
JSAutoFreeFunction(this.func) {
finalizer.attach(this, func);
}
dynamic call(List<dynamic> args) {
return func(args);
}
static final finalizer = Finalizer<JSInvokable>((func) {
func.free();
});
}
mixin class _JsUiApi {
void 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':
_showDialog(message);
case 'launchUrl':
var url = message['url'];
if (url.toString().isNotEmpty) {
launchUrlString(url.toString());
}
}
}
void _showDialog(Map<String, dynamic> message) {
var title = message['title'];
var content = message['content'];
var actions = <String, JSAutoFreeFunction>{};
for (var action in message['actions']) {
actions[action['text']] = JSAutoFreeFunction(action['callback']);
}
showDialog(context: App.rootContext, builder: (context) {
return ContentDialog(
title: title,
content: Text(content).paddingHorizontal(16),
actions: actions.entries.map((entry) {
return TextButton(
onPressed: () {
entry.value.call([]);
},
child: Text(entry.key),
);
}).toList(),
);
});
}
}

View File

@@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.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/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';
@@ -34,6 +36,8 @@ class LocalComic with HistoryMixin implements Comic {
/// chapter id is the name of the directory in `LocalManager.path/$directory`
final Map<String, String>? chapters;
bool get hasChapters => chapters != null;
/// relative path to the cover image
@override
final String cover;
@@ -70,18 +74,20 @@ class LocalComic with HistoryMixin implements Comic {
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
File get coverFile => File(FilePath.join(
LocalManager().path,
directory,
baseDir,
cover,
));
String get baseDir => (directory.contains('/') || directory.contains('\\'))
? directory
: FilePath.join(LocalManager().path, directory);
@override
String get description => "";
@override
String get sourceKey => comicType == ComicType.local
? "local"
: comicType.sourceKey;
String get sourceKey =>
comicType == ComicType.local ? "local" : comicType.sourceKey;
@override
Map<String, dynamic> toJson() {
@@ -109,11 +115,14 @@ class LocalComic with HistoryMixin implements Comic {
chapters: chapters,
initialChapter: history?.ep,
initialPage: history?.page,
history: history ?? History.fromModel(
model: this,
ep: 0,
page: 0,
),
history: history ??
History.fromModel(
model: this,
ep: 0,
page: 0,
),
author: subtitle,
tags: tags,
),
);
}
@@ -148,6 +157,17 @@ class LocalManager with ChangeNotifier {
/// path to the directory where all the comics are stored
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
Future<String?> setNewPath(String newPath) async {
var newDir = Directory(newPath);
@@ -158,19 +178,56 @@ class LocalManager with ChangeNotifier {
return "Directory is not empty";
}
try {
await copyDirectory(
Directory(path),
await copyDirectoryIsolate(
directory,
newDir,
);
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(path);
} catch (e) {
await File(FilePath.join(App.dataPath, 'local_path'))
.writeAsString(newPath);
} catch (e, s) {
Log.error("IO", e, s);
return e.toString();
}
await Directory(path).deleteIgnoreError(recursive:true);
await directory.deleteContents(recursive: true);
path = newPath;
_checkNoMedia();
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 {
_db = sqlite3.open(
'${App.dataPath}/local.db',
@@ -192,31 +249,32 @@ class LocalManager with ChangeNotifier {
''');
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
} else {
if (App.isAndroid) {
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');
if (!directory.existsSync()) {
path = await findDefaultPath();
}
} else {
path = await findDefaultPath();
}
if (!Directory(path).existsSync()) {
await Directory(path).create();
try {
if (!directory.existsSync()) {
await directory.create();
}
} catch (e, s) {
Log.error("IO", "Failed to create local folder: $e", s);
}
_checkPathValidation();
_checkNoMedia();
restoreDownloadingTasks();
}
String findValidId(ComicType type) {
final res = _db.select(
'''
SELECT id FROM comics WHERE comic_type = ?
SELECT id FROM comics WHERE comic_type = ?
ORDER BY CAST(id AS INTEGER) DESC
LIMIT 1;
''', [type.value],
''',
[type.value],
);
if (res.isEmpty) {
return '1';
@@ -264,8 +322,8 @@ class LocalManager with ChangeNotifier {
List<LocalComic> getComics(LocalSortType sortType) {
var res = _db.select('''
SELECT * FROM comics
ORDER BY
${sortType.value == 'name' ? 'title' : 'created_at'}
ORDER BY
${sortType.value == 'name' ? 'title' : 'created_at'}
${sortType.value == 'time_asc' ? 'ASC' : 'DESC'}
;
''');
@@ -307,7 +365,7 @@ class LocalManager with ChangeNotifier {
LocalComic? findByName(String name) {
final res = _db.select('''
SELECT * FROM comics
SELECT * FROM comics
WHERE title = ? OR directory = ?;
''', [name, name]);
if (res.isEmpty) {
@@ -326,22 +384,26 @@ class LocalManager with ChangeNotifier {
}
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";
}
var comic = find(id, type) ?? (throw "Comic Not Found");
var directory = Directory(FilePath.join(path, comic.directory));
if (comic.chapters != null) {
var cid = ep is int
? comic.chapters!.keys.elementAt(ep - 1)
: (ep as String);
var directory = Directory(comic.baseDir);
if (comic.hasChapters) {
var cid =
ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
directory = Directory(FilePath.join(directory.path, cid));
}
var files = <File>[];
await for (var entity in directory.list()) {
if (entity is File) {
if (entity.absolute.path.replaceFirst(path, '').substring(1) ==
comic.cover) {
// Do not exclude comic.cover, since it may be the first page of the chapter.
// 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;
}
files.add(entity);
@@ -358,12 +420,12 @@ class LocalManager with ChangeNotifier {
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);
if (comic == null) return false;
if (comic.chapters == null) return true;
if (comic.chapters == null || ep == null) return true;
return comic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(ep-1));
.contains(comic.chapters!.keys.elementAt(ep - 1));
}
List<DownloadTask> downloadingTasks = [];
@@ -420,12 +482,17 @@ class LocalManager with ChangeNotifier {
void restoreDownloadingTasks() {
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
if (file.existsSync()) {
var tasks = jsonDecode(file.readAsStringSync());
for (var e in tasks) {
var task = DownloadTask.fromJson(e);
if (task != null) {
downloadingTasks.add(task);
try {
var tasks = jsonDecode(file.readAsStringSync());
for (var e in tasks) {
var task = DownloadTask.fromJson(e);
if (task != null) {
downloadingTasks.add(task);
}
}
} catch (e) {
file.delete();
Log.error("LocalManager", "Failed to restore downloading tasks: $e");
}
}
}
@@ -437,9 +504,21 @@ class LocalManager with ChangeNotifier {
downloadingTasks.first.resume();
}
void deleteComic(LocalComic c) {
var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true);
void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
if (removeFileOnDisk) {
var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true);
}
// Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
if (c.comicType == ComicType.local) {
if (HistoryManager().findSync(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);
notifyListeners();
}
@@ -462,4 +541,4 @@ enum LocalSortType {
}
return name;
}
}
}

View File

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

View File

@@ -1,14 +1,18 @@
import 'package:flutter/material.dart';
class SimpleController extends StateController {
final void Function()? refresh_;
final void Function()? refreshFunction;
SimpleController({this.refresh_});
final Map<String, dynamic> Function()? control;
SimpleController({this.refreshFunction, this.control});
@override
void refresh() {
(refresh_ ?? super.refresh)();
(refreshFunction ?? super.refresh)();
}
Map<String, dynamic> get controlMap => control?.call() ?? {};
}
abstract class StateController {
@@ -71,8 +75,8 @@ abstract class StateController {
static SimpleController putSimpleController(
void Function() onUpdate, Object? tag,
{void Function()? refresh}) {
var controller = SimpleController(refresh_: refresh);
{void Function()? refresh, Map<String, dynamic> Function()? control}) {
var controller = SimpleController(refreshFunction: refresh, control: control);
controller.stateUpdaters.add(Pair(null, onUpdate));
_controllers.add(StateControllerWrapped(controller, false, tag));
return controller;
@@ -202,6 +206,7 @@ abstract class StateWithController<T extends StatefulWidget> extends State<T> {
},
tag,
refresh: refresh,
control: () => control,
);
super.initState();
}
@@ -218,6 +223,8 @@ abstract class StateWithController<T extends StatefulWidget> extends State<T> {
}
Object? get tag;
Map<String, dynamic> get control => {};
}
class Pair<M, V>{

View File

@@ -111,4 +111,10 @@ extension StyledText on TextStyle {
TextStyle get s40 => copyWith(fontSize: 40);
TextStyle withColor(Color? color) => copyWith(color: color);
}
extension ColorExt on Color {
Color toOpacity(double opacity) {
return withValues(alpha: opacity);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -5,22 +6,37 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart';
extension FutureInit<T> on Future<T> {
/// Prevent unhandled exception
///
/// A unhandled exception occurred in init() will cause the app to crash.
Future<void> wait() async {
try {
await this;
} catch (e, s) {
Log.error("init", "$e\n$s");
}
}
}
Future<void> init() async {
await AppTranslation.init();
await appdata.init();
await App.init();
await HistoryManager().init();
await LocalFavoritesManager().init();
await SAFTaskWorker().init().wait();
await AppTranslation.init().wait();
await appdata.init().wait();
await App.init().wait();
await HistoryManager().init().wait();
await TagsTranslation.readData().wait();
await LocalFavoritesManager().init().wait();
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
await JsEngine().init();
await ComicSource.init();
await LocalManager().init();
await TagsTranslation.readData();
await JsEngine().init().wait();
await ComicSource.init().wait();
await LocalManager().init().wait();
CacheManager().setLimitSize(appdata.settings['cacheSize']);
}

View File

@@ -1,15 +1,15 @@
import 'dart:async';
import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/auth_page.dart';
import 'package:venera/pages/main_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/io.dart';
import 'package:window_manager/window_manager.dart';
import 'components/components.dart';
import 'components/window_frame.dart';
@@ -21,40 +21,42 @@ void main(List<String> args) {
if (runWebViewTitleBarWidget(args)) {
return;
}
runZonedGuarded(() async {
await Rhttp.init();
WidgetsFlutterBinding.ensureInitialized();
await init();
if (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");
};
runApp(const MyApp());
if (App.isDesktop) {
await windowManager.ensureInitialized();
windowManager.waitUntilReadyToShow().then((_) async {
await windowManager.setTitleBarStyle(
TitleBarStyle.hidden,
windowButtonVisibility: App.isMacOS,
);
if (App.isLinux) {
await windowManager.setBackgroundColor(Colors.transparent);
}
await windowManager.setMinimumSize(const Size(500, 600));
if (!App.isLinux) {
// https://github.com/leanflutter/window_manager/issues/460
var placement = await WindowPlacement.loadFromFile();
await placement.applyToWindow();
await windowManager.show();
WindowPlacement.loop();
}
});
}
}, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack");
overrideIO(() {
runZonedGuarded(() async {
await Rhttp.init();
WidgetsFlutterBinding.ensureInitialized();
await init();
if (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");
};
runApp(const MyApp());
if (App.isDesktop) {
await windowManager.ensureInitialized();
windowManager.waitUntilReadyToShow().then((_) async {
await windowManager.setTitleBarStyle(
TitleBarStyle.hidden,
windowButtonVisibility: App.isMacOS,
);
if (App.isLinux) {
await windowManager.setBackgroundColor(Colors.transparent);
}
await windowManager.setMinimumSize(const Size(500, 600));
if (!App.isLinux) {
// https://github.com/leanflutter/window_manager/issues/460
var placement = await WindowPlacement.loadFromFile();
await placement.applyToWindow();
await windowManager.show();
WindowPlacement.loop();
}
});
}
}, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack");
});
});
}
@@ -65,15 +67,58 @@ class MyApp extends StatefulWidget {
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
checkUpdates();
App.registerForceRebuild(forceRebuild);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addObserver(this);
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 rebuild(Element el) {
el.markNeedsBuild();
@@ -84,106 +129,129 @@ class _MyAppState extends State<MyApp> {
setState(() {});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const MainPage(),
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: App.mainColor,
surface: Colors.white,
primary: App.mainColor.shade600,
background: Colors.white,
),
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
),
navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData(
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');
},
);
Color translateColorSetting() {
return switch (appdata.settings['color']) {
'red' => Colors.red,
'pink' => Colors.pink,
'purple' => Colors.purple,
'green' => Colors.green,
'orange' => Colors.orange,
'blue' => Colors.blue,
'yellow' => Colors.yellow,
'cyan' => Colors.cyan,
_ => Colors.blue,
};
}
void checkUpdates() async {
if(!appdata.settings['checkUpdateOnStart']) {
return;
@override
Widget build(BuildContext context) {
Widget home;
if (appdata.settings['authorizationRequired']) {
home = AuthPage(
onSuccessfulAuth: () {
App.rootContext.toReplacement(() => const MainPage());
},
);
} else {
home = const MainPage();
}
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();
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
await ComicSourcePage.checkComicSourceUpdate(true);
return DynamicColorBuilder(builder: (light, dark) {
if (appdata.settings['color'] != 'system' ||
light == null ||
dark == null) {
var color = translateColorSetting();
light = ColorScheme.fromSeed(
seedColor: color,
surface: Colors.white,
);
dark = ColorScheme.fromSeed(
seedColor: color,
brightness: Brightness.dark,
surface: Colors.black,
);
} else {
light = ColorScheme.fromSeed(
seedColor: light.primary,
surface: Colors.white,
);
dark = ColorScheme.fromSeed(
seedColor: dark.primary,
brightness: Brightness.dark,
surface: Colors.black,
);
}
return MaterialApp(
home: home,
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: light,
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
),
navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData(
colorScheme: dark,
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');
},
);
});
}
}

View File

@@ -1,5 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
@@ -96,6 +96,9 @@ class MyLogInterceptor implements Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
Log.info("Network", "${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n"
"data:\n${options.data}");
options.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15);
@@ -108,29 +111,15 @@ class AppDio with DioMixin {
AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions();
interceptors.add(MyLogInterceptor());
httpClientAdapter = RHttpAdapter(const rhttp.ClientSettings());
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor());
}
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;
interceptors.add(MyLogInterceptor());
}
static String? proxy;
@@ -176,6 +165,8 @@ class AppDio with DioMixin {
return res;
}
static final Map<String, bool> _requests = {};
@override
Future<Response<T>> request<T>(
String path, {
@@ -186,6 +177,13 @@ class AppDio with DioMixin {
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) 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();
if (_proxy != proxy) {
Log.info("Network", "Proxy changed to $proxy");
@@ -196,36 +194,56 @@ class AppDio with DioMixin {
: rhttp.ProxySettings.proxy(proxy!),
));
}
Log.info(
"Network",
"${options?.method ?? 'GET'} $path\n"
"Headers: ${options?.headers}\n"
"Data: $data\n",
);
return super.request(
path,
data: data,
queryParameters: queryParameters,
cancelToken: cancelToken,
options: options,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
try {
return super.request<T>(
path,
data: data,
queryParameters: queryParameters,
cancelToken: cancelToken,
options: options,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
} finally {
if (_requests.containsKey(path)) {
_requests.remove(path);
}
}
}
}
class RHttpAdapter implements HttpClientAdapter {
rhttp.ClientSettings settings;
RHttpAdapter(this.settings) {
settings.copyWith(
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(
redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings(
connectTimeout: Duration(seconds: 15),
keepAliveTimeout: Duration(seconds: 60),
keepAlivePing: Duration(seconds: 30),
),
httpVersionPref: rhttp.HttpVersionPref.http1_1,
throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
),
);
}
@@ -272,18 +290,8 @@ class RHttpAdapter implements HttpClientAdapter {
headers[key] ??= [];
headers[key]!.add(entry.$2);
}
var data = res.body;
if(headers['content-encoding']?.contains('gzip') ?? false) {
// rhttp does not support gzip decoding
var buffer = <int>[];
await for (var chunk in data) {
buffer.addAll(chunk);
}
data = Stream.value(Uint8List.fromList(gzip.decode(buffer)));
buffer.clear();
}
return ResponseBody(
data,
res.body,
res.statusCode,
statusMessage: null,
isRedirect: false,

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:dio/dio.dart';
class NetworkCache {
@@ -43,7 +42,10 @@ class NetworkCacheManager implements Interceptor {
static const _maxCacheSize = 10 * 1024 * 1024;
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;
_cache.remove(_cache.keys.first);
}
@@ -53,7 +55,7 @@ class NetworkCacheManager implements Interceptor {
void removeCache(Uri uri) {
var cache = _cache[uri];
if(cache != null){
if (cache != null) {
size -= cache.size;
}
_cache.remove(uri);
@@ -64,41 +66,29 @@ class NetworkCacheManager implements Interceptor {
size = 0;
}
var preventParallel = <Uri, Completer>{};
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if(err.requestOptions.method != "GET"){
if (err.requestOptions.method != "GET") {
return handler.next(err);
}
if(preventParallel[err.requestOptions.uri] != null){
preventParallel[err.requestOptions.uri]!.complete();
preventParallel.remove(err.requestOptions.uri);
}
return handler.next(err);
}
@override
void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
if(options.method != "GET"){
if (options.method != "GET") {
return handler.next(options);
}
if(preventParallel[options.uri] != null){
await preventParallel[options.uri]!.future;
}
var cache = getCache(options.uri);
if (cache == null || !compareHeaders(options.headers, cache.requestHeaders)) {
if(options.headers['cache-time'] != null){
if (cache == null ||
!compareHeaders(options.headers, cache.requestHeaders)) {
if (options.headers['cache-time'] != null) {
options.headers.remove('cache-time');
}
if(options.headers['prevent-parallel'] != null){
options.headers.remove('prevent-parallel');
preventParallel[options.uri] = Completer();
}
return handler.next(options);
} else {
if(options.headers['cache-time'] == 'no'){
if (options.headers['cache-time'] == 'no') {
options.headers.remove('cache-time');
removeCache(options.uri);
return handler.next(options);
@@ -106,23 +96,24 @@ class NetworkCacheManager implements Interceptor {
}
var time = DateTime.now();
var diff = time.difference(cache.time);
if (options.headers['cache-time'] == 'long'
&& diff < const Duration(hours: 2)) {
if (options.headers['cache-time'] == 'long' &&
diff < const Duration(hours: 6)) {
return handler.resolve(Response(
requestOptions: options,
data: cache.data,
headers: Headers.fromMap(cache.responseHeaders),
headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200,
));
}
else if (diff < const Duration(seconds: 5)) {
} else if (diff < const Duration(seconds: 5)) {
return handler.resolve(Response(
requestOptions: options,
data: cache.data,
headers: Headers.fromMap(cache.responseHeaders),
headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200,
));
} else if (diff < const Duration(hours: 1)) {
} else if (diff < const Duration(hours: 2)) {
var o = options.copyWith(
method: "HEAD",
);
@@ -133,7 +124,8 @@ class NetworkCacheManager implements Interceptor {
return handler.resolve(Response(
requestOptions: options,
data: cache.data,
headers: Headers.fromMap(cache.responseHeaders),
headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200,
));
}
@@ -143,11 +135,42 @@ class NetworkCacheManager implements Interceptor {
}
static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> 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) {
return false;
}
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;
}
}
@@ -160,45 +183,44 @@ class NetworkCacheManager implements Interceptor {
if (response.requestOptions.method != "GET") {
return handler.next(response);
}
if(response.statusCode != null && response.statusCode! >= 400){
if (response.statusCode != null && response.statusCode! >= 400) {
return handler.next(response);
}
var size = _calculateSize(response.data);
if(size != null && size < 1024 * 1024 && size > 0) {
if (size != null && size < 1024 * 1024 && size > 0) {
var cache = NetworkCache(
uri: response.requestOptions.uri,
requestHeaders: response.requestOptions.headers,
responseHeaders: response.headers.map,
responseHeaders: Map.from(response.headers.map),
data: response.data,
time: DateTime.now(),
size: size,
);
setCache(cache);
}
if(preventParallel[response.requestOptions.uri] != null){
preventParallel[response.requestOptions.uri]!.complete();
preventParallel.remove(response.requestOptions.uri);
}
handler.next(response);
}
static int? _calculateSize(Object? data){
if(data == null){
static int? _calculateSize(Object? data) {
if (data == null) {
return 0;
}
if(data is List<int>) {
if (data is List<int>) {
return data.length;
}
if(data is String) {
if(data.trim().isEmpty){
if (data is Uint8List) {
return data.length;
}
if (data is String) {
if (data.trim().isEmpty) {
return 0;
}
if(data.length < 512 && data.contains("IP address")){
if (data.length < 512 && data.contains("IP address")) {
return 0;
}
return data.length * 4;
}
if(data is Map) {
if (data is Map) {
return data.toString().length * 4;
}
return null;

View File

@@ -1,7 +1,6 @@
import 'dart:io' as io;
import 'package:dio/dio.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/consts.dart';

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:venera/foundation/appdata.dart';
@@ -11,13 +12,14 @@ import 'package:venera/network/images.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart';
import 'package:zip_flutter/zip_flutter.dart';
import 'file_downloader.dart';
abstract class DownloadTask with ChangeNotifier {
/// 0-1
double get progress;
bool get isComplete;
bool get isError;
bool get isPaused;
@@ -76,11 +78,14 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
@override
ComicType get comicType => ComicType(source.key.hashCode);
String? comicTitle;
ImagesDownloadTask({
required this.source,
required this.comicId,
this.comic,
this.chapters,
this.comicTitle,
});
@override
@@ -90,7 +95,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
var local = LocalManager().find(id, comicType);
if (path != null) {
if (local == null) {
Directory(path!).deleteIgnoreError();
Directory(path!).deleteIgnoreError(recursive: true);
} else if (chapters != null) {
for (var c in chapters!) {
var dir = Directory(FilePath.join(path!, c));
@@ -103,10 +108,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}
@override
String? get cover => _cover;
@override
bool get isComplete => _totalCount == _downloadedCount;
String? get cover => _cover ?? comic?.cover;
@override
String get message => _message;
@@ -144,19 +146,25 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
String? _cover;
/// All images to download, key is chapter name
Map<String, List<String>>? _images;
/// Downloaded image count
int _downloadedCount = 0;
/// Total image count
int _totalCount = 0;
/// Current downloading image index
int _index = 0;
/// Current downloading chapter, index of [_images]
int _chapter = 0;
var tasks = <int, _ImageDownloadWrapper>{};
int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt();
int get _maxConcurrentTasks =>
(appdata.settings["downloadThreads"] as num).toInt();
void _scheduleTasks() {
var images = _images![_images!.keys.elementAt(_chapter)]!;
@@ -177,10 +185,10 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (comic!.chapters != null) {
saveTo = Directory(FilePath.join(
path!,
comic!.chapters!.keys.elementAt(_chapter),
_images!.keys.elementAt(_chapter),
));
if (!saveTo.existsSync()) {
saveTo.createSync();
saveTo.createSync(recursive: true);
}
} else {
saveTo = Directory(path!);
@@ -232,25 +240,26 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}
if (path == null) {
var dir = await LocalManager().findValidDirectory(
comicId,
comicType,
comic!.title,
);
if (!(await dir.exists())) {
try {
try {
var dir = await LocalManager().findValidDirectory(
comicId,
comicType,
comic!.title,
);
if (!(await dir.exists())) {
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();
if (cover == null) {
if (_cover == null) {
var res = await runWithRetry(() async {
Uint8List? data;
await for (var progress
@@ -263,11 +272,13 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
throw "Failed to download cover";
}
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);
return file.path;
return "file://${file.path}";
});
if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}");
return;
} else {
@@ -291,6 +302,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return;
}
if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}");
return;
} else {
@@ -320,6 +332,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return;
}
if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}");
return;
} else {
@@ -344,6 +357,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return;
}
if (task.error != null) {
Log.error("Download", task.error.toString());
_setError("Error: ${task.error}");
return;
}
@@ -357,6 +371,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}
LocalManager().completeTask(this);
stopRecorder();
}
@override
@@ -371,14 +386,13 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_message = message;
notifyListeners();
stopRecorder();
Log.error("Download", message);
}
@override
int get speed => currentSpeed;
@override
String get title => comic?.title ?? "Loading...";
String get title => comic?.title ?? comicTitle ?? "Loading...";
@override
Map<String, dynamic> toJson() {
@@ -444,7 +458,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}).toList(),
directory: Directory(path!).name,
chapters: comic!.chapters,
cover: File(_cover!).uri.pathSegments.last,
cover:
File(_cover!.split("file://").last).name,
comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [],
createdAt: DateTime.now(),
@@ -534,6 +549,9 @@ class _ImageDownloadWrapper {
}
}
} catch (e, s) {
if (isCancelled) {
return;
}
Log.error("Download", e.toString(), s);
retry--;
if (retry > 0) {
@@ -570,7 +588,7 @@ abstract mixin class _TransferSpeedMixin {
void onData(int length) {
if (timer == null) return;
if(length < 0) {
if (length < 0) {
return;
}
_bytesSinceLastSecond += length;
@@ -596,3 +614,216 @@ abstract mixin class _TransferSpeedMixin {
_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 resultFile = File(FilePath.join(path!, "archive.zip"));
Log.info("Download", "Downloading $archiveUrl");
_downloader = FileDownloader(archiveUrl, resultFile.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(path!);
} catch (e) {
_setError("Failed to extract archive: $e");
return;
}
await resultFile.deleteIgnoreError();
LocalManager().completeTask(this);
}
static Future<void> extractArchive(String path) async {
var resultFile = FilePath.join(path, "archive.zip");
await Isolate.run(() {
ZipFile.openAndExtract(resultFile, path);
});
}
@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 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart';
@@ -9,8 +10,9 @@ import 'app_dio.dart';
class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey) async* {
final cacheKey = "$url@$sourceKey";
String url, String? sourceKey,
[String? cid]) async* {
final cacheKey = "$url@$sourceKey${cid != null ? '@$cid' : ''}";
final cache = await CacheManager().findCache(cacheKey);
if (cache != null) {
@@ -33,6 +35,16 @@ class ImageDownloader {
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(
headers: Map<String, dynamic>.from(configs['headers']),
method: configs['method'] ?? 'GET',
@@ -57,8 +69,9 @@ class ImageDownloader {
}
}
if (configs['onResponse'] != null) {
buffer = configs['onResponse'](buffer);
if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
(configs['onResponse'] as JSInvokable).free();
}
await CacheManager().writeCache(cacheKey, buffer);
@@ -83,61 +96,98 @@ class ImageDownloader {
);
}
Future<Map<String, dynamic>?> Function()? onLoadFailed;
var configs = <String, dynamic>{};
if (sourceKey != null) {
var comicSource = ComicSource.find(sourceKey);
configs = (await comicSource!.getImageLoadingConfig
?.call(imageKey, cid, eid)) ?? {};
?.call(imageKey, cid, eid)) ??
{};
}
configs['headers'] ??= {
'user-agent': webUA,
};
var retryLimit = 5;
while (true) {
try {
configs['headers'] ??= {
'user-agent': webUA,
};
var dio = AppDio(BaseOptions(
headers: configs['headers'],
method: configs['method'] ?? 'GET',
responseType: ResponseType.stream,
));
if (configs['onLoadFailed'] is JSInvokable) {
onLoadFailed = () async {
dynamic result = (configs['onLoadFailed'] as JSInvokable)([]);
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,
data: configs['data']);
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
int? expectedBytes = req.data!.contentLength;
if (expectedBytes == -1) {
expectedBytes = null;
}
var buffer = <int>[];
await for (var data in stream) {
buffer.addAll(data);
if (expectedBytes != null) {
var dio = AppDio(BaseOptions(
headers: configs['headers'],
method: configs['method'] ?? 'GET',
responseType: ResponseType.stream,
));
var req = await dio.request<ResponseBody>(configs['url'] ?? imageKey,
data: configs['data']);
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
int? expectedBytes = req.data!.contentLength;
if (expectedBytes == -1) {
expectedBytes = null;
}
var buffer = <int>[];
await for (var data in stream) {
buffer.addAll(data);
if (expectedBytes != null) {
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(
currentBytes: buffer.length,
totalBytes: expectedBytes,
currentBytes: data.length,
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,
);
}
}

View File

@@ -0,0 +1,205 @@
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/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() {
sources = ComicSource.all().where((e) => e.searchPageData != null).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(source: source, keyword: _keyword);
},
childCount: sources.length,
),
),
]);
}
}
class _SliverSearchResult extends StatefulWidget {
const _SliverSearchResult({required this.source, required this.keyword});
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;
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 if (data.loadNext != null) {
var res = await data.loadNext!(widget.keyword, null, options);
if (!res.error) {
setState(() {
comics = res.data;
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) {
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 (comics == null || comics!.isEmpty)
SizedBox(
height: _kComicHeight,
child: Column(
children: [
Row(
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Text("No search results found".tl),
],
),
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();
}
}
}

View File

@@ -30,8 +30,15 @@ class CategoriesPage extends StatelessWidget {
.toList();
if(categories.isEmpty) {
var msg = "No Category Pages".tl;
msg += '\n';
if(ComicSource.isEmpty) {
msg += "Add a comic source in home page".tl;
} else {
msg += "Please check your settings".tl;
}
return NetworkError(
message: "No Category Pages".tl,
message: msg,
retry: () {
controller.update();
},
@@ -46,6 +53,7 @@ class CategoriesPage extends StatelessWidget {
child: Column(
children: [
FilledTabBar(
key: PageStorageKey(categories.toString()),
tabs: categories.map((e) {
String title = e;
try {
@@ -248,36 +256,19 @@ class _CategoryPage extends StatelessWidget {
Widget buildTag(String tag, ClickTagCallback onClick,
[String? namespace, String? param]) {
String translateTag(String tag) {
/*
// TODO: Implement translation
if (enableTranslation) {
if (namespace != null) {
tag = TagsTranslation.translationTagWithNamespace(tag, namespace);
} else {
tag = tag.translateTagsToCN;
}
}
*/
return tag;
}
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Builder(
builder: (context) {
return Material(
elevation: 0.6,
borderRadius: const BorderRadius.all(Radius.circular(4)),
color: context.colorScheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(8)),
color: context.colorScheme.primaryContainer.toOpacity(0.72),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(4)),
borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () => onClick(tag, param),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(translateTag(tag)),
child: Text(tag),
),
),
);

View File

@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shimmer_animation/shimmer_animation.dart';
import 'package:sliver_tools/sliver_tools.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/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
@@ -24,12 +27,22 @@ import 'dart:math' as math;
import 'comments_page.dart';
class ComicPage extends StatefulWidget {
const ComicPage({super.key, required this.id, required this.sourceKey});
const ComicPage({
super.key,
required this.id,
required this.sourceKey,
this.cover,
this.title,
});
final String id;
final String sourceKey;
final String? cover;
final String? title;
@override
State<ComicPage> createState() => _ComicPageState();
}
@@ -53,13 +66,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override
Widget buildLoading() {
return Column(
children: [
const Appbar(title: Text("")),
Expanded(
child: super.buildLoading(),
),
],
return _ComicPageLoadingPlaceHolder(
cover: widget.cover,
title: widget.title,
sourceKey: widget.sourceKey,
cid: widget.id,
);
}
@@ -113,6 +124,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildDescription(),
buildInfo(),
buildChapters(),
buildComments(),
buildThumbnails(),
buildRecommend(),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
@@ -142,6 +154,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ep: 0,
page: 0,
),
author: localComic.subTitle ?? '',
tags: localComic.tags,
);
});
App.mainNavigatorKey!.currentContext!.pop();
@@ -169,7 +183,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false;
if (comic.chapters == null) {
isDownloaded = await LocalManager().isDownloaded(
isDownloaded = LocalManager().isDownloaded(
comic.id,
comic.comicType,
0,
@@ -196,21 +210,32 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
),
width: double.infinity,
height: double.infinity,
),
width: double.infinity,
height: double.infinity,
),
),
const SizedBox(width: 16),
@@ -220,7 +245,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
children: [
SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14),
SelectableText(comic.subTitle!, style: ts.s14)
.paddingVertical(4),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
@@ -282,12 +308,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
isActive: isFavorite || isAddToLocalFav,
text: 'Favorite'.tl,
onPressed: openFavPanel,
onLongPressed: quickFavorite,
iconColor: context.useTextColor(Colors.purple),
),
if (comicSource.commentsLoader != null)
_ActionButton(
icon: const Icon(Icons.comment),
text: (comic.commentsCount ?? 'Comments'.tl).toString(),
text: (comic.commentCount ?? 'Comments'.tl).toString(),
onPressed: showComments,
iconColor: context.useTextColor(Colors.green),
),
@@ -324,7 +351,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
}
Widget buildDescription() {
if (comic.description == null) {
if (comic.description == null || comic.description!.trim().isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverToBoxAdapter(
@@ -389,6 +416,27 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
onSecondaryTapDown: (details) {
showMenuX(context, details.globalPosition, [
MenuEntry(
icon: Icons.remove_red_eye,
text: "View".tl,
onClick: onTap,
),
MenuEntry(
icon: Icons.copy,
text: "Copy".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
),
]);
},
child: Text(text).padding(padding),
),
);
@@ -403,6 +451,26 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
}
}
String formatTime(String time) {
if (int.tryParse(time) != null) {
var t = int.tryParse(time);
if (t! > 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(t)
.toString()
.substring(0, 19);
} else {
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
.toString()
.substring(0, 19);
}
}
if (time.contains('T') || time.contains('Z')) {
var t = DateTime.parse(time);
return t.toString().substring(0, 19);
}
return time;
}
Widget buildWrap({required List<Widget> children}) {
return Wrap(
runSpacing: 8,
@@ -461,14 +529,14 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildWrap(
children: [
buildTag(text: 'Upload Time'.tl, isTitle: true),
buildTag(text: comic.uploadTime!),
buildTag(text: formatTime(comic.uploadTime!)),
],
),
if (comic.updateTime != null)
buildWrap(
children: [
buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: comic.updateTime!),
buildTag(text: formatTime(comic.updateTime!)),
],
),
const SizedBox(height: 12),
@@ -505,6 +573,16 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
SliverGridComics(comics: comic.recommend!),
]);
}
Widget buildComments() {
if (comic.comments == null || comic.comments!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return _CommentsPart(
comments: comic.comments!,
showMore: showComments,
);
}
}
abstract mixin class _ComicPageActions {
@@ -538,12 +616,22 @@ abstract mixin class _ComicPageActions {
bool isFavorite = false;
void openFavPanel() {
FavoriteItem _toFavoriteItem() {
var tags = <String>[];
for (var e in comic.tags.entries) {
tags.addAll(e.value.map((tag) => '${e.key}:$tag'));
}
return FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
);
}
void openFavPanel() {
showSideBar(
App.rootContext,
_FavoritePanel(
@@ -555,18 +643,25 @@ abstract mixin class _ComicPageActions {
isAddToLocalFav = local ?? isAddToLocalFav;
update();
},
favoriteItem: FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
),
favoriteItem: _toFavoriteItem(),
),
);
}
void quickFavorite() {
var folder = appdata.settings['quickFavorite'];
if (folder is! String) {
return;
}
LocalFavoritesManager().addComic(
folder,
_toFavoriteItem(),
);
isAddToLocalFav = true;
update();
App.rootContext.showMessage(message: "Added".tl);
}
void share() {
var text = comic.title;
if (comic.url != null) {
@@ -590,6 +685,8 @@ abstract mixin class _ComicPageActions {
initialChapter: ep,
initialPage: page,
history: History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '',
tags: comic.plainTags,
),
);
}
@@ -606,10 +703,126 @@ abstract mixin class _ComicPageActions {
return;
}
if (comic.chapters == null &&
await LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
App.rootContext.showMessage(message: "The comic is downloaded".tl);
return;
}
if (comicSource.archiveDownloader != null) {
bool useNormalDownload = false;
List<ArchiveInfo>? archives;
int selected = -1;
bool isLoading = false;
bool isGettingLink = false;
await showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: "Download".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<int>(
value: -1,
groupValue: selected,
title: Text("Normal".tl),
onChanged: (v) {
setState(() {
selected = v!;
});
},
),
ExpansionTile(
title: Text("Archive".tl),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
collapsedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
onExpansionChanged: (b) {
if (!isLoading && b && archives == null) {
isLoading = true;
comicSource.archiveDownloader!
.getArchives(comic.id)
.then((value) {
if (value.success) {
archives = value.data;
} else {
App.rootContext
.showMessage(message: value.errorMessage!);
}
setState(() {
isLoading = false;
});
});
}
},
children: [
if (archives == null)
const ListLoadingIndicator().toCenter()
else
for (int i = 0; i < archives!.length; i++)
RadioListTile<int>(
value: i,
groupValue: selected,
onChanged: (v) {
setState(() {
selected = v!;
});
},
title: Text(archives![i].title),
subtitle: Text(archives![i].description),
)
],
)
],
),
actions: [
Button.filled(
isLoading: isGettingLink,
onPressed: () async {
if (selected == -1) {
useNormalDownload = true;
context.pop();
return;
}
setState(() {
isGettingLink = true;
});
var res =
await comicSource.archiveDownloader!.getDownloadUrl(
comic.id,
archives![selected].id,
);
if (res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
setState(() {
isGettingLink = false;
});
} else if (context.mounted) {
LocalManager()
.addTask(ArchiveDownloadTask(res.data, comic));
App.rootContext
.showMessage(message: "Download started".tl);
context.pop();
}
},
child: Text("Confirm".tl),
),
],
);
},
);
},
);
if (!useNormalDownload) {
return;
}
}
if (comic.chapters == null) {
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
@@ -800,6 +1013,7 @@ class _ActionButton extends StatelessWidget {
required this.icon,
required this.text,
required this.onPressed,
this.onLongPressed,
this.activeIcon,
this.isActive,
this.isLoading,
@@ -820,6 +1034,8 @@ class _ActionButton extends StatelessWidget {
final Color? iconColor;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
return Container(
@@ -837,6 +1053,7 @@ class _ActionButton extends StatelessWidget {
onPressed();
}
},
onLongPress: onLongPressed,
borderRadius: BorderRadius.circular(18),
child: IconTheme.merge(
data: IconThemeData(size: 20, color: iconColor),
@@ -923,14 +1140,12 @@ class _ComicChaptersState extends State<_ComicChapters> {
(state.history?.readEpisode ?? const {}).contains(i + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Material(
elevation: 5,
color: context.colorScheme.surface,
surfaceTintColor: context.colorScheme.surfaceTint,
child: Material(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: InkWell(
onTap: () => state.read(i + 1),
borderRadius: const BorderRadius.all(Radius.circular(12)),
shadowColor: Colors.transparent,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -941,19 +1156,18 @@ class _ComicChaptersState extends State<_ComicChapters> {
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
visited ? context.colorScheme.outline : null),
color: visited ? context.colorScheme.outline : null,
),
),
),
),
),
onTap: () => state.read(i + 1),
),
);
}),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, itemHeight: 48),
),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
if (eps.length > 20 && !showAll)
SliverToBoxAdapter(
child: Align(
@@ -998,6 +1212,8 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
String? error;
bool isLoading = false;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
@@ -1011,6 +1227,12 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
if (!isInitialLoading && next == null) {
return;
}
if (isLoading) return;
Future.microtask(() {
setState(() {
isLoading = true;
});
});
var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next);
if (res.success) {
thumbnails.addAll(res.data);
@@ -1019,13 +1241,17 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
} else {
error = res.errorMessage;
}
setState(() {});
if (mounted) {
setState(() {
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return SliverMainAxisGroup(
slivers: [
return MultiSliver(
children: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Preview".tl),
@@ -1125,10 +1351,8 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
],
),
)
else if (next != null || isInitialLoading)
const SliverToBoxAdapter(
child: ListLoadingIndicator(),
),
else if (isLoading)
const SliverListLoadingIndicator(),
const SliverToBoxAdapter(
child: Divider(),
),
@@ -1576,10 +1800,12 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: selected.isEmpty ? null : () {
widget.finishSelect(selected);
context.pop();
},
onPressed: selected.isEmpty
? null
: () {
widget.finishSelect(selected);
context.pop();
},
child: Text("Download Selected".tl),
),
),
@@ -1593,3 +1819,273 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
);
}
}
class _CommentsPart extends StatefulWidget {
const _CommentsPart({
required this.comments,
required this.showMore,
});
final List<Comment> comments;
final void Function() showMore;
@override
State<_CommentsPart> createState() => _CommentsPartState();
}
class _CommentsPartState extends State<_CommentsPart> {
final scrollController = ScrollController();
late List<Comment> comments;
@override
void initState() {
comments = widget.comments;
super.initState();
}
@override
Widget build(BuildContext context) {
return MultiSliver(
children: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Comments".tl),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
scrollController.animateTo(
scrollController.position.pixels - 340,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
scrollController.animateTo(
scrollController.position.pixels + 340,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
),
],
),
),
),
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 184,
child: MediaQuery.removePadding(
removeTop: true,
context: context,
child: ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
itemCount: comments.length,
itemBuilder: (context, index) {
return _CommentWidget(comment: comments[index]);
},
),
),
),
const SizedBox(height: 8),
_ActionButton(
icon: const Icon(Icons.comment),
text: "View more".tl,
onPressed: widget.showMore,
iconColor: context.useTextColor(Colors.green),
).fixHeight(48).paddingRight(8).toAlign(Alignment.centerRight),
const SizedBox(height: 8),
],
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}
class _CommentWidget extends StatelessWidget {
const _CommentWidget({required this.comment});
final Comment comment;
@override
Widget build(BuildContext context) {
return Container(
height: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 0, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
width: 324,
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
children: [
if (comment.avatar != null)
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: context.colorScheme.surfaceContainer,
),
clipBehavior: Clip.antiAlias,
child: Image(
image: CachedImageProvider(comment.avatar!),
width: 36,
height: 36,
fit: BoxFit.cover,
),
).paddingRight(8),
Text(comment.userName, style: ts.bold),
],
),
const SizedBox(height: 4),
Expanded(
child: RichCommentContent(text: comment.content).fixWidth(324),
),
const SizedBox(height: 4),
if (comment.time != null)
Text(comment.time!, style: ts.s12).toAlign(Alignment.centerLeft),
],
),
);
}
}
class _ComicPageLoadingPlaceHolder extends StatelessWidget {
const _ComicPageLoadingPlaceHolder({
this.cover,
this.title,
required this.sourceKey,
required this.cid,
});
final String? cover;
final String? title;
final String sourceKey;
final String cid;
@override
Widget build(BuildContext context) {
Widget buildContainer(double? width, double? height,
{Color? color, double? radius}) {
return Container(
height: height,
width: width,
decoration: BoxDecoration(
color: color ?? context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(radius ?? 4),
),
);
}
return Shimmer(
child: Column(
children: [
Appbar(title: Text(""), backgroundColor: context.colorScheme.surface),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
buildImage(context),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(title ?? "", style: ts.s18)
else
buildContainer(200, 25),
const SizedBox(height: 8),
buildContainer(80, 20),
],
),
),
],
),
const SizedBox(height: 8),
if (context.width < changePoint)
Row(
children: [
Expanded(
child: buildContainer(null, 36, radius: 18),
),
const SizedBox(width: 16),
Expanded(
child: buildContainer(null, 36, radius: 18),
),
],
).paddingHorizontal(16),
const Divider(),
const SizedBox(height: 8),
Center(
child: CircularProgressIndicator(
strokeWidth: 2.4,
).fixHeight(24).fixWidth(24),
)
],
),
);
}
Widget buildImage(BuildContext context) {
Widget child;
if (cover != null) {
child = AnimatedImage(
image: CachedImageProvider(
cover!,
sourceKey: sourceKey,
cid: cid,
),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
);
} else {
child = const SizedBox();
}
return Hero(
tag: "cover$cid$sourceKey",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: child,
),
);
}
}

View File

@@ -14,17 +14,15 @@ import 'package:venera/utils/translations.dart';
class ComicSourcePage extends StatefulWidget {
const ComicSourcePage({super.key});
static Future<void> checkComicSourceUpdate([bool implicit = false]) async {
static Future<int> checkComicSourceUpdate() async {
if (ComicSource.all().isEmpty) {
return;
return 0;
}
var controller = implicit ? null : showLoadingDialog(App.rootContext);
var dio = AppDio();
var res = await dio.get<String>(
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
if (res.statusCode != 200) {
App.rootContext.showMessage(message: "Network error".tl);
return;
return -1;
}
var list = jsonDecode(res.data!) as List;
var versions = <String, String>{};
@@ -34,34 +32,17 @@ class ComicSourcePage extends StatefulWidget {
var shouldUpdate = <String>[];
for (var source in ComicSource.all()) {
if (versions.containsKey(source.key) &&
versions[source.key] != source.version) {
compareSemVer(versions[source.key]!, source.version)) {
shouldUpdate.add(source.key);
}
}
controller?.close();
if (shouldUpdate.isEmpty) {
if(!implicit) {
App.rootContext.showMessage(message: "No Update Available".tl);
if (shouldUpdate.isNotEmpty) {
for (var key in shouldUpdate) {
ComicSource.availableUpdates[key] = versions[key]!;
}
return;
ComicSource.notifyListeners();
}
var msg = "";
for (var key in shouldUpdate) {
msg += "${ComicSource.find(key)?.name}: v${versions[key]}\n";
}
msg = msg.trim();
await showConfirmDialog(
context: App.rootContext,
title: "Updates Available".tl,
content: msg,
confirmText: "Update",
onConfirm: () {
for (var key in shouldUpdate) {
var source = ComicSource.find(key);
_BodyState.update(source!);
}
},
);
return shouldUpdate.length;
}
@override
@@ -72,9 +53,6 @@ class _ComicSourcePageState extends State<ComicSourcePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text('Comic Source'.tl),
),
body: const _Body(),
);
}
@@ -90,46 +68,77 @@ class _Body extends StatefulWidget {
class _BodyState extends State<_Body> {
var url = "";
void updateUI() {
setState(() {});
}
@override
void initState() {
super.initState();
ComicSource.addListener(updateUI);
}
@override
void dispose() {
super.dispose();
ComicSource.removeListener(updateUI);
}
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text('Comic Source'.tl),
style: AppbarStyle.shadow,
),
buildCard(context),
buildSettings(),
for (var source in ComicSource.all()) buildSource(context, source),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
],
);
}
Widget buildSettings() {
return SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.update_outlined),
title: Text("Check updates".tl),
onTap: () => ComicSourcePage.checkComicSourceUpdate(false),
trailing: const Icon(Icons.arrow_right),
),
);
}
Widget buildSource(BuildContext context, ComicSource source) {
var newVersion = ComicSource.availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
return SliverToBoxAdapter(
child: Column(
children: [
const Divider(),
ListTile(
title: Text(source.name),
title: Row(
children: [
Text(source.name),
const SizedBox(width: 6),
if (hasUpdate)
Tooltip(
message: newVersion,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
"New Version".tl,
style: const TextStyle(fontSize: 13),
),
),
)
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (App.isDesktop)
Tooltip(
message: "Edit".tl,
child: IconButton(
onPressed: () => edit(source),
icon: const Icon(Icons.edit_note)),
),
Tooltip(
message: "Edit".tl,
child: IconButton(
onPressed: () => edit(source),
icon: const Icon(Icons.edit_note)),
),
Tooltip(
message: "Update".tl,
child: IconButton(
@@ -175,17 +184,22 @@ class _BodyState extends State<_Body> {
break;
}
}
} else {
current = item.value['options']
.firstWhere((e) => e['value'] == current)['text'] ??
current;
}
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Select(
current: (current as String).ts(source.key),
values: (item.value['options'] as List)
.map<String>(
(e) => ((e['text'] ?? e['value']) as String).ts(source.key))
.map<String>((e) =>
((e['text'] ?? e['value']) as String).ts(source.key))
.toList(),
onTap: (i) {
source.data['settings'][key] = item.value['options'][i]['value'];
source.data['settings'][key] =
item.value['options'][i]['value'];
source.saveData();
setState(() {});
},
@@ -209,7 +223,8 @@ class _BodyState extends State<_Body> {
source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
subtitle: Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle:
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
@@ -230,9 +245,10 @@ class _BodyState extends State<_Body> {
},
),
);
} else if (type == "callback") {
yield _CallbackSetting(setting: item);
}
}
catch(e, s) {
} catch (e, s) {
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
}
}
@@ -242,7 +258,10 @@ class _BodyState extends State<_Body> {
showConfirmDialog(
context: App.rootContext,
title: "Delete".tl,
content: "Are you sure you want to delete it?".tl,
content: "Delete comic source '@n' ?".tlParams({
"n": source.name,
}),
btnColor: context.colorScheme.error,
onConfirm: () {
var file = File(source.filePath);
file.delete();
@@ -254,30 +273,38 @@ class _BodyState extends State<_Body> {
}
void edit(ComicSource source) async {
try {
await Process.run("code", [source.filePath], runInShell: true);
await showDialog(
if (App.isDesktop) {
try {
await Process.run("code", [source.filePath], runInShell: true);
await showDialog(
context: App.rootContext,
builder: (context) => AlertDialog(
title: const Text("Reload Configs"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("cancel")),
TextButton(
onPressed: () async {
await ComicSource.reload();
App.forceRebuild();
},
child: const Text("continue")),
],
));
} catch (e) {
context.showMessage(message: "Failed to launch vscode");
title: const Text("Reload Configs"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("cancel")),
TextButton(
onPressed: () async {
await ComicSource.reload();
App.forceRebuild();
},
child: const Text("continue")),
],
),
);
return;
} catch (e) {
//
}
}
context.to(() => _EditFilePage(source.filePath)).then((value) async {
await ComicSource.reload();
setState(() {});
});
}
static void update(ComicSource source) async {
static Future<void> update(ComicSource source) async {
if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config");
return;
@@ -296,6 +323,9 @@ class _BodyState extends State<_Body> {
controller.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!);
if (ComicSource.availableUpdates.containsKey(source.key)) {
ComicSource.availableUpdates.remove(source.key);
}
} catch (e) {
if (cancel) return;
App.rootContext.showMessage(message: e.toString());
@@ -305,55 +335,72 @@ class _BodyState extends State<_Body> {
}
Widget buildCard(BuildContext context) {
Widget buildButton(
{required Widget child, required VoidCallback onPressed}) {
return Button.normal(
onPressed: onPressed,
child: child,
).fixHeight(32);
}
return SliverToBoxAdapter(
child: Card.outlined(
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text("Add comic source".tl),
leading: const Icon(Icons.dashboard_customize),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text("Add comic source".tl),
leading: const Icon(Icons.dashboard_customize),
),
TextField(
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton(
onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check))),
onChanged: (value) {
url = value;
},
onSubmitted: handleAddSource,
).paddingHorizontal(16).paddingBottom(8),
ListTile(
title: Text("Comic Source list".tl),
trailing: buildButton(
child: Text("View".tl),
onPressed: () {
showPopUpWidget(
App.rootContext,
_ComicSourceList(handleAddSource),
);
},
),
TextField(
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton(
onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check))),
onChanged: (value) {
url = value;
},
onSubmitted: handleAddSource)
.paddingHorizontal(16)
.paddingBottom(32),
Row(
children: [
TextButton(
onPressed: _selectFile, child: Text("Select file".tl))
.paddingLeft(8),
const Spacer(),
TextButton(
onPressed: () {
showPopUpWidget(
App.rootContext, _ComicSourceList(handleAddSource));
},
child: Text("View list".tl)),
const Spacer(),
TextButton(onPressed: help, child: Text("Open help".tl))
.paddingRight(8),
],
),
ListTile(
title: Text("Use a config file".tl),
trailing: buildButton(
onPressed: _selectFile,
child: Text("Select".tl),
),
const SizedBox(height: 8),
],
),
),
ListTile(
title: Text("Help".tl),
trailing: buildButton(
onPressed: help,
child: Text("Open".tl),
),
),
ListTile(
title: Text("Check updates".tl),
trailing: _CheckUpdatesButton(),
),
const SizedBox(height: 8),
],
),
).paddingHorizontal(12),
),
);
}
@@ -372,8 +419,7 @@ class _BodyState extends State<_Body> {
}
void help() {
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
launchUrlString("https://github.com/venera-app/venera-configs");
}
Future<void> handleAddSource(String url) async {
@@ -549,3 +595,136 @@ void _addAllPagesWithComicSource(ComicSource source) {
appdata.saveData();
}
class _EditFilePage extends StatefulWidget {
const _EditFilePage(this.path);
final String path;
@override
State<_EditFilePage> createState() => __EditFilePageState();
}
class __EditFilePageState extends State<_EditFilePage> {
var current = '';
@override
void initState() {
super.initState();
current = File(widget.path).readAsStringSync();
}
@override
void dispose() {
File(widget.path).writeAsStringSync(current);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text("Edit".tl),
),
body: Column(
children: [
Container(
height: 0.6,
color: context.colorScheme.outlineVariant,
),
Expanded(
child: CodeEditor(
initialValue: current,
onChanged: (value) => current = value,
),
),
],
),
);
}
}
class _CheckUpdatesButton extends StatefulWidget {
const _CheckUpdatesButton();
@override
State<_CheckUpdatesButton> createState() => _CheckUpdatesButtonState();
}
class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
bool isLoading = false;
void check() async {
setState(() {
isLoading = true;
});
var count = await ComicSourcePage.checkComicSourceUpdate();
if (count == -1) {
context.showMessage(message: "Network error".tl);
} else if (count == 0) {
context.showMessage(message: "No updates".tl);
} else {
context.showMessage(message: "@c updates".tlParams({"c": count}));
}
setState(() {
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Button.normal(
onPressed: check,
isLoading: isLoading,
child: Text("Check".tl),
).fixHeight(32);
}
}
class _CallbackSetting extends StatefulWidget {
const _CallbackSetting({required this.setting});
final MapEntry<String, Map<String, dynamic>> setting;
@override
State<_CallbackSetting> createState() => _CallbackSettingState();
}
class _CallbackSettingState extends State<_CallbackSetting> {
String get key => widget.setting.key;
String get buttonText => widget.setting.value['buttonText'] ?? "Click";
String get title => widget.setting.value['title'] ?? key;
bool isLoading = false;
Future<void> onClick() async {
var func = widget.setting.value['callback'];
var result = func([]);
if (result is Future) {
setState(() {
isLoading = true;
});
try {
await result;
} finally {
setState(() {
isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title.ts(key)),
trailing: Button.normal(
onPressed: onClick,
isLoading: isLoading,
child: Text(buttonText.ts(key)),
).fixHeight(32),
);
}
}

View File

@@ -1,8 +1,14 @@
import 'dart:collection';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
class CommentsPage extends StatefulWidget {
@@ -268,7 +274,10 @@ class _CommentTileState extends State<_CommentTile> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.comment.userName, style: ts.bold,),
Text(
widget.comment.userName,
style: ts.bold,
),
if (widget.comment.time != null)
Text(widget.comment.time!, style: ts.s12),
const SizedBox(height: 4),
@@ -426,7 +435,7 @@ class _CommentTileState extends State<_CommentTile> {
isCancel,
);
if (res.success) {
if(isCancel) {
if (isCancel) {
voteStatus = 0;
} else {
if (isUp) {
@@ -498,6 +507,289 @@ class _CommentContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SelectableText(text);
if (!text.contains('<') && !text.contains('http')) {
return SelectableText(text);
} else {
return RichCommentContent(text: text);
}
}
}
class _Tag {
final String name;
final Map<String, String> attributes;
const _Tag(this.name, this.attributes);
TextSpan merge(TextSpan s, BuildContext context) {
var style = s.style ?? ts;
style = switch (name) {
'b' => style.bold,
'i' => style.italic,
'u' => style.underline,
's' => style.lineThrough,
'a' => style.withColor(context.colorScheme.primary),
'span' => () {
if (attributes.containsKey('style')) {
var s = attributes['style']!;
var css = s.split(';');
for (var c in css) {
var kv = c.split(':');
if (kv.length == 2) {
var key = kv[0].trim();
var value = kv[1].trim();
switch (key) {
case 'color':
// Color is not supported, we should make text display well in light and dark mode.
break;
case 'font-weight':
if (value == 'bold') {
style = style.bold;
} else if (value == 'lighter') {
style = style.light;
}
break;
case 'font-style':
if (value == 'italic') {
style = style.italic;
}
break;
case 'text-decoration':
if (value == 'underline') {
style = style.underline;
} else if (value == 'line-through') {
style = style.lineThrough;
}
break;
case 'font-size':
// Font size is not supported.
break;
}
}
}
}
return style;
}(),
_ => style,
};
if (style.color != null) {
style = style.copyWith(decorationColor: style.color);
}
var recognizer = s.recognizer;
if (name == 'a') {
var link = attributes['href'];
if (link != null && link.isURL) {
recognizer = TapGestureRecognizer()
..onTap = () {
handleLink(link);
};
}
}
return TextSpan(
text: s.text,
style: style,
recognizer: recognizer,
);
}
static void handleLink(String link) async {
if (link.isURL) {
if (await handleAppLink(Uri.parse(link))) {
Navigator.of(App.rootContext).maybePop();
} else {
launchUrlString(link);
}
}
}
}
class _CommentImage {
final String url;
final String? link;
const _CommentImage(this.url, this.link);
}
class RichCommentContent extends StatefulWidget {
const RichCommentContent({super.key, required this.text});
final String text;
@override
State<RichCommentContent> createState() => _RichCommentContentState();
}
class _RichCommentContentState extends State<RichCommentContent> {
var textSpan = <InlineSpan>[];
var images = <_CommentImage>[];
@override
void didChangeDependencies() {
render();
super.didChangeDependencies();
}
bool isValidUrlChar(String char) {
return RegExp(r'[a-zA-Z0-9%:/.@\-_?&=#*!+;]').hasMatch(char);
}
void render() {
var s = Queue<_Tag>();
int i = 0;
var buffer = StringBuffer();
var text = widget.text;
text = text.replaceAll('\r\n', '\n');
text = text.replaceAll('&amp;', '&');
void writeBuffer() {
if (buffer.isEmpty) return;
var span = TextSpan(text: buffer.toString());
for (var tag in s) {
span = tag.merge(span, context);
}
textSpan.add(span);
buffer.clear();
}
while (i < text.length) {
if (text[i] == '<' && i != text.length - 1) {
if (text[i + 1] != '/') {
// start tag
var j = text.indexOf('>', i);
if (j != -1) {
var tagContent = text.substring(i + 1, j);
var splits = tagContent.split(' ');
splits.removeWhere((element) => element.isEmpty);
var tagName = splits[0];
var attributes = <String, String>{};
for (var k = 1; k < splits.length; k++) {
var attr = splits[k];
var attrSplits = attr.split('=');
if (attrSplits.length == 2) {
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', '');
}
}
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span'];
if (acceptedTags.contains(tagName)) {
writeBuffer();
if (tagName == 'img') {
var url = attributes['src'];
String? link;
for (var tag in s) {
if (tag.name == 'a') {
link = tag.attributes['href'];
break;
}
}
if (url != null) {
images.add(_CommentImage(url, link));
}
} else if (tagName == 'br') {
buffer.write('\n');
} else {
s.add(_Tag(tagName, attributes));
}
i = j + 1;
continue;
}
}
} else {
// end tag
var j = text.indexOf('>', i);
if (j != -1) {
var tagContent = text.substring(i + 2, j);
var splits = tagContent.split(' ');
splits.removeWhere((element) => element.isEmpty);
var tagName = splits[0];
if (s.isNotEmpty && s.last.name == tagName) {
writeBuffer();
s.removeLast();
i = j + 1;
continue;
}
if (tagName == 'br') {
i = j + 1;
buffer.write('\n');
continue;
}
}
}
} else if (text.length - i > 8 &&
text.substring(i, i + 4) == 'http' &&
!s.any((e) => e.name == 'a')) {
// auto link
int j = i;
for (; j < text.length; j++) {
if (!isValidUrlChar(text[j])) {
break;
}
}
var url = text.substring(i, j);
if (url.isURL) {
writeBuffer();
textSpan.add(TextSpan(
text: url,
style: ts.withColor(context.colorScheme.primary),
recognizer: TapGestureRecognizer()
..onTap = () {
_Tag.handleLink(url);
},
));
i = j;
continue;
}
}
buffer.write(text[i]);
i++;
}
writeBuffer();
}
@override
Widget build(BuildContext context) {
Widget content = SelectableText.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: textSpan,
),
);
if (images.isNotEmpty) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
content,
Wrap(
runSpacing: 4,
spacing: 4,
children: images.map((e) {
Widget image = Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainerLow,
),
width: 100,
height: 100,
child: Image(
width: 100,
height: 100,
image: CachedImageProvider(e.url),
),
);
if (e.link != null) {
image = InkWell(
onTap: () {
_Tag.handleLink(e.link!);
},
child: image,
);
}
return image;
}).toList(),
)
],
);
}
return content;
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/download.dart';
import 'package:venera/utils/io.dart';
@@ -27,7 +28,9 @@ class _DownloadingPageState extends State<DownloadingPage> {
}
void update() {
setState(() {});
if(mounted) {
setState(() {});
}
}
@override
@@ -159,8 +162,8 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> {
clipBehavior: Clip.antiAlias,
child: widget.task.cover == null
? null
: Image.file(
File(widget.task.cover!),
: Image(
image: CachedImageProvider(widget.task.cover!),
filterQuality: FilterQuality.medium,
fit: BoxFit.cover,
),
@@ -204,6 +207,7 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> {
Text(
widget.task.message,
style: ts.s12,
maxLines: 3,
),
const SizedBox(height: 4),
LinearProgressIndicator(

View File

@@ -46,6 +46,18 @@ class _ExplorePageState extends State<ExplorePage>
}
}
void onNaviItemTapped(int index) {
if (index == 2) {
int page = controller.index;
String currentPageId = pages[page];
StateController.find<SimpleController>(tag: currentPageId)
.control!()['toTop']
?.call();
}
}
NaviPaneState? naviPane;
@override
void initState() {
pages = List<String>.from(appdata.settings["explore_pages"]);
@@ -59,13 +71,21 @@ class _ExplorePageState extends State<ExplorePage>
vsync: this,
);
appdata.settings.addListener(onSettingsChanged);
NaviPane.of(context).addNaviItemTapListener(onNaviItemTapped);
super.initState();
}
@override
void didChangeDependencies() {
naviPane = NaviPane.of(context);
super.didChangeDependencies();
}
@override
void dispose() {
controller.dispose();
appdata.settings.removeListener(onSettingsChanged);
naviPane?.removeNaviItemTapListener(onNaviItemTapped);
super.dispose();
}
@@ -90,11 +110,20 @@ class _ExplorePageState extends State<ExplorePage>
return Tab(text: i.ts(comicSource.key), key: Key(i));
}
Widget buildBody(String i) => _SingleExplorePage(i, key: Key(i));
Widget buildBody(String i) => Material(
child: _SingleExplorePage(i, key: PageStorageKey(i)),
);
Widget buildEmpty() {
var msg = "No Explore Pages".tl;
msg += '\n';
if (ComicSource.isEmpty) {
msg += "Add a comic source in home page".tl;
} else {
msg += "Please check your settings".tl;
}
return NetworkError(
message: "No Explore Pages".tl,
message: msg,
retry: () {
setState(() {
pages = ComicSource.all()
@@ -120,7 +149,7 @@ class _ExplorePageState extends State<ExplorePage>
Widget tabBar = Material(
child: FilledTabBar(
key: Key(pages.toString()),
key: PageStorageKey(pages.toString()),
tabs: pages.map((e) => buildTab(e)).toList(),
controller: controller,
),
@@ -213,18 +242,14 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
with AutomaticKeepAliveClientMixin<_SingleExplorePage> {
late final ExplorePageData data;
bool loading = true;
String? message;
List<ExplorePagePart>? parts;
late final String comicSourceKey;
int key = 0;
bool _wantKeepAlive = true;
var scrollController = ScrollController();
VoidCallback? refreshHandler;
void onSettingsChanged() {
var explorePages = appdata.settings["explore_pages"];
if (!explorePages.contains(widget.title)) {
@@ -259,14 +284,35 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
Widget build(BuildContext context) {
super.build(context);
if (data.loadMultiPart != null) {
return buildMultiPart();
return _MultiPartExplorePage(
key: const PageStorageKey("comic_list"),
data: data,
controller: scrollController,
comicSourceKey: comicSourceKey,
refreshHandlerCallback: (c) {
refreshHandler = c;
},
);
} else if (data.loadPage != null || data.loadNext != null) {
return buildComicList();
return ComicList(
enablePageStorage: true,
loadPage: data.loadPage,
loadNext: data.loadNext,
key: const PageStorageKey("comic_list"),
controller: scrollController,
refreshHandlerCallback: (c) {
refreshHandler = c;
},
);
} else if (data.loadMixed != null) {
return _MixedExplorePage(
data,
comicSourceKey,
key: ValueKey(key),
key: const PageStorageKey("comic_list"),
controller: scrollController,
refreshHandlerCallback: (c) {
refreshHandler = c;
},
);
} else {
return const Center(
@@ -275,91 +321,59 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
}
}
Widget buildComicList() {
return ComicList(
loadPage: data.loadPage,
loadNext: data.loadNext,
key: ValueKey(key),
);
}
void load() async {
var res = await data.loadMultiPart!();
loading = false;
if (mounted) {
setState(() {
if (res.error) {
message = res.errorMessage;
} else {
parts = res.data;
}
});
}
}
Widget buildMultiPart() {
if (loading) {
load();
return const Center(
child: CircularProgressIndicator(),
);
} else if (message != null) {
return NetworkError(
message: message!,
retry: refresh,
withAppbar: false,
);
} else {
return buildPage();
}
}
Widget buildPage() {
return SmoothCustomScrollView(
slivers: _buildPage().toList(),
);
}
Iterable<Widget> _buildPage() sync* {
for (var part in parts!) {
yield* _buildExplorePagePart(part, comicSourceKey);
}
}
@override
Object? get tag => widget.title;
@override
void refresh() {
message = null;
if (data.loadMultiPart != null) {
setState(() {
loading = true;
});
} else {
setState(() {
key++;
});
}
refreshHandler?.call();
}
@override
bool get wantKeepAlive => _wantKeepAlive;
void toTop() {
if (scrollController.hasClients) {
scrollController.animateTo(
scrollController.position.minScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
}
}
@override
Map<String, dynamic> get control => {"toTop": toTop};
}
class _MixedExplorePage extends StatefulWidget {
const _MixedExplorePage(this.data, this.sourceKey, {super.key});
const _MixedExplorePage(this.data, this.sourceKey,
{super.key, this.controller, required this.refreshHandlerCallback});
final ExplorePageData data;
final String sourceKey;
final ScrollController? controller;
final void Function(VoidCallback c) refreshHandlerCallback;
@override
State<_MixedExplorePage> createState() => _MixedExplorePageState();
}
class _MixedExplorePageState
extends MultiPageLoadingState<_MixedExplorePage, Object> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
widget.refreshHandlerCallback(refresh);
}
void refresh() {
reset();
}
Iterable<Widget> buildSlivers(BuildContext context, List<Object> data) sync* {
List<Comic> cache = [];
for (var part in data) {
@@ -387,9 +401,10 @@ class _MixedExplorePageState
@override
Widget buildContent(BuildContext context, List<Object> data) {
return SmoothCustomScrollView(
controller: widget.controller,
slivers: [
...buildSlivers(context, data),
if (haveNextPage) const ListLoadingIndicator().toSliver()
const SliverListLoadingIndicator(),
],
);
}
@@ -470,3 +485,125 @@ Iterable<Widget> _buildExplorePagePart(
yield buildTitle(part);
yield buildComics(part);
}
class _MultiPartExplorePage extends StatefulWidget {
const _MultiPartExplorePage({
super.key,
required this.data,
required this.controller,
required this.comicSourceKey,
required this.refreshHandlerCallback,
});
final ExplorePageData data;
final ScrollController controller;
final String comicSourceKey;
final void Function(VoidCallback c) refreshHandlerCallback;
@override
State<_MultiPartExplorePage> createState() => _MultiPartExplorePageState();
}
class _MultiPartExplorePageState extends State<_MultiPartExplorePage> {
late final ExplorePageData data;
List<ExplorePagePart>? parts;
bool loading = true;
String? message;
Map<String, dynamic> get state => {
"loading": loading,
"message": message,
"parts": parts,
};
void restoreState(dynamic state) {
if (state == null) return;
loading = state["loading"];
message = state["message"];
parts = state["parts"];
}
void storeState() {
PageStorage.of(context).writeState(context, state);
}
void refresh() {
setState(() {
loading = true;
message = null;
parts = null;
});
storeState();
}
@override
void initState() {
super.initState();
data = widget.data;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
restoreState(PageStorage.of(context).readState(context));
widget.refreshHandlerCallback(refresh);
}
void load() async {
var res = await data.loadMultiPart!();
loading = false;
if (mounted) {
setState(() {
if (res.error) {
message = res.errorMessage;
} else {
parts = res.data;
}
});
storeState();
}
}
@override
Widget build(BuildContext context) {
if (loading) {
load();
return const Center(
child: CircularProgressIndicator(),
);
} else if (message != null) {
return NetworkError(
message: message!,
retry: () {
setState(() {
loading = true;
message = null;
});
},
withAppbar: false,
);
} else {
return buildPage();
}
}
Widget buildPage() {
return SmoothCustomScrollView(
key: const PageStorageKey('scroll'),
controller: widget.controller,
slivers: _buildPage().toList(),
);
}
Iterable<Widget> _buildPage() sync* {
for (var part in parts!) {
yield* _buildExplorePagePart(part, widget.comicSourceKey);
}
}
}

View File

@@ -6,7 +6,6 @@ Future<void> newFolder() async {
context: App.rootContext,
builder: (context) {
var controller = TextEditingController();
var folders = LocalFavoritesManager().folderNames;
String? error;
return StatefulBuilder(builder: (context, setState) {
@@ -35,12 +34,11 @@ Future<void> newFolder() async {
child: Text("Import from file".tl),
onPressed: () async {
var file = await selectFile(ext: ['json']);
if(file == null) return;
if (file == null) return;
var data = await file.readAsBytes();
try {
LocalFavoritesManager().fromJson(utf8.decode(data));
}
catch(e) {
} catch (e) {
context.showMessage(message: "Failed to import".tl);
return;
}
@@ -79,13 +77,13 @@ String? validateFolderName(String newFolderName) {
return null;
}
void addFavorite(Comic comic) {
void addFavorite(List<Comic> comics) {
var folders = LocalFavoritesManager().folderNames;
showDialog(
context: App.rootContext,
builder: (context) {
String? selectedFolder;
String? selectedFolder = appdata.settings['quickFavorite'];
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
@@ -107,17 +105,21 @@ void addFavorite(Comic comic) {
FilledButton(
onPressed: () {
if (selectedFolder != null) {
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle ?? '',
type: ComicType((comic.sourceKey == 'local' ? 0 : comic.sourceKey.hashCode)),
tags: comic.tags ?? [],
),
);
for (var comic in comics) {
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle ?? '',
type: ComicType((comic.sourceKey == 'local'
? 0
: comic.sourceKey.hashCode)),
tags: comic.tags ?? [],
),
);
}
context.pop();
}
},
@@ -129,3 +131,380 @@ void addFavorite(Comic comic) {
},
);
}
Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var comics = LocalFavoritesManager().getAllComics(folder);
Future<void> updateSingleComic(int index) async {
int retry = 3;
while (true) {
try {
var c = comics[index];
var comicSource = c.type.comicSource;
if (comicSource == null) return;
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
comics[index] = FavoriteItem(
id: c.id,
name: newInfo.title,
coverPath: newInfo.cover,
author: newInfo.subTitle ??
newInfo.tags['author']?.firstOrNull ??
c.author,
type: c.type,
tags: newTags,
);
LocalFavoritesManager().updateInfo(folder, comics[index]);
return;
} catch (e) {
retry--;
if (retry == 0) {
rethrow;
}
continue;
}
}
}
var finished = ValueNotifier(0);
var errors = 0;
var index = 0;
bool isCanceled = false;
showDialog(
context: App.rootContext,
builder: (context) {
return ValueListenableBuilder(
valueListenable: finished,
builder: (context, value, child) {
var isFinished = value == comics.length;
return ContentDialog(
title: isFinished ? "Finished".tl : "Updating".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
LinearProgressIndicator(
value: value / comics.length,
),
const SizedBox(height: 4),
Text("$value/${comics.length}"),
const SizedBox(height: 4),
if (errors > 0) Text("Errors: $errors"),
],
).paddingHorizontal(16),
actions: [
Button.filled(
color: isFinished ? null : context.colorScheme.error,
onPressed: () {
isCanceled = true;
context.pop();
},
child: isFinished ? Text("OK".tl) : Text("Cancel".tl),
),
],
);
},
);
},
).then((_) {
isCanceled = true;
});
while (index < comics.length) {
var futures = <Future>[];
const maxConcurrency = 4;
if (isCanceled) {
return comics;
}
for (var i = 0; i < maxConcurrency; i++) {
if (index + i >= comics.length) break;
futures.add(updateSingleComic(index + i).then((v) {
finished.value++;
}, onError: (_) {
errors++;
finished.value++;
}));
}
await Future.wait(futures);
index += maxConcurrency;
}
return comics;
}
Future<void> sortFolders() async {
var folders = LocalFavoritesManager().folderNames;
await showPopUpWidget(
App.rootContext,
StatefulBuilder(builder: (context, setState) {
return PopUpWidgetScaffold(
title: "Sort".tl,
tailing: [
Tooltip(
message: "Help".tl,
child: IconButton(
icon: const Icon(Icons.help_outline),
onPressed: () {
showInfoDialog(
context: context,
title: "Reorder".tl,
content: "Long press and drag to reorder.".tl,
);
},
),
)
],
body: ReorderableListView.builder(
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex--;
}
setState(() {
var item = folders.removeAt(oldIndex);
folders.insert(newIndex, item);
});
},
itemCount: folders.length,
itemBuilder: (context, index) {
return ListTile(
key: ValueKey(folders[index]),
title: Text(folders[index]),
);
},
),
);
}),
);
LocalFavoritesManager().updateOrder(folders);
}
Future<void> importNetworkFolder(
String source,
int updatePageNum,
String? folder,
String? folderID,
) async {
var comicSource = ComicSource.find(source);
if (comicSource == null) {
return;
}
if (folder != null && folder.isEmpty) {
folder = null;
}
var resultName = folder ?? comicSource.name;
var exists = LocalFavoritesManager().existsFolder(resultName);
if (exists) {
if (!LocalFavoritesManager()
.isLinkedToNetworkFolder(resultName, source, folderID ?? "")) {
App.rootContext.showMessage(message: "Folder already exists".tl);
return;
}
}
if (!exists) {
LocalFavoritesManager().createFolder(resultName);
LocalFavoritesManager().linkFolderToNetwork(
resultName,
source,
folderID ?? "",
);
}
bool isOldToNewSort = comicSource.favoriteData?.isOldToNewSort ?? false;
var current = 0;
int receivedComics = 0;
int requestCount = 0;
var isFinished = false;
int maxPage = 1;
List<FavoriteItem> comics = [];
String? next;
// 如果是从旧到新, 先取一下maxPage
if (isOldToNewSort) {
var res = await comicSource.favoriteData?.loadComic!(1, folderID);
maxPage = res?.subData ?? 1;
}
Future<void> fetchNext() async {
var retry = 3;
while (updatePageNum > requestCount && !isFinished) {
try {
if (comicSource.favoriteData?.loadComic != null) {
// 从旧到新的情况下, 假设有10页, 更新3页, 则从第8页开始, 8, 9, 10 三页
next ??=
isOldToNewSort ? (maxPage - updatePageNum + 1).toString() : '1';
var page = int.parse(next!);
var res = await comicSource.favoriteData!.loadComic!(page, folderID);
var count = 0;
receivedComics += res.data.length;
for (var c in res.data) {
if (!LocalFavoritesManager()
.comicExists(resultName, c.id, ComicType(source.hashCode))) {
count++;
comics.add(FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
));
}
}
requestCount++;
current += count;
if (res.data.isEmpty || res.subData == page) {
isFinished = true;
next = null;
} else {
next = (page + 1).toString();
}
} else if (comicSource.favoriteData?.loadNext != null) {
var res = await comicSource.favoriteData!.loadNext!(next, folderID);
var count = 0;
receivedComics += res.data.length;
for (var c in res.data) {
if (!LocalFavoritesManager()
.comicExists(resultName, c.id, ComicType(source.hashCode))) {
count++;
comics.add(FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
));
}
}
requestCount++;
current += count;
if (res.data.isEmpty || res.subData == null) {
isFinished = true;
next = null;
} else {
next = res.subData;
}
} else {
throw "Unsupported source";
}
return;
} catch (e) {
retry--;
if (retry == 0) {
rethrow;
}
continue;
}
}
// 跳出循环, 表示已经完成, 强制为 true, 避免死循环
isFinished = true;
}
bool isCanceled = false;
String? errorMsg;
bool isErrored() => errorMsg != null;
void Function()? updateDialog;
void Function()? closeDialog;
showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
updateDialog = () => setState(() {});
closeDialog = () => Navigator.pop(context);
return ContentDialog(
title: isFinished
? "Finished".tl
: isErrored()
? "Error".tl
: "Importing".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
LinearProgressIndicator(
value: isFinished ? 1 : null,
),
const SizedBox(height: 4),
Text("Imported @a comics, loaded @b pages, received @c comics"
.tlParams({
"a": current,
"b": requestCount,
"c": receivedComics,
})),
const SizedBox(height: 4),
if (isErrored()) Text("Error: $errorMsg"),
],
).paddingHorizontal(16),
actions: [
Button.filled(
color: (isFinished || isErrored())
? null
: context.colorScheme.error,
onPressed: () {
isCanceled = true;
context.pop();
},
child: (isFinished || isErrored())
? Text("OK".tl)
: Text("Cancel".tl),
),
],
);
},
);
},
).then((_) {
isCanceled = true;
});
while (!isFinished && !isCanceled) {
try {
await fetchNext();
updateDialog?.call();
} catch (e) {
errorMsg = e.toString();
updateDialog?.call();
break;
}
}
try {
if (appdata.settings['newFavoriteAddTo'] == "start" && !isOldToNewSort) {
// 如果是插到最前, 并且是从新到旧, 反转一下
comics = comics.reversed.toList();
}
for (var c in comics) {
LocalFavoritesManager().addComic(resultName, c);
}
// 延迟一点, 让用户看清楚到底新增了多少
await Future.delayed(const Duration(milliseconds: 500));
closeDialog?.call();
} catch (e, stackTrace) {
Log.error("Unhandled Exception", e.toString(), stackTrace);
}
}

View File

@@ -8,8 +8,14 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
@@ -30,7 +36,7 @@ class FavoritesPage extends StatefulWidget {
State<FavoritesPage> createState() => _FavoritesPageState();
}
class _FavoritesPageState extends State<FavoritesPage> {
class _FavoritesPageState extends State<FavoritesPage> {
String? folder;
bool isNetwork = false;
@@ -53,7 +59,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
@override
void initState() {
var data = appdata.implicitData['favoriteFolder'];
if(data != null){
if (data != null) {
folder = data['name'];
isNetwork = data['isNetwork'] ?? false;
}
@@ -90,13 +96,13 @@ class _FavoritesPageState extends State<FavoritesPage> {
barrierDismissible: true,
fullscreenDialog: true,
opaque: false,
barrierColor: Colors.black.withOpacity(0.36),
barrierColor: Colors.black.toOpacity(0.36),
pageBuilder: (context, animation, secondary) {
return Align(
alignment: Alignment.centerLeft,
child: Material(
child: SizedBox(
width: min(300, context.width-16),
width: min(300, context.width - 16),
child: _LeftBar(
withAppbar: true,
favPage: this,
@@ -148,13 +154,16 @@ class _FavoritesPageState extends State<FavoritesPage> {
);
}
if (!isNetwork) {
return _LocalFavoritesPage(folder: folder!, key: Key(folder!));
return _LocalFavoritesPage(
folder: folder!, key: PageStorageKey("local_$folder"));
} else {
var favoriteData = getFavoriteDataOrNull(folder!);
if (favoriteData == null) {
return const Center(child: Text("Unknown source"));
folder = null;
return buildBody();
} else {
return NetworkFavoritePage(favoriteData, key: Key(folder!));
return NetworkFavoritePage(favoriteData,
key: PageStorageKey("network_$folder"));
}
}
}
@@ -164,4 +173,4 @@ abstract interface class FolderList {
void update();
void updateFolders();
}
}

View File

@@ -14,147 +14,686 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
late List<FavoriteItem> comics;
String? networkSource;
String? networkFolder;
Map<Comic, bool> selectedComics = {};
var selectedLocalFolders = <String>{};
late List<String> added = [];
String keyword = "";
bool searchMode = false;
bool multiSelectMode = false;
int? lastSelectedIndex;
void updateComics() {
print(comics.length);
setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder);
print(comics.length);
});
if (keyword.isEmpty) {
setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder);
});
} else {
setState(() {
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword);
});
}
}
@override
void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
comics = LocalFavoritesManager().getAllComics(widget.folder);
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a;
networkFolder = b;
LocalFavoritesManager().addListener(updateComics);
super.initState();
}
@override
void dispose() {
super.dispose();
LocalFavoritesManager().removeListener(updateComics);
}
void selectAll() {
setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
});
}
void invertSelection() {
setState(() {
comics.asMap().forEach((k, v) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
});
selectedComics.removeWhere((k, v) => !v);
});
}
bool downloadComic(FavoriteItem c) {
var source = c.type.comicSource;
if (source != null) {
bool isDownloaded = LocalManager().isDownloaded(
c.id,
(c).type,
);
if (isDownloaded) {
return false;
}
LocalManager().addTask(ImagesDownloadTask(
source: source,
comicId: c.id,
comicTitle: c.title,
));
return true;
}
return false;
}
void downloadSelected() {
int count = 0;
for (var c in selectedComics.keys) {
if (downloadComic(c as FavoriteItem)) {
count++;
}
}
if (count > 0) {
context.showMessage(
message: "Added @c comics to download queue.".tlParams({"c": count}),
);
}
}
var scrollController = ScrollController();
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
Widget body = SmoothCustomScrollView(
controller: scrollController,
slivers: [
SliverAppbar(
leading: Tooltip(
message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth
? IconButton(
icon: const Icon(Icons.menu),
color: context.colorScheme.primary,
onPressed: favPage.showFolderSelector,
)
: const SizedBox(),
),
title: GestureDetector(
onTap: context.width < _kTwoPanelChangeWidth
? favPage.showFolderSelector
: null,
child: Text(favPage.folder ?? "Unselected".tl),
),
actions: [
MenuButton(
entries: [
if (!searchMode && !multiSelectMode)
SliverAppbar(
style: context.width < changePoint
? AppbarStyle.shadow
: AppbarStyle.blur,
leading: Tooltip(
message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth
? IconButton(
icon: const Icon(Icons.menu),
color: context.colorScheme.primary,
onPressed: favPage.showFolderSelector,
)
: const SizedBox(),
),
title: GestureDetector(
onTap: context.width < _kTwoPanelChangeWidth
? favPage.showFolderSelector
: null,
child: Text(favPage.folder ?? "Unselected".tl),
),
actions: [
if (networkSource != null)
Tooltip(
message: "Sync".tl,
child: Flyout(
flyoutBuilder: (context) {
final GlobalKey<_SelectUpdatePageNumState>
selectUpdatePageNumKey =
GlobalKey<_SelectUpdatePageNumState>();
var updatePageWidget = _SelectUpdatePageNum(
networkSource: networkSource!,
networkFolder: networkFolder,
key: selectUpdatePageNumKey,
);
return FlyoutContent(
title: "Sync".tl,
content: updatePageWidget,
actions: [
Button.filled(
child: Text("Update".tl),
onPressed: () {
context.pop();
importNetworkFolder(
networkSource!,
selectUpdatePageNumKey
.currentState!.updatePageNum,
widget.folder,
networkFolder!,
).then(
(value) {
updateComics();
},
);
},
),
],
);
},
child: Builder(builder: (context) {
return IconButton(
icon: const Icon(Icons.sync),
onPressed: () {
Flyout.of(context).show();
},
);
}),
),
),
Tooltip(
message: "Search".tl,
child: IconButton(
icon: const Icon(Icons.search),
onPressed: () {
setState(() {
searchMode = true;
});
},
),
),
MenuButton(
entries: [
MenuEntry(
icon: Icons.edit_outlined,
text: "Rename".tl,
onClick: () {
showInputDialog(
context: App.rootContext,
title: "Rename".tl,
hintText: "New Name".tl,
onConfirm: (value) {
var err = validateFolderName(value.toString());
if (err != null) {
return err;
}
LocalFavoritesManager().rename(
widget.folder,
value.toString(),
);
favPage.folderList?.updateFolders();
favPage.setFolder(false, value.toString());
return null;
},
);
}),
MenuEntry(
icon: Icons.reorder,
text: "Reorder".tl,
onClick: () {
context.to(
() {
return _ReorderComicsPage(
widget.folder,
(comics) {
this.comics = comics;
},
);
},
).then(
(value) {
if (mounted) {
setState(() {});
}
},
);
}),
MenuEntry(
icon: Icons.upload_file,
text: "Export".tl,
onClick: () {
var json = LocalFavoritesManager().folderToJson(
widget.folder,
);
saveFile(
data: utf8.encode(json),
filename: "${widget.folder}.json",
);
}),
MenuEntry(
icon: Icons.update,
text: "Update Comics Info".tl,
onClick: () {
updateComicsInfo(widget.folder).then((newComics) {
if (mounted) {
setState(() {
comics = newComics;
});
}
});
}),
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Folder".tl,
color: context.colorScheme.error,
onClick: () {
showConfirmDialog(
context: App.rootContext,
title: "Delete".tl,
content: "Delete folder '@f' ?".tlParams({
"f": widget.folder,
}),
btnColor: context.colorScheme.error,
onConfirm: () {
favPage.setFolder(false, null);
LocalFavoritesManager().deleteFolder(widget.folder);
favPage.folderList?.updateFolders();
},
);
}),
],
),
],
)
else if (multiSelectMode)
SliverAppbar(
style: context.width < changePoint
? AppbarStyle.shadow
: AppbarStyle.blur,
leading: Tooltip(
message: "Cancel".tl,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
},
),
),
title: Text(
"Selected @c comics".tlParams({"c": selectedComics.length})),
actions: [
MenuButton(entries: [
MenuEntry(
icon: Icons.drive_file_move,
text: "Move to folder".tl,
onClick: () => favoriteOption('move')),
MenuEntry(
icon: Icons.copy,
text: "Copy to folder".tl,
onClick: () => favoriteOption('add')),
MenuEntry(
icon: Icons.select_all,
text: "Select All".tl,
onClick: selectAll),
MenuEntry(
icon: Icons.deselect,
text: "Deselect".tl,
onClick: _cancel),
MenuEntry(
icon: Icons.flip,
text: "Invert Selection".tl,
onClick: invertSelection),
MenuEntry(
icon: Icons.delete_outline,
text: "Delete Folder".tl,
text: "Delete Comic".tl,
color: context.colorScheme.error,
onClick: () {
showConfirmDialog(
context: App.rootContext,
context: context,
title: "Delete".tl,
content:
"Are you sure you want to delete this folder?".tl,
content: "Delete @c comics?"
.tlParams({"c": selectedComics.length}),
btnColor: context.colorScheme.error,
onConfirm: () {
favPage.setFolder(false, null);
LocalFavoritesManager().deleteFolder(widget.folder);
favPage.folderList?.updateFolders();
_deleteComicWithId();
},
);
}),
MenuEntry(
icon: Icons.edit_outlined,
text: "Rename".tl,
onClick: () {
showInputDialog(
context: App.rootContext,
title: "Rename".tl,
hintText: "New Name".tl,
onConfirm: (value) {
var err = validateFolderName(value.toString());
if (err != null) {
return err;
}
LocalFavoritesManager().rename(
widget.folder,
value.toString(),
);
favPage.folderList?.updateFolders();
favPage.setFolder(false, value.toString());
return null;
},
);
}),
MenuEntry(
icon: Icons.reorder,
text: "Reorder".tl,
onClick: () {
context.to(
() {
return _ReorderComicsPage(
widget.folder,
(comics) {
this.comics = comics;
},
);
},
).then(
(value) {
setState(() {});
},
);
}),
MenuEntry(
icon: Icons.upload_file,
text: "Export".tl,
onClick: () {
var json = LocalFavoritesManager().folderToJson(
widget.folder,
);
saveFile(
data: utf8.encode(json),
filename: "${widget.folder}.json",
);
}),
],
icon: Icons.download,
text: "Download".tl,
onClick: downloadSelected,
),
]),
],
)
else if (searchMode)
SliverAppbar(
style: context.width < changePoint
? AppbarStyle.shadow
: AppbarStyle.blur,
leading: Tooltip(
message: "Cancel".tl,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
searchMode = false;
keyword = "";
updateComics();
});
},
),
),
],
),
title: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
),
onChanged: (v) {
keyword = v;
updateComics();
},
),
),
SliverGridComics(
comics: comics,
selections: selectedComics,
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.delete_outline,
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
showConfirmDialog(
context: context,
title: "Delete".tl,
content: "Are you sure you want to delete this comic?".tl,
onConfirm: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
updateComics();
},
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
),
MenuEntry(
icon: Icons.check,
text: "Select".tl,
onClick: () {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
}
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
onClick: () {
downloadComic(c as FavoriteItem);
context.showMessage(
message: "Download started".tl,
);
},
),
if (appdata.settings["onClickFavorite"] == "viewDetail")
MenuEntry(
icon: Icons.menu_book_outlined,
text: "Read".tl,
onClick: () {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
},
),
];
},
onTap: (c) {
if (multiSelectMode) {
setState(() {
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
} else {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
}
},
onLongPressed: (c) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
} else {
if (lastSelectedIndex != null) {
int start = lastSelectedIndex!;
int end = comics.indexOf(c as FavoriteItem);
if (start > end) {
int temp = start;
start = end;
end = temp;
}
for (int i = start; i <= end; i++) {
if (i == lastSelectedIndex) continue;
var comic = comics[i];
if (selectedComics.containsKey(comic)) {
selectedComics.remove(comic);
} else {
selectedComics[comic] = true;
}
}
}
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
}
_checkExitSelectMode();
});
},
),
],
);
body = Scrollbar(
controller: scrollController,
thickness: App.isDesktop ? 8 : 12,
radius: const Radius.circular(8),
interactive: true,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: body,
),
);
return PopScope(
canPop: !multiSelectMode && !searchMode,
onPopInvokedWithResult: (didPop, result) {
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
} else if (searchMode) {
setState(() {
searchMode = false;
keyword = "";
updateComics();
});
}
},
child: body,
);
}
void favoriteOption(String option) {
var targetFolders = LocalFavoritesManager()
.folderNames
.where((folder) => folder != favPage.folder)
.toList();
showPopUpWidget(
App.rootContext,
StatefulBuilder(
builder: (context, setState) {
return PopUpWidgetScaffold(
title: favPage.folder ?? "Unselected".tl,
body: Padding(
padding: EdgeInsets.only(bottom: context.padding.bottom + 16),
child: Container(
constraints:
const BoxConstraints(maxHeight: 700, maxWidth: 500),
child: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: targetFolders.length + 1,
itemBuilder: (context, index) {
if (index == targetFolders.length) {
return SizedBox(
height: 36,
child: Center(
child: TextButton(
onPressed: () {
newFolder().then((v) {
setState(() {
targetFolders = LocalFavoritesManager()
.folderNames
.where((folder) =>
folder != favPage.folder)
.toList();
});
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 4),
Text("New Folder".tl),
],
),
),
),
);
}
var folder = targetFolders[index];
var disabled = false;
if (selectedLocalFolders.isNotEmpty) {
if (added.contains(folder) &&
!added.contains(selectedLocalFolders.first)) {
disabled = true;
} else if (!added.contains(folder) &&
added.contains(selectedLocalFolders.first)) {
disabled = true;
}
}
return CheckboxListTile(
title: Row(
children: [
Text(folder),
const SizedBox(width: 8),
],
),
value: selectedLocalFolders.contains(folder),
onChanged: disabled
? null
: (v) {
setState(() {
if (v!) {
selectedLocalFolders.add(folder);
} else {
selectedLocalFolders.remove(folder);
}
});
},
);
},
),
),
Center(
child: FilledButton(
onPressed: () {
if (selectedLocalFolders.isEmpty) {
return;
}
if (option == 'move') {
for (var c in selectedComics.keys) {
for (var s in selectedLocalFolders) {
LocalFavoritesManager().moveFavorite(
favPage.folder as String,
s,
c.id,
(c as FavoriteItem).type);
}
}
} else {
for (var c in selectedComics.keys) {
for (var s in selectedLocalFolders) {
LocalFavoritesManager().addComic(
s,
FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
author: c.subtitle ?? '',
type: ComicType((c.sourceKey == 'local'
? 0
: c.sourceKey.hashCode)),
tags: c.tags ?? [],
),
);
}
}
}
App.rootContext.pop();
updateComics();
_cancel();
},
child: Text(option == 'move' ? "Move".tl : "Add".tl),
),
),
],
),
),
),
);
},
),
);
}
void _checkExitSelectMode() {
if (selectedComics.isEmpty) {
setState(() {
multiSelectMode = false;
});
}
}
void _cancel() {
setState(() {
selectedComics.clear();
multiSelectMode = false;
});
}
void _deleteComicWithId() {
for (var c in selectedComics.keys) {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
}
_cancel();
}
}
@@ -176,12 +715,19 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
late var comics = LocalFavoritesManager().getAllComics(widget.name);
bool changed = false;
Color lightenColor(Color color, double lightenValue) {
int red = (color.red + ((255 - color.red) * lightenValue)).round();
int green = (color.green + ((255 - color.green) * lightenValue)).round();
int blue = (color.blue + ((255 - color.blue) * lightenValue)).round();
static int _floatToInt8(double x) {
return (x * 255.0).round() & 0xff;
}
return Color.fromARGB(color.alpha, red, green, blue);
Color lightenColor(Color color, double lightenValue) {
int red =
(_floatToInt8(color.r) + ((255 - color.r) * lightenValue)).round();
int green = (_floatToInt8(color.g) * 255 + ((255 - color.g) * lightenValue))
.round();
int blue = (_floatToInt8(color.b) * 255 + ((255 - color.b) * lightenValue))
.round();
return Color.fromARGB(_floatToInt8(color.a), red, green, blue);
}
@override
@@ -194,19 +740,24 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
@override
Widget build(BuildContext context) {
var type = appdata.settings['comicDisplayMode'];
var tiles = comics.map(
(e) {
var comicSource = e.type.comicSource;
return ComicTile(
key: Key(e.hashCode.toString()),
enableLongPressed: false,
comic: Comic(
e.name,
e.coverPath,
e.id,
e.author,
e.tags,
"${e.time} | ${comicSource?.name ?? "Unknown"}",
comicSource?.key ?? "Unknown",
type == 'detailed'
? "${e.time} | ${comicSource?.name ?? "Unknown"}"
: "${e.type.comicSource?.name ?? "Unknown"} | ${e.time}",
comicSource?.key ??
(e.type == ComicType.local ? "local" : "Unknown"),
null,
null,
),
@@ -227,9 +778,20 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
);
},
),
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
});
},
),
],
),
body: ReorderableBuilder(
body: ReorderableBuilder<FavoriteItem>(
key: reorderWidgetKey,
scrollController: _scrollController,
longPressDelay: App.isDesktop
@@ -238,14 +800,14 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
onReorder: (reorderFunc) {
changed = true;
setState(() {
comics = reorderFunc(comics) as List<FavoriteItem>;
comics = reorderFunc(comics);
});
widget.onReorder(comics);
},
dragChildBoxDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: lightenColor(
Theme.of(context).splashColor.withOpacity(1),
Theme.of(context).splashColor.withAlpha(255),
0.2,
),
),
@@ -262,3 +824,76 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
);
}
}
class _SelectUpdatePageNum extends StatefulWidget {
const _SelectUpdatePageNum({
required this.networkSource,
this.networkFolder,
super.key,
});
final String? networkFolder;
final String networkSource;
@override
State<_SelectUpdatePageNum> createState() => _SelectUpdatePageNumState();
}
class _SelectUpdatePageNumState extends State<_SelectUpdatePageNum> {
int updatePageNum = 9999999;
String get _allPageText => 'All'.tl;
List<String> get pageNumList =>
['1', '2', '3', '5', '10', '20', '50', '100', '200', _allPageText];
@override
void initState() {
updatePageNum =
appdata.implicitData["local_favorites_update_page_num"] ?? 9999999;
super.initState();
}
@override
Widget build(BuildContext context) {
var source = ComicSource.find(widget.networkSource);
var sourceName = source?.name ?? widget.networkSource;
var text = "The folder is Linked to @source".tlParams({
"source": sourceName,
});
if (widget.networkFolder != null && widget.networkFolder!.isNotEmpty) {
text += "\n${"Source Folder".tl}: ${widget.networkFolder}";
}
return Column(
children: [
Row(
children: [Text(text)],
),
Row(
children: [
Text("Update the page number by the latest collection".tl),
Spacer(),
Select(
current: updatePageNum.toString() == '9999999'
? _allPageText
: updatePageNum.toString(),
values: pageNumList,
minWidth: 48,
onTap: (index) {
setState(() {
updatePageNum = int.parse(pageNumList[index] == _allPageText
? '9999999'
: pageNumList[index]);
appdata.implicitData["local_favorites_update_page_num"] =
updatePageNum;
appdata.writeImplicitData();
});
},
)
],
),
],
);
}
}

View File

@@ -19,9 +19,8 @@ Future<bool> _deleteComic(
bool loading = false;
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Delete".tl,
content: Text("Are you sure you want to delete this comic?".tl)
.paddingHorizontal(16),
title: "Remove".tl,
content: Text("Remove comic from favorite?".tl).paddingHorizontal(16),
actions: [
Button.filled(
isLoading: loading,
@@ -94,6 +93,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
return ComicList(
key: comicListKey,
leadingSliver: SliverAppbar(
style:
context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
leading: Tooltip(
message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth
@@ -108,6 +109,17 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
onTap: context.width < _kTwoPanelChangeWidth ? showFolders : null,
child: Text(widget.data.title),
),
actions: [
MenuButton(entries: [
MenuEntry(
icon: Icons.sync,
text: "Convert to local".tl,
onClick: () {
importNetworkFolder(widget.data.key, 9999999, null, null);
},
)
]),
],
),
errorLeading: Appbar(
leading: Tooltip(
@@ -152,6 +164,7 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
),
];
},
enablePageStorage: true,
);
}
}
@@ -200,6 +213,8 @@ class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> {
@override
Widget build(BuildContext context) {
var sliverAppBar = SliverAppbar(
style:
context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
leading: Tooltip(
message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth
@@ -413,8 +428,7 @@ class _FolderTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Delete".tl,
content: Text("Are you sure you want to delete this folder?".tl)
.paddingHorizontal(16),
content: Text("Delete folder?".tl).paddingHorizontal(16),
actions: [
Button.filled(
isLoading: loading,
@@ -531,8 +545,20 @@ class _FavoriteFolder extends StatelessWidget {
Widget build(BuildContext context) {
return ComicList(
key: comicListKey,
enablePageStorage: true,
leadingSliver: SliverAppbar(
title: Text(title),
actions: [
MenuButton(entries: [
MenuEntry(
icon: Icons.sync,
text: "Convert to local".tl,
onClick: () {
importNetworkFolder(data.key, 9999999, title, folderID);
},
)
]),
],
),
errorLeading: Appbar(
title: Text(title),

View File

@@ -80,7 +80,6 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const SizedBox(width: 16),
Icon(
Icons.local_activity,
color: context.colorScheme.secondary,
@@ -88,27 +87,41 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.search),
color: context.colorScheme.primary,
onPressed: () {
context.to(() => const LocalSearchPage());
},
MenuButton(
entries: [
MenuEntry(
icon: Icons.search,
text: 'Search'.tl,
onClick: () {
context.to(() => const LocalSearchPage());
},
),
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
],
),
IconButton(
icon: const Icon(Icons.add),
color: context.colorScheme.primary,
onPressed: () {
newFolder().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
const SizedBox(width: 16),
],
),
).paddingHorizontal(16),
);
}
index--;
@@ -166,7 +179,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: isSelected
? context.colorScheme.primaryContainer.withOpacity(0.36)
? context.colorScheme.primaryContainer.toOpacity(0.36)
: null,
border: Border(
left: BorderSide(
@@ -201,7 +214,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: isSelected
? context.colorScheme.primaryContainer.withOpacity(0.36)
? context.colorScheme.primaryContainer.toOpacity(0.36)
: null,
border: Border(
left: BorderSide(
@@ -219,13 +232,13 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
@override
void update() {
if(!mounted) return;
if (!mounted) return;
setState(() {});
}
@override
void updateFolders() {
if(!mounted) return;
if (!mounted) return;
setState(() {
folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all()

View File

@@ -4,8 +4,6 @@ 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/history.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
class HistoryPage extends StatefulWidget {
@@ -78,31 +76,7 @@ class _HistoryPageState extends State<HistoryPage> {
],
),
SliverGridComics(
comics: comics.map(
(e) {
var cover = e.cover;
if (!cover.isURL) {
var localComic = LocalManager().find(
e.id,
e.type,
);
if(localComic != null) {
cover = "file://${localComic.coverFile.path}";
}
}
return Comic(
e.title,
cover,
e.id,
e.subtitle,
null,
getDescription(e),
e.type.comicSource?.key ?? "Invalid:${e.type.value}",
null,
null,
);
},
).toList(),
comics: comics,
badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name;
},
@@ -111,12 +85,18 @@ class _HistoryPageState extends State<HistoryPage> {
MenuEntry(
icon: Icons.remove,
text: 'Remove'.tl,
color: context.colorScheme.error,
onClick: () {
if (c.sourceKey.startsWith("Invalid")) {
if (c.sourceKey.startsWith("Unknown")) {
HistoryManager().remove(
c.id,
ComicType(int.parse(c.sourceKey.split(':')[1])),
);
} else if (c.sourceKey == 'local') {
HistoryManager().remove(
c.id,
ComicType.local,
);
} else {
HistoryManager().remove(
c.id,

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