304 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
149 changed files with 14123 additions and 2973 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,7 +1,10 @@
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-15
@@ -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 }}

4
.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
@@ -43,3 +44,6 @@ app.*.map.json
/android/app/release
add_translation.py
*/*/generated_*
*/*/Generated*

View File

@@ -1,13 +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

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 {

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

@@ -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": "更新时间",
@@ -179,7 +190,135 @@
"Move To First": "移动到最前",
"Cancel": "取消",
"Paused": "已暂停",
"Pause": "暂停"
"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": "首頁",
@@ -200,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": "刪除",
@@ -224,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": "更新",
@@ -279,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": "粉色",
@@ -311,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": "緩存限制",
@@ -323,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": "查看更多",
@@ -335,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": "更新時間",
@@ -361,6 +511,134 @@
"Move To First": "移動到最前",
"Cancel": "取消",
"Paused": "已暫停",
"Pause": "暫停"
"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';
@@ -46,3 +47,4 @@ part 'side_bar.dart';
part 'comic.dart';
part 'effects.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.3";
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,14 +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) {
@@ -130,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;
}
@@ -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";
/// 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,21 +249,21 @@ 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();
}
@@ -216,7 +273,8 @@ class LocalManager with ChangeNotifier {
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';
@@ -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();
}

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

@@ -112,3 +112,9 @@ extension StyledText on TextStyle {
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,22 +194,44 @@ class AppDio with DioMixin {
: rhttp.ProxySettings.proxy(proxy!),
));
}
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) {
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(
@@ -220,6 +240,10 @@ class RHttpAdapter implements HttpClientAdapter {
keepAlivePing: Duration(seconds: 30),
),
throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
),
);
}
@@ -232,12 +256,6 @@ class RHttpAdapter implements HttpClientAdapter {
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
Log.info(
"Network",
"${options.method} ${options.uri}\n"
"Headers: ${options.headers}\n"
"Data: ${options.data}\n",
);
var res = await rhttp.Rhttp.request(
method: switch (options.method) {
'GET' => rhttp.HttpMethod.get,
@@ -272,13 +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
data = gzip.decoder.bind(data).map((data) => Uint8List.fromList(data));
}
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
@@ -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

@@ -34,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;
}
@@ -78,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(
@@ -106,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();
}
},
@@ -128,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,14 +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) {
folder = null;
return buildBody();
} else {
return NetworkFavoritePage(favoriteData, key: Key(folder!));
return NetworkFavoritePage(favoriteData,
key: PageStorageKey("network_$folder"));
}
}
}

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() {
setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder);
});
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) {
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",
);
}),
],
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,6 +740,7 @@ 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;
@@ -206,8 +753,11 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
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,
),
@@ -228,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
@@ -239,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,
),
),
@@ -263,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,

View File

@@ -1,13 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sliver_tools/sliver_tools.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/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart';
@@ -15,10 +13,11 @@ import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/image_favorites_page/image_favorites_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/cbz.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/import_comic.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'local_comics_page.dart';
@@ -32,10 +31,12 @@ class HomePage extends StatelessWidget {
slivers: [
SliverPadding(padding: EdgeInsets.only(top: context.padding.top)),
const _SearchBar(),
const _SyncDataWidget(),
const _History(),
const _Local(),
const _ComicSourceWidget(),
const _AccountsWidget(),
const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
],
);
@@ -54,7 +55,7 @@ class _SearchBar extends StatelessWidget {
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Material(
color: context.colorScheme.surfaceContainer,
color: context.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(32),
child: InkWell(
borderRadius: BorderRadius.circular(32),
@@ -77,6 +78,112 @@ class _SearchBar extends StatelessWidget {
}
}
class _SyncDataWidget extends StatefulWidget {
const _SyncDataWidget();
@override
State<_SyncDataWidget> createState() => _SyncDataWidgetState();
}
class _SyncDataWidgetState extends State<_SyncDataWidget>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
DataSync().addListener(update);
WidgetsBinding.instance.addObserver(this);
lastCheck = DateTime.now();
}
void update() {
if (mounted) {
setState(() {});
}
}
@override
void dispose() {
super.dispose();
DataSync().removeListener(update);
WidgetsBinding.instance.removeObserver(this);
}
late DateTime lastCheck;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
if (DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) {
lastCheck = DateTime.now();
DataSync().downloadData();
}
}
}
@override
Widget build(BuildContext context) {
Widget child;
if (!DataSync().isEnabled) {
child = const SliverPadding(padding: EdgeInsets.zero);
} else if (DataSync().isUploading || DataSync().isDownloading) {
child = SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(Icons.sync),
title: Text('Syncing Data'.tl),
trailing: const CircularProgressIndicator(strokeWidth: 2)
.fixWidth(18)
.fixHeight(18),
),
),
);
} else {
child = SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(Icons.sync),
title: Text('Sync Data'.tl),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}),
IconButton(
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}),
],
),
),
),
);
}
return SliverAnimatedPaintExtent(
duration: const Duration(milliseconds: 200),
child: child,
);
}
}
class _History extends StatefulWidget {
const _History();
@@ -158,21 +265,8 @@ class _HistoryState extends State<_History> {
scrollDirection: Axis.horizontal,
itemCount: history.length,
itemBuilder: (context, index) {
var cover = history[index].cover;
ImageProvider imageProvider = CachedImageProvider(
cover,
sourceKey: history[index].type.comicSource?.key,
);
if (!cover.isURL) {
var localComic = LocalManager().find(
history[index].id,
history[index].type,
);
if (localComic != null) {
imageProvider = FileImage(localComic.coverFile);
}
}
return InkWell(
return SimpleComicTile(
comic: history[index],
onTap: () {
context.to(
() => ComicPage(
@@ -181,27 +275,7 @@ class _HistoryState extends State<_History> {
),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
width: 92,
height: 114,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context)
.colorScheme
.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: imageProvider,
width: 96,
height: 128,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
);
).paddingHorizontal(8).paddingVertical(2);
},
),
).paddingHorizontal(8).paddingBottom(16),
@@ -294,33 +368,8 @@ class _LocalState extends State<_Local> {
scrollDirection: Axis.horizontal,
itemCount: local.length,
itemBuilder: (context, index) {
return InkWell(
onTap: () {
local[index].read();
},
borderRadius: BorderRadius.circular(8),
child: Container(
width: 92,
height: 114,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context)
.colorScheme
.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: FileImage(
local[index].coverFile,
),
width: 96,
height: 128,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
);
return SimpleComicTile(comic: local[index])
.paddingHorizontal(8);
},
),
).paddingHorizontal(8),
@@ -389,6 +438,10 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
String? selectedFolder;
bool copyToLocalFolder = true;
bool cancelled = false;
@override
void dispose() {
loading = false;
@@ -400,8 +453,17 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
String info = [
"Select a directory which contains the comic files.".tl,
"Select a directory which contains the comic directories.".tl,
"Select a cbz file.".tl,
"Select an archive file (cbz, zip, 7z, cb7)".tl,
"Select a directory which contains multiple archive files.".tl,
"Select an EhViewer database and a download folder.".tl
][type];
List<String> importMethods = [
"Single Comic".tl,
"Multiple Comics".tl,
"An archive file".tl,
"Multiple archive files".tl,
"EhViewer downloads".tl
];
return ContentDialog(
dismissible: !loading,
@@ -419,49 +481,42 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 600),
RadioListTile(
title: Text("Single Comic".tl),
value: 0,
groupValue: type,
onChanged: (value) {
setState(() {
type = value as int;
});
},
),
RadioListTile(
title: Text("Multiple Comics".tl),
value: 1,
groupValue: type,
onChanged: (value) {
setState(() {
type = value as int;
});
},
),
RadioListTile(
title: Text("A cbz file".tl),
value: 2,
groupValue: type,
onChanged: (value) {
setState(() {
type = value as int;
});
},
),
ListTile(
title: Text("Add to favorites".tl),
trailing: Select(
current: selectedFolder,
values: folders,
minWidth: 112,
onTap: (v) {
...List.generate(importMethods.length, (index) {
return RadioListTile(
title: Text(importMethods[index]),
value: index,
groupValue: type,
onChanged: (value) {
setState(() {
selectedFolder = folders[v];
type = value as int;
});
},
),
).paddingHorizontal(8),
);
}),
if (type != 4)
ListTile(
title: Text("Add to favorites".tl),
trailing: Select(
current: selectedFolder,
values: folders,
minWidth: 112,
onTap: (v) {
setState(() {
selectedFolder = folders[v];
});
},
),
).paddingHorizontal(8),
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
CheckboxListTile(
enabled: true,
title: Text("Copy to app local path".tl),
value: copyToLocalFolder,
onChanged: (v) {
setState(() {
copyToLocalFolder = !copyToLocalFolder;
});
}).paddingHorizontal(8),
const SizedBox(height: 8),
Text(info).paddingHorizontal(24),
],
@@ -482,7 +537,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
onPressed: () {
showDialog(
context: context,
barrierColor: Colors.transparent,
barrierColor: Colors.black.toOpacity(0.2),
builder: (context) {
var help = '';
help +=
@@ -493,7 +548,10 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
help +=
'${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n';
help +=
"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"
.tl;
help +=
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database."
.tl;
return ContentDialog(
title: "Help".tl,
@@ -521,190 +579,28 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
}
void selectAndImport() async {
if (type == 2) {
var xFile = await selectFile(ext: ['cbz']);
var controller = showLoadingDialog(context, allowCancel: false);
try {
var cache = FilePath.join(App.cachePath, xFile?.name ?? 'temp.cbz');
await xFile!.saveTo(cache);
var comic = await CBZ.import(File(cache));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(selectedFolder!, FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
));
}
await File(cache).deleteIgnoreError();
} catch (e, s) {
Log.error("Import Comic", e.toString(), s);
context.showMessage(message: e.toString());
}
controller.close();
return;
}
height = key.currentContext!.size!.height;
setState(() {
loading = true;
});
final picker = DirectoryPicker();
final path = await picker.pickDirectory();
if (!loading) {
picker.dispose();
return;
}
if (path == null) {
var importer = ImportComic(
selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder);
var result = switch (type) {
0 => await importer.directory(true),
1 => await importer.directory(false),
2 => await importer.cbz(),
3 => await importer.multipleCbz(),
4 => await importer.ehViewer(),
int() => true,
};
if (result) {
context.pop();
} else {
setState(() {
loading = false;
});
return;
}
Map<Directory, LocalComic> comics = {};
if (type == 0) {
var result = await checkSingleComic(path);
if (result != null) {
comics[path] = result;
} else {
context.showMessage(message: "Invalid Comic".tl);
setState(() {
loading = false;
});
return;
}
} else {
await for (var entry in path.list()) {
if (entry is Directory) {
var result = await checkSingleComic(entry);
if (result != null) {
comics[entry] = result;
}
}
}
}
bool shouldCopy = true;
for (var comic in comics.keys) {
if (comic.parent.path == LocalManager().path) {
shouldCopy = false;
break;
}
}
if (shouldCopy && comics.isNotEmpty) {
try {
// copy the comics to the local directory
await compute<Map<String, dynamic>, void>(_copyDirectories, {
'toBeCopied': comics.keys.map((e) => e.path).toList(),
'destination': LocalManager().path,
});
} catch (e) {
context.showMessage(message: "Failed to import comics".tl);
Log.error("Import Comic", e.toString());
setState(() {
loading = false;
});
return;
}
}
for (var comic in comics.values) {
LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(selectedFolder!, FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subtitle,
type: comic.comicType,
tags: comic.tags,
));
}
}
context.pop();
context.showMessage(
message: "Imported @a comics".tlParams({
'a': comics.length,
}));
}
static _copyDirectories(Map<String, dynamic> data) {
var toBeCopied = data['toBeCopied'] as List<String>;
var destination = data['destination'] as String;
for (var dir in toBeCopied) {
var source = Directory(dir);
var dest = Directory("$destination/${source.name}");
if (dest.existsSync()) {
// The destination directory already exists, and it is not managed by the app.
// Rename the old directory to avoid conflicts.
Log.info("Import Comic",
"Directory already exists: ${source.name}\nRenaming the old directory.");
dest.rename(
findValidDirectoryName(dest.parent.path, "${dest.path}_old"));
}
dest.createSync();
copyDirectory(source, dest);
}
}
Future<LocalComic?> checkSingleComic(Directory directory) async {
if (!(await directory.exists())) return null;
var name = directory.name;
if (LocalManager().findByName(name) != null) {
Log.info("Import Comic", "Comic already exists: $name");
return null;
}
bool hasChapters = false;
var chapters = <String>[];
var coverPath = ''; // relative path to the cover image
await for (var entry in directory.list()) {
if (entry is Directory) {
hasChapters = true;
chapters.add(entry.name);
await for (var file in entry.list()) {
if (file is Directory) {
Log.info("Import Comic",
"Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory.");
return null;
}
}
} else if (entry is File) {
if (entry.name.startsWith('cover')) {
coverPath = entry.name;
}
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'];
if (!coverPath.startsWith('cover') &&
imageExtensions.contains(entry.extension)) {
coverPath = entry.name;
}
}
}
chapters.sort();
if (hasChapters && coverPath == '') {
// use the first image in the first chapter as the cover
var firstChapter = Directory('${directory.path}/${chapters.first}');
await for (var entry in firstChapter.list()) {
if (entry is File) {
coverPath = entry.name;
break;
}
}
}
if (coverPath == '') {
Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found.");
return null;
}
return LocalComic(
id: '0',
title: name,
subtitle: '',
tags: [],
directory: directory.name,
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
cover: coverPath,
comicType: ComicType.local,
downloadedChapters: chapters,
createdAt: DateTime.now(),
);
}
}
@@ -800,6 +696,30 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}).toList(),
).paddingHorizontal(16).paddingBottom(16),
),
if (ComicSource.availableUpdates.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
border: Border.all(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.update, color: context.colorScheme.primary, size: 20,),
const SizedBox(width: 8),
Text("@c updates".tlParams({
'c': ComicSource.availableUpdates.length,
}), style: ts.withColor(context.colorScheme.primary),),
],
),
).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8),
],
),
),
@@ -975,3 +895,281 @@ class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon>
);
}
}
class ImageFavorites extends StatefulWidget {
const ImageFavorites({super.key});
@override
State<ImageFavorites> createState() => _ImageFavoritesState();
}
class _ImageFavoritesState extends State<ImageFavorites> {
ImageFavoritesComputed? imageFavoritesCompute;
int displayType = 0;
void refreshImageFavorites() async {
try {
imageFavoritesCompute =
await ImageFavoriteManager.computeImageFavorites();
if (mounted) {
setState(() {});
}
} catch (e, stackTrace) {
Log.error("Unhandled Exception", e.toString(), stackTrace);
}
}
@override
void initState() {
refreshImageFavorites();
ImageFavoriteManager().addListener(refreshImageFavorites);
super.initState();
}
@override
void dispose() {
ImageFavoriteManager().removeListener(refreshImageFavorites);
super.dispose();
}
@override
Widget build(BuildContext context) {
bool hasData =
imageFavoritesCompute != null && !imageFavoritesCompute!.isEmpty;
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
context.to(() => const ImageFavoritesPage());
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 56,
child: Row(
children: [
Center(
child: Text('Image Favorites'.tl, style: ts.s18),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
),
).paddingHorizontal(16),
if (hasData)
Row(
children: [
const Spacer(),
buildTypeButton(0, "Tags".tl),
const Spacer(),
buildTypeButton(1, "Authors".tl),
const Spacer(),
buildTypeButton(2, "Comics".tl),
const Spacer(),
],
),
if (hasData) const SizedBox(height: 8),
if (hasData)
buildChart(switch (displayType) {
0 => imageFavoritesCompute!.tags,
1 => imageFavoritesCompute!.authors,
2 => imageFavoritesCompute!.comics,
_ => [],
})
.paddingHorizontal(16)
.paddingBottom(16),
],
),
),
),
);
}
Widget buildTypeButton(int type, String text) {
const radius = 24.0;
return InkWell(
borderRadius: BorderRadius.circular(radius),
onTap: () async {
setState(() {
displayType = type;
});
await Future.delayed(const Duration(milliseconds: 20));
var scrollController = ScrollControllerProvider.of(context);
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
child: AnimatedContainer(
width: 96,
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color:
displayType == type ? context.colorScheme.primaryContainer : null,
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(radius),
),
duration: const Duration(milliseconds: 200),
child: Center(
child: Text(
text,
style: ts.s16,
),
),
),
);
}
Widget buildChart(List<TextWithCount> data) {
if (data.isEmpty) {
return const SizedBox();
}
var maxCount = data.map((e) => e.count).reduce((a, b) => a > b ? a : b);
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: 164,
),
child: SingleChildScrollView(
child: Column(
key: ValueKey(displayType),
children: data.map((e) {
return _ChartLine(
text: e.text,
count: e.count,
maxCount: maxCount,
enableTranslation: displayType != 2,
onTap: (text) {
context.to(() => ImageFavoritesPage(initialKeyword: text));
},
);
}).toList(),
),
),
);
}
}
class _ChartLine extends StatefulWidget {
const _ChartLine({
required this.text,
required this.count,
required this.maxCount,
required this.enableTranslation,
this.onTap,
});
final String text;
final int count;
final int maxCount;
final bool enableTranslation;
final void Function(String text)? onTap;
@override
State<_ChartLine> createState() => __ChartLineState();
}
class __ChartLineState extends State<_ChartLine>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
value: 0,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
var text = widget.text;
var enableTranslation =
App.locale.countryCode == 'CN' && widget.enableTranslation;
if (enableTranslation) {
text = text.translateTagsToCN;
}
if (widget.enableTranslation && text.contains(':')) {
text = text.split(':').last;
}
return Row(
children: [
InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () {
widget.onTap?.call(widget.text);
},
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
.paddingHorizontal(4)
.toAlign(Alignment.centerLeft)
.fixWidth(context.width > 600 ? 120 : 80)
.fixHeight(double.infinity),
),
const SizedBox(width: 8),
Expanded(
child: LayoutBuilder(builder: (context, constrains) {
var width = constrains.maxWidth * widget.count / widget.maxCount;
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: width * _controller.value,
height: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
gradient: LinearGradient(
colors: context.isDarkMode
? [
Colors.blue.shade800,
Colors.blue.shade500,
]
: [
Colors.blue.shade300,
Colors.blue.shade600,
],
),
),
).toAlign(Alignment.centerLeft);
},
);
}),
),
const SizedBox(width: 8),
Text(
widget.count.toString(),
style: ts.s12,
).fixWidth(context.width > 600 ? 60 : 30),
],
).fixHeight(28);
}
}

View File

@@ -0,0 +1,287 @@
part of 'image_favorites_page.dart';
class _ImageFavoritesItem extends StatefulWidget {
const _ImageFavoritesItem({
required this.imageFavoritesComic,
required this.selectedImageFavorites,
required this.addSelected,
required this.multiSelectMode,
required this.finalImageFavoritesComicList,
});
final ImageFavoritesComic imageFavoritesComic;
final Function(ImageFavorite) addSelected;
final Map<ImageFavorite, bool> selectedImageFavorites;
final List<ImageFavoritesComic> finalImageFavoritesComicList;
final bool multiSelectMode;
@override
State<_ImageFavoritesItem> createState() => _ImageFavoritesItemState();
}
class _ImageFavoritesItemState extends State<_ImageFavoritesItem> {
late final imageFavorites = widget.imageFavoritesComic.images.toList();
void goComicInfo(ImageFavoritesComic comic) {
App.mainNavigatorKey?.currentContext?.to(() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
));
}
void goReaderPage(ImageFavoritesComic comic, int ep, int page) {
App.rootContext.to(
() => ReaderWithLoading(
id: comic.id,
sourceKey: comic.sourceKey,
initialEp: ep,
initialPage: page,
),
);
}
void goPhotoView(ImageFavorite imageFavorite) {
Navigator.of(App.rootContext).push(MaterialPageRoute(
builder: (context) => ImageFavoritesPhotoView(
comic: widget.imageFavoritesComic,
imageFavorite: imageFavorite,
)));
}
void copyTitle() {
Clipboard.setData(ClipboardData(text: widget.imageFavoritesComic.title));
App.rootContext.showMessage(message: 'Copy the title successfully'.tl);
}
void onLongPress() {
var renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size;
var location = renderBox.localToGlobal(
Offset((size.width - 242) / 2, size.height / 2),
);
showMenu(location, context);
}
void onSecondaryTap(TapDownDetails details) {
showMenu(details.globalPosition, context);
}
void showMenu(Offset location, BuildContext context) {
showMenuX(
App.rootContext,
location,
[
MenuEntry(
icon: Icons.chrome_reader_mode_outlined,
text: 'Details'.tl,
onClick: () {
goComicInfo(widget.imageFavoritesComic);
},
),
MenuEntry(
icon: Icons.copy,
text: 'Copy Title'.tl,
onClick: () {
copyTitle();
},
),
MenuEntry(
icon: Icons.select_all,
text: 'Select All'.tl,
onClick: () {
for (var ele in widget.imageFavoritesComic.images) {
widget.addSelected(ele);
}
},
),
MenuEntry(
icon: Icons.read_more,
text: 'Photo View'.tl,
onClick: () {
goPhotoView(widget.imageFavoritesComic.images.first);
},
),
],
);
}
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onSecondaryTapDown: onSecondaryTap,
onLongPress: onLongPress,
onTap: () {
if (widget.multiSelectMode) {
for (var ele in widget.imageFavoritesComic.images) {
widget.addSelected(ele);
}
} else {
// 单击跳转漫画详情
goComicInfo(widget.imageFavoritesComic);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
buildTop(),
SizedBox(
height: 145,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemBuilder: buildItem,
itemCount: imageFavorites.length,
),
).paddingHorizontal(8),
buildBottom(),
],
),
),
);
}
Widget buildItem(BuildContext context, int index) {
var image = imageFavorites[index];
bool isSelected = widget.selectedImageFavorites[image] ?? false;
int curPage = image.page;
String pageText = curPage == firstPage
? '@a Cover'.tlParams({"a": image.epName})
: curPage.toString();
return InkWell(
onTap: () {
// 单击去阅读页面, 跳转到当前点击的page
if (widget.multiSelectMode) {
widget.addSelected(image);
} else {
goReaderPage(widget.imageFavoritesComic, image.ep, curPage);
}
},
onLongPress: () {
goPhotoView(image);
},
borderRadius: BorderRadius.circular(8),
child: Container(
width: 98,
height: 128,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: null,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
children: [
Container(
height: 128,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: Hero(
tag: "${image.sourceKey}${image.ep}${image.page}",
child: AnimatedImage(
image: ImageFavoritesProvider(image),
width: 96,
height: 128,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
),
),
),
Text(
pageText,
style: ts.s10,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
],
),
),
).paddingHorizontal(4);
}
Widget buildTop() {
return Row(
children: [
Expanded(
child: Text(
widget.imageFavoritesComic.title,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 16.0,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
softWrap: true,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
"${imageFavorites.length}/${widget.imageFavoritesComic.maxPageFromEp}",
style: ts.s12),
),
],
).paddingHorizontal(16).paddingVertical(8);
}
Widget buildBottom() {
var enableTranslate = App.locale.languageCode == 'zh';
String time =
DateFormat('yyyy-MM-dd').format(widget.imageFavoritesComic.time);
List<String> tags = [];
for (var tag in widget.imageFavoritesComic.tags) {
var text = enableTranslate ? tag.translateTagsToCN : tag;
if (text.contains(':')) {
text = text.split(':').last;
}
tags.add(text);
if (tags.length == 5) {
break;
}
}
var comicSource = ComicSource.find(widget.imageFavoritesComic.sourceKey);
return Row(
children: [
Text(
"$time | ${comicSource?.name ?? "Unknown"}",
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 12.0,
),
).paddingRight(8),
if (tags.isNotEmpty)
Expanded(
child: Text(
tags
.map((e) => enableTranslate ? e.translateTagsToCN : e)
.join(" "),
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 12.0,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
],
).paddingHorizontal(8).paddingBottom(8);
}
}

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