184 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
123 changed files with 8645 additions and 2272 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

@@ -2,6 +2,9 @@ name: Build ALL
run-name: Build ALL run-name: Build ALL
on: on:
workflow_dispatch: {} workflow_dispatch: {}
release:
types: [published]
jobs: jobs:
Build_MacOS: Build_MacOS:
runs-on: macos-15 runs-on: macos-15
@@ -139,3 +142,45 @@ jobs:
name: arch_build name: arch_build
path: build/linux/arch/ 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 }}

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ migrate_working_dir/
*.ipr *.ipr
*.iws *.iws
.idea/ .idea/
.vscode/
# The .vscode folder contains launch configuration and tasks you configure in # The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line # VS Code which you may wish to be included in version control, so this line

View File

@@ -1,13 +1,17 @@
# venera # 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) [![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases) [![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![stars](https://img.shields.io/github/stars/venera-app/venera)](https://github.com/venera-app/venera/stargazers) [![stars](https://img.shields.io/github/stars/venera-app/venera?style=flat)](https://github.com/venera-app/venera/stargazers)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/+Ws-IpmUutzkxMjhl) [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/+Ws-IpmUutzkxMjhl)
A comic reader that support reading local and network comics. A comic reader that support reading local and network comics.
[<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 ## Features
- Read local comics - Read local comics

View File

@@ -5,6 +5,8 @@ plugins {
id "dev.flutter.flutter-gradle-plugin" id "dev.flutter.flutter-gradle-plugin"
} }
ext.abiCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86_64": 3]
def localProperties = new Properties() def localProperties = new Properties()
def localPropertiesFile = rootProject.file("local.properties") def localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) { if (localPropertiesFile.exists()) {
@@ -35,7 +37,7 @@ android {
splits{ splits{
abi { abi {
reset() reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
enable true enable true
universalApk true universalApk true
} }
@@ -78,7 +80,7 @@ android {
buildTypes { buildTypes {
release { release {
ndk { ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64" abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
} }
signingConfig signingConfigs.release signingConfig signingConfigs.release
applicationVariants.all { variant -> applicationVariants.all { variant ->
@@ -86,13 +88,25 @@ android {
def abi = output.getFilter(com.android.build.OutputFile.ABI) def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (abi != null) { if (abi != null) {
outputFileName = "venera-${variant.versionName}-${abi}.apk" outputFileName = "venera-${variant.versionName}-${abi}.apk"
def abiVersionCode = project.ext.abiCodes.get(abi)
if (abiVersionCode != null) {
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
} else { } else {
outputFileName = "venera-${variant.versionName}.apk" 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 { flutter {

View File

@@ -53,6 +53,8 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- [flutter 3.27.1] Impeller is still worse than skia, disable it -->
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false"/>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.Settings import android.provider.Settings
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@@ -324,8 +325,25 @@ class MainActivity : FlutterFragmentActivity() {
} }
} }
// use copy method // use copy method
val filePath = FileUtils.getPathFromCopyOfFileFromUri(this, uri) val tmp = File(cacheDir, fileName)
result.success(filePath) 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()
} }
} }
} }

View File

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

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.2.1' apply false id "com.android.application" version '8.3.2' apply false
id "org.jetbrains.kotlin.android" version "1.8.10" apply false id "org.jetbrains.kotlin.android" version "1.8.10" apply false
} }

View File

@@ -4,6 +4,13 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app. This library provides a set of APIs for interacting with the Venera app.
*/ */
function setTimeout(callback, delay) {
sendMessage({
method: 'delay',
time: delay,
}).then(callback);
}
/// encode, decode, hash, decrypt /// encode, decode, hash, decrypt
let Convert = { let Convert = {
/** /**
@@ -486,6 +493,37 @@ let Network = {
}, },
}; };
/**
* [fetch] function for sending HTTP requests. Same api as the browser fetch.
* @param url {string}
* @param options {{method: string, headers: Object, body: any}}
* @returns {Promise<{ok: boolean, status: number, statusText: string, headers: {}, arrayBuffer: (function(): Promise<ArrayBuffer>), text: (function(): Promise<string>), json: (function(): Promise<any>)}>}
* @since 1.2.0
*/
async function fetch(url, options) {
let method = 'GET';
let headers = {};
let data = null;
if (options) {
method = options.method || method;
headers = options.headers || headers;
data = options.body || data;
}
let result = await Network.fetchBytes(method, url, headers, data);
return {
ok: result.status >= 200 && result.status < 300,
status: result.status,
statusText: '',
headers: result.headers,
arrayBuffer: async () => result.body,
text: async () => Convert.decodeUtf8(result.body),
json: async () => JSON.parse(Convert.decodeUtf8(result.body)),
}
}
/** /**
* HtmlDocument class for parsing HTML and querying elements. * HtmlDocument class for parsing HTML and querying elements.
*/ */
@@ -877,6 +915,8 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
/** /**
* Create a comic details object * Create a comic details object
* @param title {string} * @param title {string}
* @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string} * @param cover {string}
* @param description {string?} * @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined} * @param tags {Map<string, string[]> | {} | null | undefined}
@@ -897,8 +937,9 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page. * @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
* @constructor * @constructor
*/ */
function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) { function ComicDetails({title, subtitle, subTitle, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
this.title = title; this.title = title;
this.subtitle = subtitle ?? subTitle;
this.cover = cover; this.cover = cover;
this.description = description; this.description = description;
this.tags = tags; this.tags = tags;
@@ -1163,3 +1204,45 @@ class Image {
return new Image(key); 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

@@ -18,7 +18,7 @@
"help": "帮助", "help": "帮助",
"Select": "选择", "Select": "选择",
"Selected @a comics": "已选择 @a 部漫画", "Selected @a comics": "已选择 @a 部漫画",
"Imported @a comics": "已导入 @a 部漫画", "Imported @a comics, loaded @b pages, received @c comics": "已导入 @a 部漫画, 加载 @b 页, 接收到 @c 部漫画",
"Downloading": "下载中", "Downloading": "下载中",
"Back": "后退", "Back": "后退",
"Delete": "删除", "Delete": "删除",
@@ -41,6 +41,7 @@
"Select a folder": "选择一个文件夹", "Select a folder": "选择一个文件夹",
"Folder": "文件夹", "Folder": "文件夹",
"Confirm": "确认", "Confirm": "确认",
"Reversed successfully": "反转成功",
"Remove comic from favorite?": "从收藏中移除漫画?", "Remove comic from favorite?": "从收藏中移除漫画?",
"Move": "移动", "Move": "移动",
"Move to folder": "移动到文件夹", "Move to folder": "移动到文件夹",
@@ -105,6 +106,7 @@
"Continuous (Right to Left)": "连续(从右到左)", "Continuous (Right to Left)": "连续(从右到左)",
"Continuous (Top to Bottom)": "连续(从上到下)", "Continuous (Top to Bottom)": "连续(从上到下)",
"Auto page turning interval": "自动翻页间隔", "Auto page turning interval": "自动翻页间隔",
"The number of pic in screen (Only Gallery Mode)": "同屏幕图片数量(仅画廊模式)",
"Theme Mode": "主题模式", "Theme Mode": "主题模式",
"System": "系统", "System": "系统",
"Light": "浅色", "Light": "浅色",
@@ -152,8 +154,8 @@
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。", "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.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n", "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", "Export as cbz": "导出为cbz",
"Select a cbz file." : "选择一个cbz文件", "Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
"A cbz file" : "一个cbz文件", "An archive file" : "一个归档文件",
"Fullscreen": "全屏", "Fullscreen": "全屏",
"Exit": "退出", "Exit": "退出",
"View more": "查看更多", "View more": "查看更多",
@@ -163,7 +165,7 @@
"Date Desc": "日期降序", "Date Desc": "日期降序",
"Start": "开始", "Start": "开始",
"Export App Data": "导出应用数据", "Export App Data": "导出应用数据",
"Import App Data": "导入应用数据", "Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)",
"Export": "导出", "Export": "导出",
"Download Threads": "下载线程数", "Download Threads": "下载线程数",
"Update Time": "更新时间", "Update Time": "更新时间",
@@ -217,7 +219,6 @@
"Create Folder": "新建文件夹", "Create Folder": "新建文件夹",
"Select an image on screen": "选择屏幕上的图片", "Select an image on screen": "选择屏幕上的图片",
"Added @count comics to download queue.": "已添加 @count 本漫画到下载队列", "Added @count comics to download queue.": "已添加 @count 本漫画到下载队列",
"Ignore Certificate Errors": "忽略证书错误",
"Authorization Required": "需要身份验证", "Authorization Required": "需要身份验证",
"Sync": "同步", "Sync": "同步",
"The folder is Linked to @source": "文件夹已关联到 @source", "The folder is Linked to @source": "文件夹已关联到 @source",
@@ -244,7 +245,80 @@
"Deleted @a favorite items.": "已删除 @a 条无效收藏", "Deleted @a favorite items.": "已删除 @a 条无效收藏",
"New version available": "有新版本可用", "New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
"No new version available": "没有新版本可用" "No new version available": "没有新版本可用",
"Export as pdf": "导出为pdf",
"Export as 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": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -266,7 +340,7 @@
"help": "幫助", "help": "幫助",
"Select": "選擇", "Select": "選擇",
"Selected @a comics": "已選擇 @a 部漫畫", "Selected @a comics": "已選擇 @a 部漫畫",
"Imported @a comics": "已匯入 @a 部漫畫", "Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 加載 @b 頁, 接收到 @c 部漫畫",
"Downloading": "下載中", "Downloading": "下載中",
"Back": "後退", "Back": "後退",
"Delete": "刪除", "Delete": "刪除",
@@ -352,6 +426,7 @@
"Continuous (Right to Left)": "連續(從右到左)", "Continuous (Right to Left)": "連續(從右到左)",
"Continuous (Top to Bottom)": "連續(從上到下)", "Continuous (Top to Bottom)": "連續(從上到下)",
"Auto page turning interval": "自動翻頁間隔", "Auto page turning interval": "自動翻頁間隔",
"The number of pic in screen (Only Gallery Mode)": "同螢幕圖片數量(僅畫廊模式)",
"Theme Mode": "主題模式", "Theme Mode": "主題模式",
"System": "系統", "System": "系統",
"Light": "浅色", "Light": "浅色",
@@ -399,8 +474,8 @@
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。", "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.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n", "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", "Export as cbz": "匯出為cbz",
"Select a cbz file." : "選擇一個cbz文件", "Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"A cbz file" : "一個cbz文件", "An archive file" : "一個歸檔文件",
"Fullscreen": "全螢幕", "Fullscreen": "全螢幕",
"Exit": "退出", "Exit": "退出",
"View more": "查看更多", "View more": "查看更多",
@@ -409,8 +484,9 @@
"Date": "日期", "Date": "日期",
"Date Desc": "日期降序", "Date Desc": "日期降序",
"Start": "開始", "Start": "開始",
"Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據", "Export App Data": "匯出應用數據",
"Import App Data": "匯入應用數據", "Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)",
"Export": "匯出", "Export": "匯出",
"Download Threads": "下載線程數", "Download Threads": "下載線程數",
"Update Time": "更新時間", "Update Time": "更新時間",
@@ -464,7 +540,6 @@
"Create Folder": "新建文件夾", "Create Folder": "新建文件夾",
"Select an image on screen": "選擇屏幕上的圖片", "Select an image on screen": "選擇屏幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載隊列", "Added @count comics to download queue.": "已添加 @count 本漫畫到下載隊列",
"Ignore Certificate Errors": "忽略證書錯誤",
"Authorization Required": "需要身份驗證", "Authorization Required": "需要身份驗證",
"Sync": "同步", "Sync": "同步",
"The folder is Linked to @source": "文件夾已關聯到 @source", "The folder is Linked to @source": "文件夾已關聯到 @source",
@@ -491,6 +566,79 @@
"Deleted @a favorite items.": "已刪除 @a 條無效收藏", "Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用", "New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用" "No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf",
"Export as 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] [Desktop Entry]
Version={{Version}}
Name=Venera Name=Venera
GenericName=Venera GenericName=Venera
Comment=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 # 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. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -76,7 +76,7 @@ class _AppbarState extends State<Appbar> {
var content = Container( var content = Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.backgroundColor ?? color: widget.backgroundColor ??
context.colorScheme.surface.withOpacity(0.72), context.colorScheme.surface.toOpacity(0.72),
), ),
height: _kAppBarHeight + context.padding.top, height: _kAppBarHeight + context.padding.top,
child: Row( child: Row(
@@ -201,8 +201,7 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
), ),
Expanded( Expanded(
child: DefaultTextStyle( child: DefaultTextStyle(
style: style: DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
child: title, child: title,
@@ -220,7 +219,7 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
child: BlurEffect( child: BlurEffect(
blur: 15, blur: 15,
child: Material( child: Material(
color: context.colorScheme.surface.withOpacity(0.72), color: context.colorScheme.surface.toOpacity(0.72),
elevation: 0, elevation: 0,
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
child: body, child: body,
@@ -298,12 +297,21 @@ class _FilledTabBarState extends State<FilledTabBar> {
super.dispose(); super.dispose();
} }
PageStorageBucket get bucket => PageStorage.of(context);
@override @override
void didChangeDependencies() { void didChangeDependencies() {
_controller = widget.controller ?? DefaultTabController.of(context); _controller = widget.controller ?? DefaultTabController.of(context);
_controller.animation!.addListener(onTabChanged);
initPainter(); initPainter();
super.didChangeDependencies(); super.didChangeDependencies();
var prevIndex = bucket.readState(context) as int?;
if (prevIndex != null &&
prevIndex != _controller.index &&
prevIndex >= 0 &&
prevIndex < widget.tabs.length) {
_controller.index = prevIndex;
}
_controller.animation!.addListener(onTabChanged);
} }
@override @override
@@ -332,7 +340,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
animation: _controller, animation: _controller.animation ?? _controller,
builder: buildTabBar, builder: buildTabBar,
); );
} }
@@ -347,6 +355,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
controller: scrollController, controller: scrollController,
builder: (context, controller, physics) { builder: (context, controller, physics) {
return SingleChildScrollView( return SingleChildScrollView(
key: const PageStorageKey('scroll'),
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
controller: controller, controller: controller,
@@ -387,6 +396,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
} }
updateScrollOffset(i); updateScrollOffset(i);
previousIndex = i; previousIndex = i;
bucket.writeState(context, i);
} }
void updateScrollOffset(int i) { void updateScrollOffset(int i) {
@@ -427,7 +437,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: DefaultTextStyle( child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith( style: DefaultTextStyle.of(context).style.copyWith(
color: i == _controller.index color: i == _controller.animation?.value.round()
? context.colorScheme.primary ? context.colorScheme.primary
: context.colorScheme.onSurface, : context.colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -567,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 { class SearchBarController {
_SearchBarMixin? _state; _SearchBarMixin? _state;
@@ -724,6 +779,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate {
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
editingController.clear(); editingController.clear();
onChanged?.call("");
}, },
); );
}, },

View File

@@ -214,7 +214,7 @@ class _ButtonState extends State<Button> {
boxShadow: (isHover && !isLoading && (widget.type == ButtonType.filled || widget.type == ButtonType.normal)) boxShadow: (isHover && !isLoading && (widget.type == ButtonType.filled || widget.type == ButtonType.normal))
? [ ? [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color: Colors.black.toOpacity(0.1),
blurRadius: 2, blurRadius: 2,
offset: const Offset(0, 1), offset: const Offset(0, 1),
) )
@@ -248,7 +248,7 @@ class _ButtonState extends State<Button> {
if (widget.type == ButtonType.filled) { if (widget.type == ButtonType.filled) {
var color = widget.color ?? context.colorScheme.primary; var color = widget.color ?? context.colorScheme.primary;
if (isHover) { if (isHover) {
return color.withOpacity(0.9); return color.toOpacity(0.9);
} else { } else {
return color; return color;
} }
@@ -256,13 +256,13 @@ class _ButtonState extends State<Button> {
if (widget.type == ButtonType.normal) { if (widget.type == ButtonType.normal) {
var color = widget.color ?? context.colorScheme.surfaceContainer; var color = widget.color ?? context.colorScheme.surfaceContainer;
if (isHover) { if (isHover) {
return color.withOpacity(0.9); return color.toOpacity(0.9);
} else { } else {
return color; return color;
} }
} }
if (isHover) { if (isHover) {
return context.colorScheme.outline.withOpacity(0.2); return context.colorScheme.outline.toOpacity(0.2);
} }
return Colors.transparent; return Colors.transparent;
} }
@@ -345,7 +345,7 @@ class _IconButtonState extends State<_IconButton> {
? Theme.of(context) ? Theme.of(context)
.colorScheme .colorScheme
.outlineVariant .outlineVariant
.withOpacity(0.4) .toOpacity(0.4)
: null, : null,
borderRadius: BorderRadius.circular((iconSize + 12) / 2), borderRadius: BorderRadius.circular((iconSize + 12) / 2),
), ),

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

@@ -0,0 +1,383 @@
part of 'components.dart';
class CodeEditor extends StatefulWidget {
const CodeEditor({super.key, this.initialValue, this.onChanged});
final String? initialValue;
final void Function(String value)? onChanged;
@override
State<CodeEditor> createState() => _CodeEditorState();
}
class _CodeEditorState extends State<CodeEditor> {
late _CodeTextEditingController _controller;
late FocusNode _focusNode;
var horizontalScrollController = ScrollController();
var verticalScrollController = ScrollController();
int lineCount = 1;
@override
void initState() {
super.initState();
_controller = _CodeTextEditingController(text: widget.initialValue);
_focusNode = FocusNode()
..onKeyEvent = (node, event) {
if (event.logicalKey == LogicalKeyboardKey.tab) {
if (event is KeyDownEvent) {
handleTab();
}
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
lineCount = calculateLineCount(widget.initialValue ?? '');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
future = _controller.init(context.brightness);
}
void handleTab() {
var text = _controller.text;
var start = _controller.selection.start;
var end = _controller.selection.end;
_controller.text = '${text.substring(0, start)} ${text.substring(end)}';
_controller.selection = TextSelection.collapsed(offset: start + 4);
}
int calculateLineCount(String text) {
return text.split('\n').length;
}
Widget buildLineNumbers() {
return SizedBox(
width: 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,5 +1,27 @@
part of 'components.dart'; part of 'components.dart';
ImageProvider? _findImageProvider(Comic comic) {
ImageProvider image;
if (comic is LocalComic) {
image = LocalComicImageProvider(comic);
} else if (comic is History) {
image = HistoryImageProvider(comic);
} else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local);
if (localComic == null) {
return null;
}
image = FileImage(localComic.coverFile);
} else {
image = CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
}
return image;
}
class ComicTile extends StatelessWidget { class ComicTile extends StatelessWidget {
const ComicTile( const ComicTile(
{super.key, {super.key,
@@ -27,8 +49,14 @@ class ComicTile extends StatelessWidget {
onTap!(); onTap!();
return; return;
} }
App.mainNavigatorKey?.currentContext App.mainNavigatorKey?.currentContext?.to(
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); () => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
),
);
} }
void _onLongPressed(context) { void _onLongPressed(context) {
@@ -43,7 +71,7 @@ class ComicTile extends StatelessWidget {
var renderBox = context.findRenderObject() as RenderBox; var renderBox = context.findRenderObject() as RenderBox;
var size = renderBox.size; var size = renderBox.size;
var location = renderBox.localToGlobal( var location = renderBox.localToGlobal(
Offset(size.width / 2, size.height / 2), Offset((size.width - 242) / 2, size.height / 2),
); );
showMenu(location, context); showMenu(location, context);
} }
@@ -61,8 +89,14 @@ class ComicTile extends StatelessWidget {
icon: Icons.chrome_reader_mode_outlined, icon: Icons.chrome_reader_mode_outlined,
text: 'Details'.tl, text: 'Details'.tl,
onClick: () { onClick: () {
App.mainNavigatorKey?.currentContext App.mainNavigatorKey?.currentContext?.to(
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); () => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
),
);
}, },
), ),
MenuEntry( MenuEntry(
@@ -77,7 +111,7 @@ class ComicTile extends StatelessWidget {
icon: Icons.stars_outlined, icon: Icons.stars_outlined,
text: 'Add to favorites'.tl, text: 'Add to favorites'.tl,
onClick: () { onClick: () {
addFavorite(comic); addFavorite([comic]);
}, },
), ),
MenuEntry( MenuEntry(
@@ -144,7 +178,7 @@ class ComicTile extends StatelessWidget {
if (history != null) if (history != null)
Container( Container(
height: 24, height: 24,
color: Colors.blue.withOpacity(0.9), color: Colors.blue.toOpacity(0.9),
constraints: const BoxConstraints(minWidth: 24), constraints: const BoxConstraints(minWidth: 24),
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: CustomPaint( child: CustomPaint(
@@ -161,22 +195,10 @@ class ComicTile extends StatelessWidget {
} }
Widget buildImage(BuildContext context) { Widget buildImage(BuildContext context) {
ImageProvider image; var image = _findImageProvider(comic);
if (comic is LocalComic) { if (image == null) {
image = FileImage((comic as LocalComic).coverFile);
} else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local);
if (localComic == null) {
return const SizedBox(); return const SizedBox();
} }
image = FileImage(localComic.coverFile);
} else {
image = CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
}
return AnimatedImage( return AnimatedImage(
image: image, image: image,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -197,16 +219,26 @@ class ComicTile extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row( child: Row(
children: [ children: [
Container( Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
width: height * 0.68, width: height * 0.68,
height: double.infinity, height: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: buildImage(context), child: buildImage(context),
), ),
),
SizedBox.fromSize( SizedBox.fromSize(
size: const Size(16, 5), size: const Size(16, 5),
), ),
@@ -233,34 +265,38 @@ class ComicTile extends StatelessWidget {
} }
Widget _buildBriefMode(BuildContext context) { Widget _buildBriefMode(BuildContext context) {
return Padding( return LayoutBuilder(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: _onTap, onTap: _onTap,
onLongPress: onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Column( child: Column(
children: [ children: [
Expanded( Expanded(
child: SizedBox(
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: context.colorScheme.secondaryContainer,
.colorScheme
.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.toOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 2),
),
],
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: buildImage(context), child: buildImage(context),
), ),
), ),
),
Align( Align(
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
child: (() { child: (() {
@@ -268,93 +304,109 @@ class ComicTile extends StatelessWidget {
comic.subtitle?.replaceAll('\n', '').trim(); comic.subtitle?.replaceAll('\n', '').trim();
final text = comic.description.isNotEmpty final text = comic.description.isNotEmpty
? comic.description.split('|').join('\n') ? comic.description.split('|').join('\n')
: (subtitle?.isNotEmpty == true : (subtitle?.isNotEmpty == true ? subtitle : null);
? subtitle final fortSize = constraints.maxWidth < 80
: null); ? 8.0
final scale = : constraints.maxWidth < 150
(appdata.settings['comicTileScale'] as num) ? 10.0
.toDouble(); : 12.0;
final fortSize = scale < 0.85
? 8.0 // 小尺寸
: (scale < 1.0 ? 10.0 : 12.0);
if (text == null) { if (text == null) {
return const SizedBox return const SizedBox();
.shrink(); // 如果没有文本,则不显示任何内容
} }
return Padding( var children = <Widget>[];
padding: const EdgeInsets.symmetric( for (var line in text.split('\n')) {
horizontal: 2, vertical: 2), children.add(Container(
child: ClipRRect( margin: const EdgeInsets.fromLTRB(2, 0, 2, 2),
borderRadius: const BorderRadius.all( padding: constraints.maxWidth < 80
Radius.circular(10.0), ? 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),
), ),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Padding(
padding:
const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: constraints.maxWidth, maxWidth: constraints.maxWidth,
), ),
child: Text( child: Text(
text, line,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
fontSize: fortSize, fontSize: fortSize,
color: Colors.white, color: Colors.white,
), ),
textAlign: TextAlign.right, textAlign: TextAlign.right,
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ));
), }
), return Column(
), mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: children,
); );
})(), })(),
), ),
], ],
), ),
), ),
),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
child: Text( child: TextScroll(
comic.title.replaceAll('\n', ''), comic.title.replaceAll('\n', ''),
mode: TextScrollMode.endless,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
maxLines: 1, delayBefore: Duration(milliseconds: 500),
overflow: TextOverflow.ellipsis, velocity: const Velocity(pixelsPerSecond: Offset(40, 0)),
), ),
), ),
], ],
), ).paddingHorizontal(6).paddingVertical(8),
); );
}, },
)); );
} }
List<String> _splitText(String text) { List<String> _splitText(String text) {
// split text by space, comma. text in brackets will be kept together. // split text by comma, brackets
var words = <String>[]; var words = <String>[];
var buffer = StringBuffer(); var buffer = StringBuffer();
var inBracket = false; var inBracket = false;
String? prevBracket;
for (var i = 0; i < text.length; i++) { for (var i = 0; i < text.length; i++) {
var c = text[i]; var c = text[i];
if (c == '[' || c == '(') { if (c == '[' || c == '(') {
inBracket = true;
} else if (c == ']' || c == ')') {
inBracket = false;
} else if (c == ' ' || c == ',') {
if (inBracket) { if (inBracket) {
buffer.write(c); buffer.write(c);
} else { } else {
words.add(buffer.toString()); 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(); buffer.clear();
} }
} else { } else {
@@ -362,8 +414,10 @@ class ComicTile extends StatelessWidget {
} }
} }
if (buffer.isNotEmpty) { if (buffer.isNotEmpty) {
words.add(buffer.toString()); words.add(buffer.toString().trim());
} }
words.removeWhere((element) => element == "");
words = words.toSet().toList();
return words; return words;
} }
@@ -381,7 +435,12 @@ class ComicTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: 'Block'.tl, title: 'Block'.tl,
content: Wrap( content: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: math.min(400, context.height - 136),
),
child: SingleChildScrollView(
child: Wrap(
runSpacing: 8, runSpacing: 8,
spacing: 8, spacing: 8,
children: [ children: [
@@ -400,7 +459,9 @@ class ComicTile extends StatelessWidget {
}, },
), ),
], ],
),
).paddingHorizontal(16), ).paddingHorizontal(16),
),
actions: [ actions: [
Button.filled( Button.filled(
onPressed: () { onPressed: () {
@@ -473,7 +534,7 @@ class _ComicDescription extends StatelessWidget {
subtitle, subtitle,
style: TextStyle( style: TextStyle(
fontSize: 10.0, fontSize: 10.0,
color: context.colorScheme.onSurface.withOpacity(0.7)), color: context.colorScheme.onSurface.toOpacity(0.7)),
maxLines: 1, maxLines: 1,
softWrap: true, softWrap: true,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -698,9 +759,16 @@ class _SliverGridComicsState extends State<SliverGridComics> {
comics.add(comic); comics.add(comic);
} }
} }
HistoryManager().addListener(update);
super.initState(); super.initState();
} }
@override
void dispose() {
HistoryManager().removeListener(update);
super.dispose();
}
void update() { void update() {
setState(() { setState(() {
comics.clear(); comics.clear();
@@ -778,7 +846,10 @@ class _SliverGridComics extends StatelessWidget {
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.72) ? Theme.of(context)
.colorScheme
.secondaryContainer
.toOpacity(0.72)
: null, : null,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -829,6 +900,9 @@ class ComicList extends StatefulWidget {
this.trailingSliver, this.trailingSliver,
this.errorLeading, this.errorLeading,
this.menuBuilder, this.menuBuilder,
this.controller,
this.refreshHandlerCallback,
this.enablePageStorage = false,
}); });
final Future<Res<List<Comic>>> Function(int page)? loadPage; final Future<Res<List<Comic>>> Function(int page)? loadPage;
@@ -843,6 +917,12 @@ class ComicList extends StatefulWidget {
final List<MenuEntry> Function(Comic)? menuBuilder; final List<MenuEntry> Function(Comic)? menuBuilder;
final ScrollController? controller;
final void Function(VoidCallback c)? refreshHandlerCallback;
final bool enablePageStorage;
@override @override
State<ComicList> createState() => ComicListState(); State<ComicList> createState() => ComicListState();
} }
@@ -860,6 +940,55 @@ class ComicListState extends State<ComicList> {
String? _nextUrl; String? _nextUrl;
late bool enablePageStorage = widget.enablePageStorage;
Map<String, dynamic> get state => {
'maxPage': _maxPage,
'data': _data,
'page': _page,
'error': _error,
'loading': _loading,
'nextUrl': _nextUrl,
};
void restoreState(Map<String, dynamic>? state) {
if (state == null || !enablePageStorage) {
return;
}
_maxPage = state['maxPage'];
_data.clear();
_data.addAll(state['data']);
_page = state['page'];
_error = state['error'];
_loading.clear();
_loading.addAll(state['loading']);
_nextUrl = state['nextUrl'];
}
void storeState() {
if (enablePageStorage) {
PageStorage.of(context).writeState(context, state);
}
}
void refresh() {
_data.clear();
_page = 1;
_maxPage = null;
_error = null;
_nextUrl = null;
_loading.clear();
storeState();
setState(() {});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
restoreState(PageStorage.of(context).readState(context));
widget.refreshHandlerCallback?.call(refresh);
}
void remove(Comic c) { void remove(Comic c) {
if (_data[_page] == null || !_data[_page]!.remove(c)) { if (_data[_page] == null || !_data[_page]!.remove(c)) {
for (var page in _data.values) { for (var page in _data.values) {
@@ -1007,15 +1136,20 @@ class ComicListState extends State<ComicList> {
while (_data[page] == null) { while (_data[page] == null) {
await _fetchNext(); await _fetchNext();
} }
if (mounted) {
setState(() {}); setState(() {});
}
} catch (e) { } catch (e) {
if (mounted) {
setState(() { setState(() {
_error = e.toString(); _error = e.toString();
}); });
} }
} }
}
} finally { } finally {
_loading[page] = false; _loading[page] = false;
storeState();
} }
} }
@@ -1064,6 +1198,8 @@ class ComicListState extends State<ComicList> {
); );
} }
return SmoothCustomScrollView( return SmoothCustomScrollView(
key: enablePageStorage ? PageStorageKey('scroll$_page') : null,
controller: widget.controller,
slivers: [ slivers: [
if (widget.leadingSliver != null) widget.leadingSliver!, if (widget.leadingSliver != null) widget.leadingSliver!,
if (_maxPage != 1) _buildSliverPageSelector(), if (_maxPage != 1) _buildSliverPageSelector(),
@@ -1297,7 +1433,7 @@ class _RatingWidgetState extends State<RatingWidget> {
} }
if (full < widget.count) { if (full < widget.count) {
children.add(ClipRect( children.add(ClipRect(
clipper: SMClipper(rating: star() * widget.size), clipper: _SMClipper(rating: star() * widget.size),
child: Icon( child: Icon(
Icons.star, Icons.star,
size: widget.size, size: widget.size,
@@ -1346,10 +1482,10 @@ class _RatingWidgetState extends State<RatingWidget> {
} }
} }
class SMClipper extends CustomClipper<Rect> { class _SMClipper extends CustomClipper<Rect> {
final double rating; final double rating;
SMClipper({required this.rating}); _SMClipper({required this.rating});
@override @override
Rect getClip(Size size) { Rect getClip(Size size) {
@@ -1357,7 +1493,52 @@ class SMClipper extends CustomClipper<Rect> {
} }
@override @override
bool shouldReclip(SMClipper oldClipper) { bool shouldReclip(_SMClipper oldClipper) {
return rating != oldClipper.rating; return rating != oldClipper.rating;
} }
} }
class SimpleComicTile extends StatelessWidget {
const SimpleComicTile({super.key, required this.comic, this.onTap});
final Comic comic;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
var image = _findImageProvider(comic);
var child = image == null
? const SizedBox()
: AnimatedImage(
image: image,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
filterQuality: FilterQuality.medium,
);
return AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ?? () {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
),
);
}
}

View File

@@ -1,5 +1,3 @@
library components;
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:math' as math; import 'dart:math' as math;
@@ -10,6 +8,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:syntax_highlight/syntax_highlight.dart';
import 'package:text_scroll/text_scroll.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/app_page_route.dart'; import 'package:venera/foundation/app_page_route.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
@@ -19,13 +19,14 @@ import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/cloudflare.dart'; import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -46,3 +47,4 @@ part 'side_bar.dart';
part 'comic.dart'; part 'comic.dart';
part 'effects.dart'; part 'effects.dart';
part 'gesture.dart'; part 'gesture.dart';
part 'code.dart';

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart';
/// patched slider.dart with RtL support /// patched slider.dart with RtL support
class _SliderDefaultsM3 extends SliderThemeData { class _SliderDefaultsM3 extends SliderThemeData {
@@ -15,45 +16,45 @@ class _SliderDefaultsM3 extends SliderThemeData {
Color? get inactiveTrackColor => _colors.surfaceContainerHighest; Color? get inactiveTrackColor => _colors.surfaceContainerHighest;
@override @override
Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); Color? get secondaryActiveTrackColor => _colors.primary.toOpacity(0.54);
@override @override
Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38); Color? get disabledActiveTrackColor => _colors.onSurface.toOpacity(0.38);
@override @override
Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); Color? get disabledInactiveTrackColor => _colors.onSurface.toOpacity(0.12);
@override @override
Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12); Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.toOpacity(0.12);
@override @override
Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.38); Color? get activeTickMarkColor => _colors.onPrimary.toOpacity(0.38);
@override @override
Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.withOpacity(0.38); Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.toOpacity(0.38);
@override @override
Color? get disabledActiveTickMarkColor => _colors.onSurface.withOpacity(0.38); Color? get disabledActiveTickMarkColor => _colors.onSurface.toOpacity(0.38);
@override @override
Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.38); Color? get disabledInactiveTickMarkColor => _colors.onSurface.toOpacity(0.38);
@override @override
Color? get thumbColor => _colors.primary; Color? get thumbColor => _colors.primary;
@override @override
Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface); Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.toOpacity(0.38), _colors.surface);
@override @override
Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) { Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.dragged)) { if (states.contains(WidgetState.dragged)) {
return _colors.primary.withOpacity(0.1); return _colors.primary.toOpacity(0.1);
} }
if (states.contains(WidgetState.hovered)) { if (states.contains(WidgetState.hovered)) {
return _colors.primary.withOpacity(0.08); return _colors.primary.toOpacity(0.08);
} }
if (states.contains(WidgetState.focused)) { if (states.contains(WidgetState.focused)) {
return _colors.primary.withOpacity(0.1); return _colors.primary.toOpacity(0.1);
} }
return Colors.transparent; return Colors.transparent;

View File

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

View File

@@ -1,7 +1,8 @@
part of 'components.dart'; part of 'components.dart';
class MouseBackDetector extends StatelessWidget { class MouseBackDetector extends StatelessWidget {
const MouseBackDetector({super.key, required this.onTapDown, required this.child}); const MouseBackDetector(
{super.key, required this.onTapDown, required this.child});
final Widget child; final Widget child;
@@ -20,3 +21,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.filterQuality = FilterQuality.medium,
this.isAntiAlias = false, this.isAntiAlias = false,
this.part, this.part,
this.onError,
Map<String, String>? headers, Map<String, String>? headers,
int? cacheWidth, int? cacheWidth,
int? cacheHeight, int? cacheHeight,
@@ -63,6 +64,8 @@ class AnimatedImage extends StatefulWidget {
final ImagePart? part; final ImagePart? part;
final Function? onError;
static void clear() => _AnimatedImageState.clear(); static void clear() => _AnimatedImageState.clear();
@override @override
@@ -169,6 +172,8 @@ class _AnimatedImageState extends State<AnimatedImage>
_handleImageFrame, _handleImageFrame,
onChunk: _handleImageChunk, onChunk: _handleImageChunk,
onError: (Object error, StackTrace? stackTrace) { onError: (Object error, StackTrace? stackTrace) {
// 图片加错错误回调
widget.onError?.call(error, stackTrace);
setState(() { setState(() {
_lastException = error; _lastException = error;
}); });

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

View File

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

View File

@@ -5,6 +5,7 @@ void showToast({
required BuildContext context, required BuildContext context,
Widget? icon, Widget? icon,
Widget? trailing, Widget? trailing,
int? seconds,
}) { }) {
var newEntry = OverlayEntry( var newEntry = OverlayEntry(
builder: (context) => _ToastOverlay( builder: (context) => _ToastOverlay(
@@ -17,7 +18,7 @@ void showToast({
state?.addOverlay(newEntry); state?.addOverlay(newEntry);
Timer(const Duration(seconds: 2), () => state?.remove(newEntry)); Timer(Duration(seconds: seconds ?? 2), () => state?.remove(newEntry));
} }
class _ToastOverlay extends StatelessWidget { class _ToastOverlay extends StatelessWidget {
@@ -46,19 +47,26 @@ class _ToastOverlay extends StatelessWidget {
child: IconTheme( child: IconTheme(
data: IconThemeData( data: IconThemeData(
color: Theme.of(context).colorScheme.onInverseSurface), color: Theme.of(context).colorScheme.onInverseSurface),
child: IntrinsicWidth(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
constraints: BoxConstraints(
maxWidth: context.width - 32,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (icon != null) icon!.paddingRight(8), if (icon != null) icon!.paddingRight(8),
Text( Expanded(
child: Text(
message, message,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500), fontSize: 16, fontWeight: FontWeight.w500),
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
),
if (trailing != null) trailing!.paddingLeft(8) if (trailing != null) trailing!.paddingLeft(8)
], ],
), ),
@@ -66,6 +74,7 @@ class _ToastOverlay extends StatelessWidget {
), ),
), ),
), ),
),
); );
} }
} }
@@ -220,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); navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
@@ -234,13 +243,13 @@ LoadingDialogController showLoadingDialog(BuildContext context,
class ContentDialog extends StatelessWidget { class ContentDialog extends StatelessWidget {
const ContentDialog({ const ContentDialog({
super.key, super.key,
required this.title, this.title, // 如果不传 title 将不会展示
required this.content, required this.content,
this.dismissible = true, this.dismissible = true,
this.actions = const [], this.actions = const [],
}); });
final String title; final String? title;
final Widget content; final Widget content;
@@ -254,14 +263,16 @@ class ContentDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Appbar( title != null
? Appbar(
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null, onPressed: dismissible ? context.pop : null,
), ),
title: Text(title), title: Text(title!),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
), )
: const SizedBox.shrink(),
this.content, this.content,
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -283,6 +294,7 @@ class ContentDialog extends StatelessWidget {
: const EdgeInsets.symmetric(horizontal: 16), : const EdgeInsets.symmetric(horizontal: 16),
elevation: 2, elevation: 2,
shadowColor: context.colorScheme.shadow, shadowColor: context.colorScheme.shadow,
backgroundColor: context.colorScheme.surface,
child: AnimatedSize( child: AnimatedSize(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter, alignment: Alignment.topCenter,

View File

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

View File

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

View File

@@ -78,6 +78,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
}, },
onPointerSignal: (pointerSignal) { onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) { if (pointerSignal is PointerScrollEvent) {
if (HardwareKeyboard.instance.isShiftPressed) {
return;
}
if (pointerSignal.kind == PointerDeviceKind.mouse && if (pointerSignal.kind == PointerDeviceKind.mouse &&
!_isMouseScroll) { !_isMouseScroll) {
setState(() { setState(() {
@@ -99,6 +102,8 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
duration: _fastAnimationDuration, curve: Curves.linear); duration: _fastAnimationDuration, curve: Curves.linear);
} }
}, },
child: ScrollControllerProvider._(
controller: _controller,
child: widget.builder( child: widget.builder(
context, context,
_controller, _controller,
@@ -106,6 +111,27 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
? const NeverScrollableScrollPhysics() ? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(), : 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

@@ -267,13 +267,14 @@ class OptionChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return AnimatedContainer(
duration: _fastAnimationDuration,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? context.colorScheme.primaryContainer ? context.colorScheme.secondaryContainer
: context.colorScheme.surface, : context.colorScheme.surface,
border: isSelected border: isSelected
? Border.all(color: context.colorScheme.primaryContainer) ? Border.all(color: context.colorScheme.secondaryContainer)
: Border.all(color: context.colorScheme.outline), : Border.all(color: context.colorScheme.outline),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),

View File

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

View File

@@ -563,7 +563,7 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
if (!_isMaximized && !_isFullScreen) if (!_isMaximized && !_isFullScreen)
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color: Colors.black.toOpacity(0.1),
offset: Offset(0.0, _isFocused ? 4 : 2), offset: Offset(0.0, _isFocused ? 4 : 2),
blurRadius: 6, blurRadius: 6,
) )

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
class _Appdata { class _Appdata {
@@ -12,7 +13,7 @@ class _Appdata {
bool _isSavingData = false; bool _isSavingData = false;
Future<void> saveData() async { Future<void> saveData([bool sync = true]) async {
if (_isSavingData) { if (_isSavingData) {
await Future.doWhile(() async { await Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 20)); await Future.delayed(const Duration(milliseconds: 20));
@@ -24,6 +25,9 @@ class _Appdata {
var file = File(FilePath.join(App.dataPath, 'appdata.json')); var file = File(FilePath.join(App.dataPath, 'appdata.json'));
await file.writeAsString(data); await file.writeAsString(data);
_isSavingData = false; _isSavingData = false;
if (sync) {
DataSync().uploadData();
}
} }
void addSearchHistory(String keyword) { void addSearchHistory(String keyword) {
@@ -76,6 +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>{}; var implicitData = <String, dynamic>{};
void writeImplicitData() { void writeImplicitData() {
@@ -92,7 +116,7 @@ class _Settings with ChangeNotifier {
final _data = <String, dynamic>{ final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief 'comicDisplayMode': 'detailed', // detailed, brief
'comicTileScale': 1.00, // 0.75-1.25 'comicTileScale': 1.00, // 0.75-1.25
'color': 'blue', // red, pink, purple, green, orange, blue 'color': 'system', // red, pink, purple, green, orange, blue
'theme_mode': 'system', // light, dark, system 'theme_mode': 'system', // light, dark, system
'newFavoriteAddTo': 'end', // start, end 'newFavoriteAddTo': 'end', // start, end
'moveFavoriteAfterRead': 'none', // none, end, start 'moveFavoriteAfterRead': 'none', // none, end, start
@@ -106,21 +130,29 @@ class _Settings with ChangeNotifier {
'defaultSearchTarget': null, 'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds 'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode] 'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumber': 1, // 1 - 5
'enableTapToTurnPages': true, 'enableTapToTurnPages': true,
'enablePageAnimation': true, 'enablePageAnimation': true,
'language': 'system', // system, zh-CN, zh-TW, en-US 'language': 'system', // system, zh-CN, zh-TW, en-US
'cacheSize': 2048, // in MB 'cacheSize': 2048, // in MB
'downloadThreads': 5, 'downloadThreads': 5,
'enableLongPressToZoom': true, 'enableLongPressToZoom': true,
'checkUpdateOnStart': true, 'checkUpdateOnStart': false,
'limitImageWidth': true, 'limitImageWidth': true,
'webdav': [], // empty means not configured 'webdav': [], // empty means not configured
'dataVersion': 0, 'dataVersion': 0,
'quickFavorite': null, 'quickFavorite': null,
'enableTurnPageByVolumeKey': true, 'enableTurnPageByVolumeKey': true,
'enableClockAndBatteryInfoInReader': true, 'enableClockAndBatteryInfoInReader': true,
'ignoreCertificateErrors': false, 'quickCollectImage': 'No', // No, DoubleTap, Swipe
'authorizationRequired': false, 'authorizationRequired': false,
'onClickFavorite': 'viewDetail', // viewDetail, read
'enableDnsOverrides': false,
'dnsOverrides': {},
'enableCustomImageProcessing': false,
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
}; };
operator [](String key) { operator [](String key) {
@@ -137,3 +169,21 @@ class _Settings with ChangeNotifier {
return _data.toString(); return _data.toString();
} }
} }
const defaultCustomImageProcessing = '''
/**
* Process an image
* @param image {ArrayBuffer} - The image to process
* @param cid {string} - The comic ID
* @param eid {string} - The episode ID
* @param page {number} - The page number
* @param sourceKey {string} - The source key
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
*/
function processImage(image, cid, eid, page, sourceKey) {
let 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 { class CategoryData {
/// The title is displayed in the tab bar. /// The title is displayed in the tab bar.

View File

@@ -1,4 +1,4 @@
library comic_source; library;
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
@@ -6,6 +6,7 @@ import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
@@ -136,6 +137,8 @@ class ComicSource {
notifyListeners(); notifyListeners();
} }
static final availableUpdates = <String, String>{};
static bool get isEmpty => _sources.isEmpty; static bool get isEmpty => _sources.isEmpty;
/// Name of this source. /// Name of this source.
@@ -201,7 +204,7 @@ class ComicSource {
final LikeCommentFunc? likeCommentFunc; final LikeCommentFunc? likeCommentFunc;
final Map<String, dynamic>? settings; final Map<String, Map<String, dynamic>>? settings;
final Map<String, Map<String, String>>? translations; final Map<String, Map<String, String>>? translations;

View File

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

View File

@@ -73,7 +73,8 @@ class Comic {
this.sourceKey, this.sourceKey,
this.maxPage, this.maxPage,
this.language, this.language,
): favoriteId = null, stars = null; ) : favoriteId = null,
stars = null;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
@@ -145,7 +146,7 @@ class ComicDetails with HistoryMixin {
final int? likesCount; final int? likesCount;
final int? commentsCount; final int? commentCount;
final String? uploader; final String? uploader;
@@ -172,7 +173,7 @@ class ComicDetails with HistoryMixin {
ComicDetails.fromJson(Map<String, dynamic> json) ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"], : title = json["title"],
subTitle = json["subTitle"], subTitle = json["subtitle"],
cover = json["cover"], cover = json["cover"],
description = json["description"], description = json["description"],
tags = _generateMap(json["tags"]), tags = _generateMap(json["tags"]),
@@ -189,7 +190,7 @@ class ComicDetails with HistoryMixin {
subId = json["subId"], subId = json["subId"],
likesCount = json["likesCount"], likesCount = json["likesCount"],
isLiked = json["isLiked"], isLiked = json["isLiked"],
commentsCount = json["commentsCount"], commentCount = json["commentCount"],
uploader = json["uploader"], uploader = json["uploader"],
uploadTime = json["uploadTime"], uploadTime = json["uploadTime"],
updateTime = json["updateTime"], updateTime = json["updateTime"],
@@ -216,7 +217,7 @@ class ComicDetails with HistoryMixin {
"subId": subId, "subId": subId,
"isLiked": isLiked, "isLiked": isLiked,
"likesCount": likesCount, "likesCount": likesCount,
"commentsCount": commentsCount, "commentsCount": commentCount,
"uploader": uploader, "uploader": uploader,
"uploadTime": uploadTime, "uploadTime": uploadTime,
"updateTime": updateTime, "updateTime": updateTime,
@@ -231,6 +232,34 @@ class ComicDetails with HistoryMixin {
String get id => comicId; String get id => comicId;
ComicType get comicType => ComicType(sourceKey.hashCode); ComicType get comicType => ComicType(sourceKey.hashCode);
/// Convert tags map to plain list
List<String> get plainTags {
var res = <String>[];
tags.forEach((key, value) {
res.addAll(value.map((e) => "$key:$e"));
});
return res;
}
/// Find the first author tag
String? findAuthor() {
var authorNamespaces = [
"author",
"authors",
"artist",
"artists",
"作者",
"画师"
];
for (var entry in tags.entries) {
if (authorNamespaces.contains(entry.key.toLowerCase()) &&
entry.value.isNotEmpty) {
return entry.value.first;
}
}
return null;
}
} }
class ArchiveInfo { class ArchiveInfo {

View File

@@ -1,5 +1,6 @@
part of 'comic_source.dart'; part of 'comic_source.dart';
/// return true if ver1 > ver2
bool compareSemVer(String ver1, String ver2) { bool compareSemVer(String ver1, String ver2) {
ver1 = ver1.replaceFirst("-", "."); ver1 = ver1.replaceFirst("-", ".");
ver2 = ver2.replaceFirst("-", "."); ver2 = ver2.replaceFirst("-", ".");
@@ -90,11 +91,10 @@ class ComicSourceParser {
var className = line1.split("class")[1].split("extends ComicSource").first; var className = line1.split("class")[1].split("extends ComicSource").first;
className = className.trim(); className = className.trim();
JsEngine().runCode(""" JsEngine().runCode("""
(() => { (() => { $js
$js
this['temp'] = new $className() this['temp'] = new $className()
}).call() }).call()
"""); """, className);
_name = JsEngine().runCode("this['temp'].name") ?? _name = JsEngine().runCode("this['temp'].name") ??
(throw ComicSourceParseException('name is required')); (throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") ?? var key = JsEngine().runCode("this['temp'].key") ??
@@ -619,6 +619,7 @@ class ComicSourceParser {
if (!_checkExists("favorites")) return null; if (!_checkExists("favorites")) return null;
final bool multiFolder = _getValue("favorites.multiFolder"); final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async { Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) { if (!ComicSource.find(_key!)!.isLogged) {
@@ -771,6 +772,7 @@ class ComicSourceParser {
addFolder: addFolder, addFolder: addFolder,
deleteFolder: deleteFolder, deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc, addOrDelFavorite: addOrDelFavFunc,
isOldToNewSort: isOldToNewSort,
); );
} }
@@ -921,8 +923,30 @@ class ComicSourceParser {
}; };
} }
Map<String, dynamic> _parseSettings() { Map<String, Map<String, dynamic>> _parseSettings() {
return _getValue("settings") ?? {}; var value = _getValue("settings");
if (value is Map) {
var newMap = <String, Map<String, dynamic>>{};
for (var e in value.entries) {
if (e.key is! String) {
continue;
}
var v = <String, dynamic>{};
for (var e2 in e.value.entries) {
if (e2.key is! String) {
continue;
}
var v2 = e2.value;
if (v2 is JSInvokable) {
v2 = JSAutoFreeFunction(v2);
}
v[e2.key] = v2;
}
newMap[e.key] = v;
}
return newMap;
}
return {};
} }
RegExp? _parseIdMatch() { RegExp? _parseIdMatch() {

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/image_provider/local_favorite_image.dart'; import 'package:venera/foundation/image_provider/local_favorite_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/utils/tags_translation.dart';
import 'dart:io'; import 'dart:io';
import 'app.dart'; import 'app.dart';
@@ -72,6 +73,7 @@ class FavoriteItem implements Comic {
@override @override
String get description { String get description {
var time = this.time.substring(0, 10);
return appdata.settings['comicDisplayMode'] == 'detailed' return appdata.settings['comicDisplayMode'] == 'detailed'
? "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}" ? "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}"
: "${type.comicSource?.name ?? "Unknown"} | $time"; : "${type.comicSource?.name ?? "Unknown"} | $time";
@@ -177,6 +179,28 @@ class LocalFavoritesManager with ChangeNotifier {
source_folder text source_folder text
); );
"""); """);
for (var folder in _getFolderNamesWithDB()) {
var columns = _db.select("""
pragma table_info("$folder");
""");
if (!columns.any((element) => element["name"] == "translated_tags")) {
_db.execute("""
alter table "$folder"
add column translated_tags TEXT;
""");
var comics = getAllComics(folder);
for (var comic in comics) {
var translatedTags = _translateTags(comic.tags);
_db.execute("""
update "$folder"
set translated_tags = ?
where id == ? and type == ?;
""", [translatedTags, comic.id, comic.type.value]);
}
} else {
break;
}
}
} }
List<String> find(String id, ComicType type) { List<String> find(String id, ComicType type) {
@@ -338,6 +362,7 @@ class LocalFavoritesManager with ChangeNotifier {
cover_path TEXT, cover_path TEXT,
time TEXT, time TEXT,
display_order int, display_order int,
translated_tags TEXT,
primary key (id, type) primary key (id, type)
); );
"""); """);
@@ -391,6 +416,17 @@ class LocalFavoritesManager with ChangeNotifier {
return FavoriteItem.fromRow(res.first); return FavoriteItem.fromRow(res.first);
} }
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. /// add comic to a folder.
/// return true if success, false if already exists /// return true if success, false if already exists
bool addComic(String folder, FavoriteItem comic, [int? order]) { bool addComic(String folder, FavoriteItem comic, [int? order]) {
@@ -405,6 +441,7 @@ class LocalFavoritesManager with ChangeNotifier {
if (res.isNotEmpty) { if (res.isNotEmpty) {
return false; return false;
} }
var translatedTags = _translateTags(comic.tags);
final params = [ final params = [
comic.id, comic.id,
comic.name, comic.name,
@@ -412,22 +449,23 @@ class LocalFavoritesManager with ChangeNotifier {
comic.type.value, comic.type.value,
comic.tags.join(","), comic.tags.join(","),
comic.coverPath, comic.coverPath,
comic.time comic.time,
translatedTags
]; ];
if (order != null) { if (order != null) {
_db.execute(""" _db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order) insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, order]); """, [...params, order]);
} else if (appdata.settings['newFavoriteAddTo'] == "end") { } else if (appdata.settings['newFavoriteAddTo'] == "end") {
_db.execute(""" _db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order) insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, maxValue(folder) + 1]); """, [...params, maxValue(folder) + 1]);
} else { } else {
_db.execute(""" _db.execute("""
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order) insert into "$folder" (id, name, author, type, tags, cover_path, time, translated_tags, display_order)
values (?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, minValue(folder) - 1]); """, [...params, minValue(folder) - 1]);
} }
notifyListeners(); notifyListeners();
@@ -503,8 +541,9 @@ class LocalFavoritesManager with ChangeNotifier {
var all = allComics(); var all = allComics();
for (var c in all) { for (var c in all) {
var comicSource = c.type.comicSource; var comicSource = c.type.comicSource;
if ((c.type == ComicType.local && LocalManager().find(c.id, c.type) == null) if ((c.type == ComicType.local &&
|| (c.type != ComicType.local && comicSource == null)) { LocalManager().find(c.id, c.type) == null) ||
(c.type != ComicType.local && comicSource == null)) {
deleteComicWithId(c.folder, c.id, c.type); deleteComicWithId(c.folder, c.id, c.type);
count++; count++;
} }
@@ -555,7 +594,10 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void onReadEnd(String id, ComicType type) async { void onRead(String id, ComicType type) async {
if (appdata.settings['moveFavoriteAfterRead'] == "none") {
return;
}
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
for (final folder in folderNames) { for (final folder in folderNames) {
var rows = _db.select(""" var rows = _db.select("""
@@ -593,6 +635,33 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
List<FavoriteItem> searchInFolder(String folder, String keyword) {
var keywordList = keyword.split(" ");
keyword = keywordList.first;
keyword = "%$keyword%";
var res = _db.select("""
SELECT * FROM "$folder"
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword, keyword]);
var comics = res.map((e) => FavoriteItem.fromRow(e)).toList();
bool test(FavoriteItem comic, String keyword) {
if (comic.name.contains(keyword)) {
return true;
} else if (comic.author.contains(keyword)) {
return true;
} else if (comic.tags.any((element) => element.contains(keyword))) {
return true;
}
return false;
}
for (var i = 1; i < keywordList.length; i++) {
comics =
comics.where((element) => test(element, keywordList[i])).toList();
}
return comics;
}
List<FavoriteItemWithFolderInfo> search(String keyword) { List<FavoriteItemWithFolderInfo> search(String keyword) {
var keywordList = keyword.split(" "); var keywordList = keyword.split(" ");
keyword = keywordList.first; keyword = keywordList.first;
@@ -601,8 +670,8 @@ class LocalFavoritesManager with ChangeNotifier {
keyword = "%$keyword%"; keyword = "%$keyword%";
var res = _db.select(""" var res = _db.select("""
SELECT * FROM "$table" SELECT * FROM "$table"
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ?; WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword]); """, [keyword, keyword, keyword, keyword]);
for (var comic in res) { for (var comic in res) {
comics.add( comics.add(
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table)); FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));

View File

@@ -1,10 +1,23 @@
import 'dart:async'; 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:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'app.dart'; import 'app.dart';
import 'consts.dart';
part "image_favorites.dart";
typedef HistoryType = ComicType; typedef HistoryType = ComicType;
@@ -22,21 +35,25 @@ abstract mixin class HistoryMixin {
HistoryType get historyType; HistoryType get historyType;
} }
class History { class History implements Comic {
HistoryType type; HistoryType type;
DateTime time; DateTime time;
@override
String title; String title;
@override
String subtitle; String subtitle;
@override
String cover; String cover;
int ep; int ep;
int page; int page;
@override
String id; String id;
/// readEpisode is a set of episode numbers that have been read. /// readEpisode is a set of episode numbers that have been read.
@@ -44,6 +61,7 @@ class History {
/// The number of episodes is 1-based. /// The number of episodes is 1-based.
Set<int> readEpisode; Set<int> readEpisode;
@override
int? maxPage; int? maxPage;
History.fromModel( History.fromModel(
@@ -137,6 +155,47 @@ class History {
@override @override
int get hashCode => Object.hash(id, type); int get hashCode => Object.hash(id, type);
@override
String get description {
var res = "";
if (ep >= 1) {
res += "Chapter @ep".tlParams({
"ep": ep,
});
}
if (page >= 1) {
if (ep >= 1) {
res += " - ";
}
res += "Page @page".tlParams({
"page": page,
});
}
return res;
}
@override
String? get favoriteId => null;
@override
String? get language => null;
@override
String get sourceKey => type == ComicType.local
? 'local'
: type.comicSource?.key ?? "Unknown:${type.value}";
@override
double? get stars => null;
@override
List<String>? get tags => null;
@override
Map<String, dynamic> toJson() {
throw UnimplementedError();
}
} }
class HistoryManager with ChangeNotifier { class HistoryManager with ChangeNotifier {
@@ -153,9 +212,12 @@ class HistoryManager with ChangeNotifier {
Map<String, bool>? _cachedHistory; Map<String, bool>? _cachedHistory;
static const _kMaxHistoryLength = 200; bool isInitialized = false;
Future<void> init() async { Future<void> init() async {
if (isInitialized) {
return;
}
_db = sqlite3.open("${App.dataPath}/history.db"); _db = sqlite3.open("${App.dataPath}/history.db");
_db.execute(""" _db.execute("""
@@ -174,18 +236,14 @@ class HistoryManager with ChangeNotifier {
"""); """);
notifyListeners(); notifyListeners();
ImageFavoriteManager().init();
isInitialized = true;
} }
/// add history. if exists, update time. /// add history. if exists, update time.
/// ///
/// This function would be called when user start reading. /// This function would be called when user start reading.
Future<void> addHistory(History newItem) async { Future<void> addHistory(History newItem) async {
while(count() >= _kMaxHistoryLength) {
_db.execute("""
delete from history
where time == (select min(time) from history);
""");
}
_db.execute(""" _db.execute("""
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page) insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
@@ -279,6 +337,7 @@ class HistoryManager with ChangeNotifier {
} }
void close() { void close() {
isInitialized = false;
_db.dispose(); _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:async' show Future, StreamController, scheduleMicrotask;
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/log.dart';
abstract class BaseImageProvider<T extends BaseImageProvider<T>> abstract class BaseImageProvider<T extends BaseImageProvider<T>>
extends ImageProvider<T> { extends ImageProvider<T> {
const BaseImageProvider(); const BaseImageProvider();
static double? _effectiveScreenWidth;
static const double _normalComicImageRatio = 0.72;
static const double _minComicImageWidth = 1920 * _normalComicImageRatio;
static TargetImageSize _getTargetSize(width, height) {
if (_effectiveScreenWidth == null) {
final screens = PlatformDispatcher.instance.displays;
for (var screen in screens) {
if (screen.size.width > screen.size.height) {
_effectiveScreenWidth = max(
_effectiveScreenWidth ?? 0,
screen.size.height * _normalComicImageRatio,
);
} else {
_effectiveScreenWidth =
max(_effectiveScreenWidth ?? 0, screen.size.width);
}
}
if (_effectiveScreenWidth! < _minComicImageWidth) {
_effectiveScreenWidth = _minComicImageWidth;
}
}
if (width > _effectiveScreenWidth!) {
height = (height * _effectiveScreenWidth! / width).round();
width = _effectiveScreenWidth!.round();
}
return TargetImageSize(width: width, height: height);
}
@override @override
ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>(); final chunkEvents = StreamController<ImageChunkEvent>();
@@ -46,14 +78,13 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
while (data == null && !stop) { while (data == null && !stop) {
try { try {
if(_cache.containsKey(key.key)){ data = await load(chunkEvents, () {
data = _cache[key.key]; if (stop) {
} else { throw const _ImageLoadingStopException();
data = await load(chunkEvents);
_checkCacheSize();
_cache[key.key] = data;
_cacheSize += data.length;
} }
});
} on _ImageLoadingStopException {
rethrow;
} catch (e) { } catch (e) {
if (e.toString().contains("Invalid Status Code: 404")) { if (e.toString().contains("Invalid Status Code: 404")) {
rethrow; rethrow;
@@ -75,7 +106,7 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
if (stop) { if (stop) {
throw Exception("Image loading is stopped"); throw const _ImageLoadingStopException();
} }
if (data!.isEmpty) { if (data!.isEmpty) {
@@ -84,13 +115,17 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
try { try {
final buffer = await ImmutableBuffer.fromUint8List(data); final buffer = await ImmutableBuffer.fromUint8List(data);
return await decode(buffer); return await decode(
buffer,
getTargetSize: enableResize ? _getTargetSize : null,
);
} catch (e) { } catch (e) {
await CacheManager().delete(this.key); await CacheManager().delete(this.key);
if (data.length < 2 * 1024) { if (data.length < 2 * 1024) {
// data is too short, it's likely that the data is text, not image // data is too short, it's likely that the data is text, not image
try { try {
var text = const Utf8Codec(allowMalformed: false).decoder.convert(data); var text =
const Utf8Codec(allowMalformed: false).decoder.convert(data);
throw Exception("Expected image data, but got text: $text"); throw Exception("Expected image data, but got text: $text");
} catch (e) { } catch (e) {
// ignore // ignore
@@ -98,41 +133,23 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
rethrow; rethrow;
} }
} catch (e) { } on _ImageLoadingStopException {
rethrow;
} catch (e, s) {
scheduleMicrotask(() { scheduleMicrotask(() {
PaintingBinding.instance.imageCache.evict(key); PaintingBinding.instance.imageCache.evict(key);
}); });
Log.error("Image Loading", e, s);
rethrow; rethrow;
} finally { } finally {
chunkEvents.close(); chunkEvents.close();
} }
} }
static final _cache = LinkedHashMap<String, Uint8List>(); Future<Uint8List> load(
StreamController<ImageChunkEvent> chunkEvents,
static var _cacheSize = 0; void Function() checkStop,
);
static var _cacheSizeLimit = 50 * 1024 * 1024;
static void _checkCacheSize(){
while (_cacheSize > _cacheSizeLimit){
var firstKey = _cache.keys.first;
_cacheSize -= _cache[firstKey]!.length;
_cache.remove(firstKey);
}
}
static void clearCache(){
_cache.clear();
_cacheSize = 0;
}
static void setCacheSizeLimit(int size){
_cacheSizeLimit = size;
_checkCacheSize();
}
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents);
String get key; String get key;
@@ -148,6 +165,12 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
String toString() { String toString() {
return "$runtimeType($key)"; return "$runtimeType($key)";
} }
bool get enableResize => false;
} }
typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List); typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List);
class _ImageLoadingStopException implements Exception {
const _ImageLoadingStopException();
}

View File

@@ -1,5 +1,4 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
@@ -22,13 +21,24 @@ class CachedImageProvider
final String? cid; final String? cid;
static int loadingCount = 0;
static const _kMaxLoadingCount = 8;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
while(loadingCount > _kMaxLoadingCount) {
await Future.delayed(const Duration(milliseconds: 100));
checkStop();
}
loadingCount++;
try {
if(url.startsWith("file://")) { if(url.startsWith("file://")) {
var file = openFilePlatform(url.substring(7)); var file = File(url.substring(7));
return file.readAsBytes(); return file.readAsBytes();
} }
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
checkStop();
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes, expectedTotalBytes: progress.totalBytes,
@@ -39,6 +49,10 @@ class CachedImageProvider
} }
throw "Error: Empty response body."; throw "Error: Empty response body.";
} }
finally {
loadingCount--;
}
}
@override @override
Future<CachedImageProvider> obtainKey(ImageConfiguration configuration) { Future<CachedImageProvider> obtainKey(ImageConfiguration configuration) {

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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -28,7 +28,7 @@ class LocalFavoriteImageProvider
} }
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
var sourceKey = ComicSource.fromIntKey(intKey)?.key; var sourceKey = ComicSource.fromIntKey(intKey)?.key;
var fileName = key.hashCode.toString(); var fileName = key.hashCode.toString();
var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName)); var file = File(FilePath.join(App.dataPath, 'favorite_cover', fileName));
@@ -37,7 +37,9 @@ class LocalFavoriteImageProvider
} else { } else {
await file.create(recursive: true); await file.create(recursive: true);
} }
checkStop();
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
checkStop();
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes, expectedTotalBytes: progress.totalBytes,
@@ -52,7 +54,8 @@ class LocalFavoriteImageProvider
} }
@override @override
Future<LocalFavoriteImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFavoriteImageProvider> obtainKey(
ImageConfiguration configuration) {
return SynchronousFuture(this); return SynchronousFuture(this);
} }

View File

@@ -1,14 +1,18 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/js_engine.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart'; import 'base_image_provider.dart';
import 'reader_image.dart' as image_provider; import 'reader_image.dart' as image_provider;
import 'package:venera/foundation/appdata.dart';
class ReaderImageProvider class ReaderImageProvider
extends BaseImageProvider<image_provider.ReaderImageProvider> { extends BaseImageProvider<image_provider.ReaderImageProvider> {
/// Image provider for normal image. /// Image provider for normal image.
const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid); const ReaderImageProvider(this.imageKey, this.sourceKey, this.cid, this.eid, this.page);
final String imageKey; final String imageKey;
@@ -18,20 +22,99 @@ class ReaderImageProvider
final String eid; final String eid;
final int page;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(chunkEvents, checkStop) async {
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 await for (var event
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) { in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
checkStop();
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: event.currentBytes, cumulativeBytesLoaded: event.currentBytes,
expectedTotalBytes: event.totalBytes, expectedTotalBytes: event.totalBytes,
)); ));
if (event.imageBytes != null) { if (event.imageBytes != null) {
return event.imageBytes!; imageBytes = event.imageBytes;
break;
} }
} }
}
if (imageBytes == null) {
throw "Error: Empty response body."; 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 @override
Future<ReaderImageProvider> obtainKey(ImageConfiguration configuration) { Future<ReaderImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -40,4 +123,7 @@ class ReaderImageProvider
@override @override
String get key => "$imageKey@$sourceKey@$cid@$eid"; String get key => "$imageKey@$sourceKey@$cid@$eid";
@override
bool get enableResize => true;
} }

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html; import 'package:html/parser.dart' as html;
import 'package:html/dom.dart' as dom; import 'package:html/dom.dart' as dom;
@@ -19,7 +20,9 @@ import 'package:pointycastle/block/modes/cbc.dart';
import 'package:pointycastle/block/modes/cfb.dart'; import 'package:pointycastle/block/modes/cfb.dart';
import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ecb.dart';
import 'package:pointycastle/block/modes/ofb.dart'; import 'package:pointycastle/block/modes/ofb.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
@@ -39,7 +42,7 @@ class JavaScriptRuntimeException implements Exception {
} }
} }
class JsEngine with _JSEngineApi { class JsEngine with _JSEngineApi, _JsUiApi {
factory JsEngine() => _cache ?? (_cache = JsEngine._create()); factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache; static JsEngine? _cache;
@@ -58,6 +61,11 @@ class JsEngine with _JSEngineApi {
JsEngine().init(); JsEngine().init();
} }
void resetDio() {
_dio = AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true));
}
Future<void> init() async { Future<void> init() async {
if (!_closed) { if (!_closed) {
return; return;
@@ -88,7 +96,6 @@ class JsEngine with _JSEngineApi {
String method = message["method"] as String; String method = message["method"] as String;
switch (method) { switch (method) {
case "log": case "log":
{
String level = message["level"]; String level = message["level"];
Log.addLog( Log.addLog(
switch (level) { switch (level) {
@@ -99,15 +106,11 @@ class JsEngine with _JSEngineApi {
}, },
message["title"], message["title"],
message["content"].toString()); message["content"].toString());
}
case 'load_data': case 'load_data':
{
String key = message["key"]; String key = message["key"];
String dataKey = message["data_key"]; String dataKey = message["data_key"];
return ComicSource.find(key)?.data[dataKey]; return ComicSource.find(key)?.data[dataKey];
}
case 'save_data': case 'save_data':
{
String key = message["key"]; String key = message["key"];
String dataKey = message["data_key"]; String dataKey = message["data_key"];
if (dataKey == 'setting') { if (dataKey == 'setting') {
@@ -117,56 +120,43 @@ class JsEngine with _JSEngineApi {
var source = ComicSource.find(key)!; var source = ComicSource.find(key)!;
source.data[dataKey] = data; source.data[dataKey] = data;
source.saveData(); source.saveData();
}
case 'delete_data': case 'delete_data':
{
String key = message["key"]; String key = message["key"];
String dataKey = message["data_key"]; String dataKey = message["data_key"];
var source = ComicSource.find(key); var source = ComicSource.find(key);
source?.data.remove(dataKey); source?.data.remove(dataKey);
source?.saveData(); source?.saveData();
}
case 'http': case 'http':
{
return _http(Map.from(message)); return _http(Map.from(message));
}
case 'html': case 'html':
{
return handleHtmlCallback(Map.from(message)); return handleHtmlCallback(Map.from(message));
}
case 'convert': case 'convert':
{
return _convert(Map.from(message)); return _convert(Map.from(message));
}
case "random": case "random":
{
return _random( return _random(
message["min"] ?? 0, message["min"] ?? 0,
message["max"] ?? 1, message["max"] ?? 1,
message["type"], message["type"],
); );
}
case "cookie": case "cookie":
{
return handleCookieCallback(Map.from(message)); return handleCookieCallback(Map.from(message));
}
case "uuid": case "uuid":
{
return const Uuid().v1(); return const Uuid().v1();
}
case "load_setting": case "load_setting":
{
String key = message["key"]; String key = message["key"];
String settingKey = message["setting_key"]; String settingKey = message["setting_key"];
var source = ComicSource.find(key)!; var source = ComicSource.find(key)!;
return source.data["settings"]?[settingKey] ?? return source.data["settings"]?[settingKey] ??
source.settings?[settingKey]['default'] ?? source.settings?[settingKey]!['default'] ??
(throw "Setting not found: $settingKey"); (throw "Setting not found: $settingKey");
}
case "isLogged": case "isLogged":
{
return ComicSource.find(message["key"])!.isLogged; return ComicSource.find(message["key"])!.isLogged;
} // temporary solution for [setTimeout] function
// TODO: implement [setTimeout] in quickjs project
case "delay":
return Future.delayed(Duration(milliseconds: message["time"]));
case "UI":
handleUIMessage(Map.from(message));
} }
} }
return null; return null;
@@ -198,7 +188,8 @@ class JsEngine with _JSEngineApi {
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy"; ..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
}, },
); );
dio.interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); dio.interceptors
.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
dio.interceptors.add(LogInterceptor()); dio.interceptors.add(LogInterceptor());
} }
response = await dio!.request(req["url"], response = await dio!.request(req["url"],
@@ -682,3 +673,62 @@ class DocumentWrapper {
return elements.length - 1; return elements.length - 1;
} }
} }
class JSAutoFreeFunction {
final JSInvokable func;
/// Automatically free the function when it's not used anymore
JSAutoFreeFunction(this.func) {
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

@@ -36,6 +36,8 @@ class LocalComic with HistoryMixin implements Comic {
/// chapter id is the name of the directory in `LocalManager.path/$directory` /// chapter id is the name of the directory in `LocalManager.path/$directory`
final Map<String, String>? chapters; final Map<String, String>? chapters;
bool get hasChapters => chapters != null;
/// relative path to the cover image /// relative path to the cover image
@override @override
final String cover; final String cover;
@@ -71,20 +73,21 @@ class LocalComic with HistoryMixin implements Comic {
downloadedChapters = List.from(jsonDecode(row[8] as String)), downloadedChapters = List.from(jsonDecode(row[8] as String)),
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int); createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
File get coverFile => openFilePlatform(FilePath.join( File get coverFile => File(FilePath.join(
baseDir, baseDir,
cover, cover,
)); ));
String get baseDir => directory.contains("/") ? directory : FilePath.join(LocalManager().path, directory); String get baseDir => (directory.contains('/') || directory.contains('\\'))
? directory
: FilePath.join(LocalManager().path, directory);
@override @override
String get description => ""; String get description => "";
@override @override
String get sourceKey => comicType == ComicType.local String get sourceKey =>
? "local" comicType == ComicType.local ? "local" : comicType.sourceKey;
: comicType.sourceKey;
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@@ -112,11 +115,14 @@ class LocalComic with HistoryMixin implements Comic {
chapters: chapters, chapters: chapters,
initialChapter: history?.ep, initialChapter: history?.ep,
initialPage: history?.page, initialPage: history?.page,
history: history ?? History.fromModel( history: history ??
History.fromModel(
model: this, model: this,
ep: 0, ep: 0,
page: 0, page: 0,
), ),
author: subtitle,
tags: tags,
), ),
); );
} }
@@ -151,6 +157,17 @@ class LocalManager with ChangeNotifier {
/// path to the directory where all the comics are stored /// path to the directory where all the comics are stored
late String path; late String path;
Directory get directory => Directory(path);
void _checkNoMedia() {
if (App.isAndroid) {
var file = File(FilePath.join(path, '.nomedia'));
if (!file.existsSync()) {
file.createSync();
}
}
}
// return error message if failed // return error message if failed
Future<String?> setNewPath(String newPath) async { Future<String?> setNewPath(String newPath) async {
var newDir = Directory(newPath); var newDir = Directory(newPath);
@@ -162,16 +179,18 @@ class LocalManager with ChangeNotifier {
} }
try { try {
await copyDirectoryIsolate( await copyDirectoryIsolate(
Directory(path), directory,
newDir, newDir,
); );
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(newPath); await File(FilePath.join(App.dataPath, 'local_path'))
.writeAsString(newPath);
} catch (e, s) { } catch (e, s) {
Log.error("IO", e, s); Log.error("IO", e, s);
return e.toString(); return e.toString();
} }
await Directory(path).deleteIgnoreError(recursive:true); await directory.deleteContents(recursive: true);
path = newPath; path = newPath;
_checkNoMedia();
return null; return null;
} }
@@ -185,7 +204,8 @@ class LocalManager with ChangeNotifier {
} }
} else if (App.isIOS) { } else if (App.isIOS) {
var oldPath = FilePath.join(App.dataPath, 'local'); var oldPath = FilePath.join(App.dataPath, 'local');
if (Directory(oldPath).existsSync() && Directory(oldPath).listSync().isNotEmpty) { if (Directory(oldPath).existsSync() &&
Directory(oldPath).listSync().isNotEmpty) {
return oldPath; return oldPath;
} else { } else {
var directory = await getApplicationDocumentsDirectory(); var directory = await getApplicationDocumentsDirectory();
@@ -196,6 +216,18 @@ class LocalManager with ChangeNotifier {
} }
} }
Future<void> _checkPathValidation() async {
var testFile = File(FilePath.join(path, 'venera_test'));
try {
testFile.createSync();
testFile.deleteSync();
} catch (e) {
Log.error("IO",
"Failed to create test file in local path: $e\nUsing default path instead.");
path = await findDefaultPath();
}
}
Future<void> init() async { Future<void> init() async {
_db = sqlite3.open( _db = sqlite3.open(
'${App.dataPath}/local.db', '${App.dataPath}/local.db',
@@ -217,20 +249,21 @@ class LocalManager with ChangeNotifier {
'''); ''');
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) { if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync(); path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
if (!Directory(path).existsSync()) { if (!directory.existsSync()) {
path = await findDefaultPath(); path = await findDefaultPath();
} }
} else { } else {
path = await findDefaultPath(); path = await findDefaultPath();
} }
try { try {
if (!Directory(path).existsSync()) { if (!directory.existsSync()) {
await Directory(path).create(); await directory.create();
} }
} } catch (e, s) {
catch(e, s) {
Log.error("IO", "Failed to create local folder: $e", s); Log.error("IO", "Failed to create local folder: $e", s);
} }
_checkPathValidation();
_checkNoMedia();
restoreDownloadingTasks(); restoreDownloadingTasks();
} }
@@ -240,7 +273,8 @@ class LocalManager with ChangeNotifier {
SELECT id FROM comics WHERE comic_type = ? SELECT id FROM comics WHERE comic_type = ?
ORDER BY CAST(id AS INTEGER) DESC ORDER BY CAST(id AS INTEGER) DESC
LIMIT 1; LIMIT 1;
''', [type.value], ''',
[type.value],
); );
if (res.isEmpty) { if (res.isEmpty) {
return '1'; return '1';
@@ -354,12 +388,11 @@ class LocalManager with ChangeNotifier {
throw "Invalid ep"; throw "Invalid ep";
} }
var comic = find(id, type) ?? (throw "Comic Not Found"); var comic = find(id, type) ?? (throw "Comic Not Found");
var directory = openDirectoryPlatform(comic.baseDir); var directory = Directory(comic.baseDir);
if (comic.chapters != null) { if (comic.hasChapters) {
var cid = ep is int var cid =
? comic.chapters!.keys.elementAt(ep - 1) ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
: (ep as String); directory = Directory(FilePath.join(directory.path, cid));
directory = openDirectoryPlatform(FilePath.join(directory.path, cid));
} }
var files = <File>[]; var files = <File>[];
await for (var entity in directory.list()) { await for (var entity in directory.list()) {
@@ -387,7 +420,7 @@ class LocalManager with ChangeNotifier {
return files.map((e) => "file://${e.path}").toList(); return files.map((e) => "file://${e.path}").toList();
} }
Future<bool> isDownloaded(String id, ComicType type, [int? ep]) async { bool isDownloaded(String id, ComicType type, [int? ep]) {
var comic = find(id, type); var comic = find(id, type);
if (comic == null) return false; if (comic == null) return false;
if (comic.chapters == null || ep == null) return true; if (comic.chapters == null || ep == null) return true;
@@ -406,10 +439,10 @@ class LocalManager with ChangeNotifier {
String id, ComicType type, String name) async { String id, ComicType type, String name) async {
var comic = find(id, type); var comic = find(id, type);
if (comic != null) { if (comic != null) {
return openDirectoryPlatform(FilePath.join(path, comic.directory)); return Directory(FilePath.join(path, comic.directory));
} }
var dir = findValidDirectoryName(path, name); var dir = findValidDirectoryName(path, name);
return openDirectoryPlatform(FilePath.join(path, dir)).create().then((value) => value); return Directory(FilePath.join(path, dir)).create().then((value) => value);
} }
void completeTask(DownloadTask task) { void completeTask(DownloadTask task) {
@@ -449,6 +482,7 @@ class LocalManager with ChangeNotifier {
void restoreDownloadingTasks() { void restoreDownloadingTasks() {
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json')); var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
if (file.existsSync()) { if (file.existsSync()) {
try {
var tasks = jsonDecode(file.readAsStringSync()); var tasks = jsonDecode(file.readAsStringSync());
for (var e in tasks) { for (var e in tasks) {
var task = DownloadTask.fromJson(e); var task = DownloadTask.fromJson(e);
@@ -456,6 +490,10 @@ class LocalManager with ChangeNotifier {
downloadingTasks.add(task); downloadingTasks.add(task);
} }
} }
} catch (e) {
file.delete();
Log.error("LocalManager", "Failed to restore downloading tasks: $e");
}
} }
} }
@@ -468,10 +506,11 @@ class LocalManager with ChangeNotifier {
void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) { void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
if (removeFileOnDisk) { if (removeFileOnDisk) {
var dir = openDirectoryPlatform(FilePath.join(path, c.directory)); var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true); dir.deleteIgnoreError(recursive: true);
} }
// Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted. // Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
if (c.comicType == ComicType.local) {
if (HistoryManager().findSync(c.id, c.comicType) != null) { if (HistoryManager().findSync(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType); HistoryManager().remove(c.id, c.comicType);
} }
@@ -479,6 +518,7 @@ class LocalManager with ChangeNotifier {
for (var f in folders) { for (var f in folders) {
LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType); LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType);
} }
}
remove(c.id, c.comicType); remove(c.id, c.comicType);
notifyListeners(); notifyListeners();
} }

View File

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

View File

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

View File

@@ -6,23 +6,37 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/js_engine.dart'; import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart'; import 'foundation/appdata.dart';
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 { Future<void> init() async {
await SAFTaskWorker().init(); await SAFTaskWorker().init().wait();
await AppTranslation.init(); await AppTranslation.init().wait();
await appdata.init(); await appdata.init().wait();
await App.init(); await App.init().wait();
await HistoryManager().init(); await HistoryManager().init().wait();
await LocalFavoritesManager().init(); await TagsTranslation.readData().wait();
await LocalFavoritesManager().init().wait();
SingleInstanceCookieJar("${App.dataPath}/cookie.db"); SingleInstanceCookieJar("${App.dataPath}/cookie.db");
await JsEngine().init(); await JsEngine().init().wait();
await ComicSource.init(); await ComicSource.init().wait();
await LocalManager().init(); await LocalManager().init().wait();
await TagsTranslation.readData();
CacheManager().setLimitSize(appdata.settings['cacheSize']); CacheManager().setLimitSize(appdata.settings['cacheSize']);
} }

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@@ -20,6 +21,7 @@ void main(List<String> args) {
if (runWebViewTitleBarWidget(args)) { if (runWebViewTitleBarWidget(args)) {
return; return;
} }
overrideIO(() {
runZonedGuarded(() async { runZonedGuarded(() async {
await Rhttp.init(); await Rhttp.init();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -55,6 +57,7 @@ void main(List<String> args) {
}, (error, stack) { }, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack"); Log.error("Unhandled Exception", "$error\n$stack");
}); });
});
} }
class MyApp extends StatefulWidget { class MyApp extends StatefulWidget {
@@ -126,6 +129,20 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
setState(() {}); setState(() {});
} }
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,
};
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget home; Widget home;
@@ -138,29 +155,41 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
} else { } else {
home = const MainPage(); home = const MainPage();
} }
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( return MaterialApp(
home: home, home: home,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: light,
seedColor: App.mainColor,
surface: Colors.white,
primary: App.mainColor.shade600,
// ignore: deprecated_member_use
background: Colors.white,
),
fontFamily: App.isWindows ? "Microsoft YaHei" : null, fontFamily: App.isWindows ? "Microsoft YaHei" : null,
), ),
navigatorKey: App.rootNavigatorKey, navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData( darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: dark,
seedColor: App.mainColor,
brightness: Brightness.dark,
surface: Colors.black,
primary: App.mainColor.shade400,
// ignore: deprecated_member_use
background: Colors.black,
),
fontFamily: App.isWindows ? "Microsoft YaHei" : null, fontFamily: App.isWindows ? "Microsoft YaHei" : null,
), ),
themeMode: switch (appdata.settings['theme_mode']) { themeMode: switch (appdata.settings['theme_mode']) {
@@ -192,8 +221,8 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
], ],
builder: (context, widget) { builder: (context, widget) {
ErrorWidget.builder = (details) { ErrorWidget.builder = (details) {
Log.error( Log.error("Unhandled Exception",
"Unhandled Exception", "${details.exception}\n${details.stack}"); "${details.exception}\n${details.stack}");
return Material( return Material(
child: Center( child: Center(
child: Text(details.exception.toString()), child: Text(details.exception.toString()),
@@ -222,6 +251,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
throw ('widget is null'); throw ('widget is null');
}, },
); );
});
} }
} }

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -109,7 +108,6 @@ class MyLogInterceptor implements Interceptor {
class AppDio with DioMixin { class AppDio with DioMixin {
String? _proxy = proxy; String? _proxy = proxy;
static bool get ignoreCertificateErrors => appdata.settings['ignoreCertificateErrors'] == true;
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
@@ -117,9 +115,6 @@ class AppDio with DioMixin {
proxySettings: proxy == null proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy() ? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!), : rhttp.ProxySettings.proxy(proxy!),
tlsSettings: rhttp.TlsSettings(
verifyCertificates: !ignoreCertificateErrors,
),
)); ));
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager()); interceptors.add(NetworkCacheManager());
@@ -197,9 +192,6 @@ class AppDio with DioMixin {
proxySettings: proxy == null proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy() ? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!), : rhttp.ProxySettings.proxy(proxy!),
tlsSettings: rhttp.TlsSettings(
verifyCertificates: !ignoreCertificateErrors,
),
)); ));
} }
try { try {
@@ -223,6 +215,22 @@ class AppDio with DioMixin {
class RHttpAdapter implements HttpClientAdapter { class RHttpAdapter implements HttpClientAdapter {
rhttp.ClientSettings settings; rhttp.ClientSettings 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()]) { RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
settings = settings.copyWith( settings = settings.copyWith(
redirectSettings: const rhttp.RedirectSettings.limited(5), redirectSettings: const rhttp.RedirectSettings.limited(5),
@@ -232,8 +240,9 @@ class RHttpAdapter implements HttpClientAdapter {
keepAlivePing: Duration(seconds: 30), keepAlivePing: Duration(seconds: 30),
), ),
throwOnStatusCode: false, throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings( tlsSettings: rhttp.TlsSettings(
verifyCertificates: !AppDio.ignoreCertificateErrors, sni: appdata.settings['sni'] != false,
), ),
); );
} }
@@ -281,13 +290,8 @@ class RHttpAdapter implements HttpClientAdapter {
headers[key] ??= []; headers[key] ??= [];
headers[key]!.add(entry.$2); headers[key]!.add(entry.$2);
} }
var data = res.body;
if (headers['content-encoding']?.contains('gzip') ?? false) {
// rhttp does not support gzip decoding
data = gzip.decoder.bind(data).map((data) => Uint8List.fromList(data));
}
return ResponseBody( return ResponseBody(
data, res.body,
res.statusCode, res.statusCode,
statusMessage: null, statusMessage: null,
isRedirect: false, isRedirect: false,

View File

@@ -42,6 +42,9 @@ class NetworkCacheManager implements Interceptor {
static const _maxCacheSize = 10 * 1024 * 1024; static const _maxCacheSize = 10 * 1024 * 1024;
void setCache(NetworkCache cache) { void setCache(NetworkCache cache) {
if (_cache.containsKey(cache.uri)) {
size -= _cache[cache.uri]!.size;
}
while (size > _maxCacheSize) { while (size > _maxCacheSize) {
size -= _cache.values.first.size; size -= _cache.values.first.size;
_cache.remove(_cache.keys.first); _cache.remove(_cache.keys.first);
@@ -94,7 +97,7 @@ class NetworkCacheManager implements Interceptor {
var time = DateTime.now(); var time = DateTime.now();
var diff = time.difference(cache.time); var diff = time.difference(cache.time);
if (options.headers['cache-time'] == 'long' && if (options.headers['cache-time'] == 'long' &&
diff < const Duration(hours: 2)) { diff < const Duration(hours: 6)) {
return handler.resolve(Response( return handler.resolve(Response(
requestOptions: options, requestOptions: options,
data: cache.data, data: cache.data,
@@ -110,7 +113,7 @@ class NetworkCacheManager implements Interceptor {
..set('venera-cache', 'true'), ..set('venera-cache', 'true'),
statusCode: 200, statusCode: 200,
)); ));
} else if (diff < const Duration(hours: 1)) { } else if (diff < const Duration(hours: 2)) {
var o = options.copyWith( var o = options.copyWith(
method: "HEAD", method: "HEAD",
); );
@@ -132,15 +135,42 @@ class NetworkCacheManager implements Interceptor {
} }
static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) { static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) {
a.remove('cache-time'); const shouldIgnore = [
a.remove('prevent-parallel'); 'cache-time',
b.remove('cache-time'); 'prevent-parallel',
b.remove('prevent-parallel'); 'date',
'x-varnish',
'cf-ray',
'connection',
'vary',
'content-encoding',
'report-to',
'server-timing',
'token',
'set-cookie',
'cf-cache-status',
'cf-request-id',
'cf-ray',
'authorization',
];
for (var key in shouldIgnore) {
a.remove(key);
b.remove(key);
}
if (a.length != b.length) { if (a.length != b.length) {
return false; return false;
} }
for (var key in a.keys) { for (var key in a.keys) {
if (a[key] != b[key]) { if (a[key] is List && b[key] is List) {
if (a[key].length != b[key].length) {
return false;
}
for (var i = 0; i < a[key].length; i++) {
if (a[key][i] != b[key][i]) {
return false;
}
}
} else if (a[key] != b[key]) {
return false; return false;
} }
} }
@@ -161,7 +191,7 @@ class NetworkCacheManager implements Interceptor {
var cache = NetworkCache( var cache = NetworkCache(
uri: response.requestOptions.uri, uri: response.requestOptions.uri,
requestHeaders: response.requestOptions.headers, requestHeaders: response.requestOptions.headers,
responseHeaders: response.headers.map, responseHeaders: Map.from(response.headers.map),
data: response.data, data: response.data,
time: DateTime.now(), time: DateTime.now(),
size: size, size: size,

View File

@@ -146,14 +146,19 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
String? _cover; String? _cover;
/// All images to download, key is chapter name
Map<String, List<String>>? _images; Map<String, List<String>>? _images;
/// Downloaded image count
int _downloadedCount = 0; int _downloadedCount = 0;
/// Total image count
int _totalCount = 0; int _totalCount = 0;
/// Current downloading image index
int _index = 0; int _index = 0;
/// Current downloading chapter, index of [_images]
int _chapter = 0; int _chapter = 0;
var tasks = <int, _ImageDownloadWrapper>{}; var tasks = <int, _ImageDownloadWrapper>{};
@@ -180,10 +185,10 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (comic!.chapters != null) { if (comic!.chapters != null) {
saveTo = Directory(FilePath.join( saveTo = Directory(FilePath.join(
path!, path!,
comic!.chapters!.keys.elementAt(_chapter), _images!.keys.elementAt(_chapter),
)); ));
if (!saveTo.existsSync()) { if (!saveTo.existsSync()) {
saveTo.createSync(); saveTo.createSync(recursive: true);
} }
} else { } else {
saveTo = Directory(path!); saveTo = Directory(path!);
@@ -235,21 +240,22 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} }
if (path == null) { if (path == null) {
try {
var dir = await LocalManager().findValidDirectory( var dir = await LocalManager().findValidDirectory(
comicId, comicId,
comicType, comicType,
comic!.title, comic!.title,
); );
if (!(await dir.exists())) { if (!(await dir.exists())) {
try {
await dir.create(); await dir.create();
} catch (e) { }
path = dir.path;
} catch (e, s) {
Log.error("Download", e.toString(), s);
_setError("Error: $e"); _setError("Error: $e");
return; return;
} }
} }
path = dir.path;
}
await LocalManager().saveCurrentDownloadingTasks(); await LocalManager().saveCurrentDownloadingTasks();
@@ -266,11 +272,13 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
throw "Failed to download cover"; throw "Failed to download cover";
} }
var fileType = detectFileType(data); var fileType = detectFileType(data);
var file = File(FilePath.join(path!, "cover${fileType.ext}")); var file =
File(FilePath.join(path!, "cover${fileType.ext}"));
file.writeAsBytesSync(data); file.writeAsBytesSync(data);
return "file://${file.path}"; return "file://${file.path}";
}); });
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -294,6 +302,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -323,6 +332,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -347,6 +357,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (task.error != null) { if (task.error != null) {
Log.error("Download", task.error.toString());
_setError("Error: ${task.error}"); _setError("Error: ${task.error}");
return; return;
} }
@@ -375,7 +386,6 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_message = message; _message = message;
notifyListeners(); notifyListeners();
stopRecorder(); stopRecorder();
Log.error("Download", message);
} }
@override @override
@@ -448,7 +458,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}).toList(), }).toList(),
directory: Directory(path!).name, directory: Directory(path!).name,
chapters: comic!.chapters, chapters: comic!.chapters,
cover: File(_cover!.split("file://").last).uri.pathSegments.last, cover:
File(_cover!.split("file://").last).name,
comicType: ComicType(source.key.hashCode), comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [], downloadedChapters: chapters ?? [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
@@ -726,8 +737,7 @@ class ArchiveDownloadTask extends DownloadTask {
isDownloaded = status.isFinished; isDownloaded = status.isFinished;
notifyListeners(); notifyListeners();
} }
} } catch (e) {
catch(e) {
_setError("Error: $e"); _setError("Error: $e");
return; return;
} }

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;
}

View File

@@ -53,6 +53,7 @@ class CategoriesPage extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
FilledTabBar( FilledTabBar(
key: PageStorageKey(categories.toString()),
tabs: categories.map((e) { tabs: categories.map((e) {
String title = e; String title = e;
try { try {
@@ -261,7 +262,7 @@ class _CategoryPage extends StatelessWidget {
builder: (context) { builder: (context) {
return Material( return Material(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
color: context.colorScheme.primaryContainer.withOpacity(0.72), color: context.colorScheme.primaryContainer.toOpacity(0.72),
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () => onClick(tag, param), onTap: () => onClick(tag, param),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shimmer_animation/shimmer_animation.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
@@ -26,12 +27,22 @@ import 'dart:math' as math;
import 'comments_page.dart'; import 'comments_page.dart';
class ComicPage extends StatefulWidget { 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 id;
final String sourceKey; final String sourceKey;
final String? cover;
final String? title;
@override @override
State<ComicPage> createState() => _ComicPageState(); State<ComicPage> createState() => _ComicPageState();
} }
@@ -55,13 +66,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override @override
Widget buildLoading() { Widget buildLoading() {
return Column( return _ComicPageLoadingPlaceHolder(
children: [ cover: widget.cover,
const Appbar(title: Text("")), title: widget.title,
Expanded( sourceKey: widget.sourceKey,
child: super.buildLoading(), cid: widget.id,
),
],
); );
} }
@@ -145,6 +154,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ep: 0, ep: 0,
page: 0, page: 0,
), ),
author: localComic.subTitle ?? '',
tags: localComic.tags,
); );
}); });
App.mainNavigatorKey!.currentContext!.pop(); App.mainNavigatorKey!.currentContext!.pop();
@@ -172,7 +183,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
isLiked = comic.isLiked ?? false; isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false; isFavorite = comic.isFavorite ?? false;
if (comic.chapters == null) { if (comic.chapters == null) {
isDownloaded = await LocalManager().isDownloaded( isDownloaded = LocalManager().isDownloaded(
comic.id, comic.id,
comic.comicType, comic.comicType,
0, 0,
@@ -199,23 +210,34 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 16), const SizedBox(width: 16),
Container( Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.primaryContainer, color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
), ),
height: 144, height: 144,
width: 144 * 0.72, width: 144 * 0.72,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: AnimatedImage( child: AnimatedImage(
image: CachedImageProvider( image: CachedImageProvider(
comic.cover, widget.cover ?? comic.cover,
sourceKey: comic.sourceKey, sourceKey: comic.sourceKey,
cid: comic.id,
), ),
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
), ),
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
@@ -223,7 +245,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
children: [ children: [
SelectableText(comic.title, style: ts.s18), SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null) if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14), SelectableText(comic.subTitle!, style: ts.s14)
.paddingVertical(4),
Text( Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '', (ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12, style: ts.s12,
@@ -288,11 +311,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
onLongPressed: quickFavorite, onLongPressed: quickFavorite,
iconColor: context.useTextColor(Colors.purple), iconColor: context.useTextColor(Colors.purple),
), ),
if (comicSource.commentsLoader != null && if (comicSource.commentsLoader != null)
(comic.comments == null || comic.comments!.isEmpty))
_ActionButton( _ActionButton(
icon: const Icon(Icons.comment), icon: const Icon(Icons.comment),
text: (comic.commentsCount ?? 'Comments'.tl).toString(), text: (comic.commentCount ?? 'Comments'.tl).toString(),
onPressed: showComments, onPressed: showComments,
iconColor: context.useTextColor(Colors.green), iconColor: context.useTextColor(Colors.green),
), ),
@@ -663,6 +685,8 @@ abstract mixin class _ComicPageActions {
initialChapter: ep, initialChapter: ep,
initialPage: page, initialPage: page,
history: History.fromModel(model: comic, ep: 0, page: 0), history: History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '',
tags: comic.plainTags,
), ),
); );
} }
@@ -679,7 +703,7 @@ abstract mixin class _ComicPageActions {
return; return;
} }
if (comic.chapters == null && 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); App.rootContext.showMessage(message: "The comic is downloaded".tl);
return; return;
} }
@@ -1116,14 +1140,12 @@ class _ComicChaptersState extends State<_ComicChapters> {
(state.history?.readEpisode ?? const {}).contains(i + 1); (state.history?.readEpisode ?? const {}).contains(i + 1);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Material( child: Material(
elevation: 5, color: context.colorScheme.surfaceContainer,
color: context.colorScheme.surface, borderRadius: const BorderRadius.all(Radius.circular(12)),
surfaceTintColor: context.colorScheme.surfaceTint, child: InkWell(
onTap: () => state.read(i + 1),
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: const BorderRadius.all(Radius.circular(12)),
shadowColor: Colors.transparent,
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4), const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -1134,19 +1156,18 @@ class _ComicChaptersState extends State<_ComicChapters> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: color: visited ? context.colorScheme.outline : null,
visited ? context.colorScheme.outline : null), ),
), ),
), ),
), ),
), ),
onTap: () => state.read(i + 1),
), ),
); );
}), }),
gridDelegate: const SliverGridDelegateWithFixedHeight( gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, itemHeight: 48), maxCrossAxisExtent: 200, itemHeight: 48),
), ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
if (eps.length > 20 && !showAll) if (eps.length > 20 && !showAll)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Align( child: Align(
@@ -1220,10 +1241,12 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
} else { } else {
error = res.errorMessage; error = res.errorMessage;
} }
if (mounted) {
setState(() { setState(() {
isLoading = false; isLoading = false;
}); });
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1329,9 +1352,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
), ),
) )
else if (isLoading) else if (isLoading)
const SliverToBoxAdapter( const SliverListLoadingIndicator(),
child: ListLoadingIndicator(),
),
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Divider(), child: Divider(),
), ),
@@ -1947,3 +1968,124 @@ class _CommentWidget extends StatelessWidget {
); );
} }
} }
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 { class ComicSourcePage extends StatefulWidget {
const ComicSourcePage({super.key}); const ComicSourcePage({super.key});
static Future<void> checkComicSourceUpdate([bool implicit = false]) async { static Future<int> checkComicSourceUpdate() async {
if (ComicSource.all().isEmpty) { if (ComicSource.all().isEmpty) {
return; return 0;
} }
var controller = implicit ? null : showLoadingDialog(App.rootContext);
var dio = AppDio(); var dio = AppDio();
var res = await dio.get<String>( var res = await dio.get<String>(
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json"); "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
if (res.statusCode != 200) { if (res.statusCode != 200) {
App.rootContext.showMessage(message: "Network error".tl); return -1;
return;
} }
var list = jsonDecode(res.data!) as List; var list = jsonDecode(res.data!) as List;
var versions = <String, String>{}; var versions = <String, String>{};
@@ -34,34 +32,17 @@ class ComicSourcePage extends StatefulWidget {
var shouldUpdate = <String>[]; var shouldUpdate = <String>[];
for (var source in ComicSource.all()) { for (var source in ComicSource.all()) {
if (versions.containsKey(source.key) && if (versions.containsKey(source.key) &&
versions[source.key] != source.version) { compareSemVer(versions[source.key]!, source.version)) {
shouldUpdate.add(source.key); shouldUpdate.add(source.key);
} }
} }
controller?.close(); if (shouldUpdate.isNotEmpty) {
if (shouldUpdate.isEmpty) {
if (!implicit) {
App.rootContext.showMessage(message: "No Update Available".tl);
}
return;
}
var msg = "";
for (var key in shouldUpdate) { for (var key in shouldUpdate) {
msg += "${ComicSource.find(key)?.name}: v${versions[key]}\n"; ComicSource.availableUpdates[key] = versions[key]!;
} }
msg = msg.trim(); ComicSource.notifyListeners();
await showConfirmDialog(
context: App.rootContext,
title: "Updates Available".tl,
content: msg,
confirmText: "Update",
onConfirm: () async {
for (var key in shouldUpdate) {
var source = ComicSource.find(key);
await _BodyState.update(source!);
} }
}, return shouldUpdate.length;
);
} }
@override @override
@@ -72,9 +53,6 @@ class _ComicSourcePageState extends State<ComicSourcePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: Appbar(
title: Text('Comic Source'.tl),
),
body: const _Body(), body: const _Body(),
); );
} }
@@ -90,10 +68,30 @@ class _Body extends StatefulWidget {
class _BodyState extends State<_Body> { class _BodyState extends State<_Body> {
var url = ""; var url = "";
void updateUI() {
setState(() {});
}
@override
void initState() {
super.initState();
ComicSource.addListener(updateUI);
}
@override
void dispose() {
super.dispose();
ComicSource.removeListener(updateUI);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SmoothCustomScrollView( return SmoothCustomScrollView(
slivers: [ slivers: [
SliverAppbar(
title: Text('Comic Source'.tl),
style: AppbarStyle.shadow,
),
buildCard(context), buildCard(context),
for (var source in ComicSource.all()) buildSource(context, source), for (var source in ComicSource.all()) buildSource(context, source),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
@@ -102,16 +100,39 @@ class _BodyState extends State<_Body> {
} }
Widget buildSource(BuildContext context, ComicSource source) { Widget buildSource(BuildContext context, ComicSource source) {
var newVersion = ComicSource.availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Column( child: Column(
children: [ children: [
const Divider(), const Divider(),
ListTile( 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( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (App.isDesktop)
Tooltip( Tooltip(
message: "Edit".tl, message: "Edit".tl,
child: IconButton( child: IconButton(
@@ -163,6 +184,10 @@ class _BodyState extends State<_Body> {
break; break;
} }
} }
} else {
current = item.value['options']
.firstWhere((e) => e['value'] == current)['text'] ??
current;
} }
yield ListTile( yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)), title: Text((item.value['title'] as String).ts(source.key)),
@@ -220,6 +245,8 @@ 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"); Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
@@ -246,6 +273,7 @@ class _BodyState extends State<_Body> {
} }
void edit(ComicSource source) async { void edit(ComicSource source) async {
if (App.isDesktop) {
try { try {
await Process.run("code", [source.filePath], runInShell: true); await Process.run("code", [source.filePath], runInShell: true);
await showDialog( await showDialog(
@@ -263,11 +291,18 @@ class _BodyState extends State<_Body> {
}, },
child: const Text("continue")), child: const Text("continue")),
], ],
)); ),
);
return;
} catch (e) { } catch (e) {
context.showMessage(message: "Failed to launch vscode"); //
} }
} }
context.to(() => _EditFilePage(source.filePath)).then((value) async {
await ComicSource.reload();
setState(() {});
});
}
static Future<void> update(ComicSource source) async { static Future<void> update(ComicSource source) async {
if (!source.url.isURL) { if (!source.url.isURL) {
@@ -288,6 +323,9 @@ class _BodyState extends State<_Body> {
controller.close(); controller.close();
await ComicSourceParser().parse(res.data!, source.filePath); await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!); await File(source.filePath).writeAsString(res.data!);
if (ComicSource.availableUpdates.containsKey(source.key)) {
ComicSource.availableUpdates.remove(source.key);
}
} catch (e) { } catch (e) {
if (cancel) return; if (cancel) return;
App.rootContext.showMessage(message: e.toString()); App.rootContext.showMessage(message: e.toString());
@@ -297,12 +335,14 @@ class _BodyState extends State<_Body> {
} }
Widget buildCard(BuildContext context) { Widget buildCard(BuildContext context) {
Widget buildButton({required Widget child, required VoidCallback onPressed}) { Widget buildButton(
{required Widget child, required VoidCallback onPressed}) {
return Button.normal( return Button.normal(
onPressed: onPressed, onPressed: onPressed,
child: child, child: child,
).fixHeight(32); ).fixHeight(32);
} }
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
@@ -355,10 +395,7 @@ class _BodyState extends State<_Body> {
), ),
ListTile( ListTile(
title: Text("Check updates".tl), title: Text("Check updates".tl),
trailing: buildButton( trailing: _CheckUpdatesButton(),
onPressed: () => ComicSourcePage.checkComicSourceUpdate(false),
child: Text("Check".tl),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
@@ -558,3 +595,136 @@ void _addAllPagesWithComicSource(ComicSource source) {
appdata.saveData(); 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

@@ -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 @override
void initState() { void initState() {
pages = List<String>.from(appdata.settings["explore_pages"]); pages = List<String>.from(appdata.settings["explore_pages"]);
@@ -59,13 +71,21 @@ class _ExplorePageState extends State<ExplorePage>
vsync: this, vsync: this,
); );
appdata.settings.addListener(onSettingsChanged); appdata.settings.addListener(onSettingsChanged);
NaviPane.of(context).addNaviItemTapListener(onNaviItemTapped);
super.initState(); super.initState();
} }
@override
void didChangeDependencies() {
naviPane = NaviPane.of(context);
super.didChangeDependencies();
}
@override @override
void dispose() { void dispose() {
controller.dispose(); controller.dispose();
appdata.settings.removeListener(onSettingsChanged); appdata.settings.removeListener(onSettingsChanged);
naviPane?.removeNaviItemTapListener(onNaviItemTapped);
super.dispose(); super.dispose();
} }
@@ -90,7 +110,9 @@ class _ExplorePageState extends State<ExplorePage>
return Tab(text: i.ts(comicSource.key), key: Key(i)); 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() { Widget buildEmpty() {
var msg = "No Explore Pages".tl; var msg = "No Explore Pages".tl;
@@ -127,7 +149,7 @@ class _ExplorePageState extends State<ExplorePage>
Widget tabBar = Material( Widget tabBar = Material(
child: FilledTabBar( child: FilledTabBar(
key: Key(pages.toString()), key: PageStorageKey(pages.toString()),
tabs: pages.map((e) => buildTab(e)).toList(), tabs: pages.map((e) => buildTab(e)).toList(),
controller: controller, controller: controller,
), ),
@@ -220,18 +242,14 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
with AutomaticKeepAliveClientMixin<_SingleExplorePage> { with AutomaticKeepAliveClientMixin<_SingleExplorePage> {
late final ExplorePageData data; late final ExplorePageData data;
bool loading = true;
String? message;
List<ExplorePagePart>? parts;
late final String comicSourceKey; late final String comicSourceKey;
int key = 0;
bool _wantKeepAlive = true; bool _wantKeepAlive = true;
var scrollController = ScrollController();
VoidCallback? refreshHandler;
void onSettingsChanged() { void onSettingsChanged() {
var explorePages = appdata.settings["explore_pages"]; var explorePages = appdata.settings["explore_pages"];
if (!explorePages.contains(widget.title)) { if (!explorePages.contains(widget.title)) {
@@ -266,14 +284,35 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
if (data.loadMultiPart != null) { 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) { } 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) { } else if (data.loadMixed != null) {
return _MixedExplorePage( return _MixedExplorePage(
data, data,
comicSourceKey, comicSourceKey,
key: ValueKey(key), key: const PageStorageKey("comic_list"),
controller: scrollController,
refreshHandlerCallback: (c) {
refreshHandler = c;
},
); );
} else { } else {
return const Center( return const Center(
@@ -282,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 @override
Object? get tag => widget.title; Object? get tag => widget.title;
@override @override
void refresh() { void refresh() {
message = null; refreshHandler?.call();
if (data.loadMultiPart != null) {
setState(() {
loading = true;
});
} else {
setState(() {
key++;
});
}
} }
@override @override
bool get wantKeepAlive => _wantKeepAlive; 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 { 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 ExplorePageData data;
final String sourceKey; final String sourceKey;
final ScrollController? controller;
final void Function(VoidCallback c) refreshHandlerCallback;
@override @override
State<_MixedExplorePage> createState() => _MixedExplorePageState(); State<_MixedExplorePage> createState() => _MixedExplorePageState();
} }
class _MixedExplorePageState class _MixedExplorePageState
extends MultiPageLoadingState<_MixedExplorePage, Object> { extends MultiPageLoadingState<_MixedExplorePage, Object> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
widget.refreshHandlerCallback(refresh);
}
void refresh() {
reset();
}
Iterable<Widget> buildSlivers(BuildContext context, List<Object> data) sync* { Iterable<Widget> buildSlivers(BuildContext context, List<Object> data) sync* {
List<Comic> cache = []; List<Comic> cache = [];
for (var part in data) { for (var part in data) {
@@ -394,9 +401,10 @@ class _MixedExplorePageState
@override @override
Widget buildContent(BuildContext context, List<Object> data) { Widget buildContent(BuildContext context, List<Object> data) {
return SmoothCustomScrollView( return SmoothCustomScrollView(
controller: widget.controller,
slivers: [ slivers: [
...buildSlivers(context, data), ...buildSlivers(context, data),
if (haveNextPage) const ListLoadingIndicator().toSliver() const SliverListLoadingIndicator(),
], ],
); );
} }
@@ -477,3 +485,125 @@ Iterable<Widget> _buildExplorePagePart(
yield buildTitle(part); yield buildTitle(part);
yield buildComics(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

@@ -77,13 +77,13 @@ String? validateFolderName(String newFolderName) {
return null; return null;
} }
void addFavorite(Comic comic) { void addFavorite(List<Comic> comics) {
var folders = LocalFavoritesManager().folderNames; var folders = LocalFavoritesManager().folderNames;
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {
String? selectedFolder; String? selectedFolder = appdata.settings['quickFavorite'];
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
@@ -105,6 +105,7 @@ void addFavorite(Comic comic) {
FilledButton( FilledButton(
onPressed: () { onPressed: () {
if (selectedFolder != null) { if (selectedFolder != null) {
for (var comic in comics) {
LocalFavoritesManager().addComic( LocalFavoritesManager().addComic(
selectedFolder!, selectedFolder!,
FavoriteItem( FavoriteItem(
@@ -118,6 +119,7 @@ void addFavorite(Comic comic) {
tags: comic.tags ?? [], tags: comic.tags ?? [],
), ),
); );
}
context.pop(); context.pop();
} }
}, },
@@ -144,6 +146,18 @@ Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var newInfo = (await comicSource.loadComicInfo!(c.id)).data; 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( comics[index] = FavoriteItem(
id: c.id, id: c.id,
name: newInfo.title, name: newInfo.title,
@@ -152,7 +166,7 @@ Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
newInfo.tags['author']?.firstOrNull ?? newInfo.tags['author']?.firstOrNull ??
c.author, c.author,
type: c.type, type: c.type,
tags: c.tags, tags: newTags,
); );
LocalFavoritesManager().updateInfo(folder, comics[index]); LocalFavoritesManager().updateInfo(folder, comics[index]);
@@ -291,6 +305,7 @@ Future<void> sortFolders() async {
Future<void> importNetworkFolder( Future<void> importNetworkFolder(
String source, String source,
int updatePageNum,
String? folder, String? folder,
String? folderID, String? folderID,
) async { ) async {
@@ -318,37 +333,46 @@ Future<void> importNetworkFolder(
folderID ?? "", folderID ?? "",
); );
} }
bool isOldToNewSort = comicSource.favoriteData?.isOldToNewSort ?? false;
var current = 0; var current = 0;
int receivedComics = 0;
int requestCount = 0;
var isFinished = false; var isFinished = false;
int maxPage = 1;
List<FavoriteItem> comics = [];
String? next; String? next;
// 如果是从旧到新, 先取一下maxPage
if (isOldToNewSort) {
var res = await comicSource.favoriteData?.loadComic!(1, folderID);
maxPage = res?.subData ?? 1;
}
Future<void> fetchNext() async { Future<void> fetchNext() async {
var retry = 3; var retry = 3;
while (updatePageNum > requestCount && !isFinished) {
while (true) {
try { try {
if (comicSource.favoriteData?.loadComic != null) { if (comicSource.favoriteData?.loadComic != null) {
next ??= '1'; // 从旧到新的情况下, 假设有10页, 更新3页, 则从第8页开始, 8, 9, 10 三页
next ??=
isOldToNewSort ? (maxPage - updatePageNum + 1).toString() : '1';
var page = int.parse(next!); var page = int.parse(next!);
var res = await comicSource.favoriteData!.loadComic!(page, folderID); var res = await comicSource.favoriteData!.loadComic!(page, folderID);
var count = 0; var count = 0;
receivedComics += res.data.length;
for (var c in res.data) { for (var c in res.data) {
var result = LocalFavoritesManager().addComic( if (!LocalFavoritesManager()
resultName, .comicExists(resultName, c.id, ComicType(source.hashCode))) {
FavoriteItem( count++;
comics.add(FavoriteItem(
id: c.id, id: c.id,
name: c.title, name: c.title,
coverPath: c.cover, coverPath: c.cover,
type: ComicType(source.hashCode), type: ComicType(source.hashCode),
author: c.subtitle ?? '', author: c.subtitle ?? '',
tags: c.tags ?? [], tags: c.tags ?? [],
), ));
);
if (result) {
count++;
} }
} }
requestCount++;
current += count; current += count;
if (res.data.isEmpty || res.subData == page) { if (res.data.isEmpty || res.subData == page) {
isFinished = true; isFinished = true;
@@ -359,22 +383,22 @@ Future<void> importNetworkFolder(
} else if (comicSource.favoriteData?.loadNext != null) { } else if (comicSource.favoriteData?.loadNext != null) {
var res = await comicSource.favoriteData!.loadNext!(next, folderID); var res = await comicSource.favoriteData!.loadNext!(next, folderID);
var count = 0; var count = 0;
receivedComics += res.data.length;
for (var c in res.data) { for (var c in res.data) {
var result = LocalFavoritesManager().addComic( if (!LocalFavoritesManager()
resultName, .comicExists(resultName, c.id, ComicType(source.hashCode))) {
FavoriteItem( count++;
comics.add(FavoriteItem(
id: c.id, id: c.id,
name: c.title, name: c.title,
coverPath: c.cover, coverPath: c.cover,
type: ComicType(source.hashCode), type: ComicType(source.hashCode),
author: c.subtitle ?? '', author: c.subtitle ?? '',
tags: c.tags ?? [], tags: c.tags ?? [],
), ));
);
if (result) {
count++;
} }
} }
requestCount++;
current += count; current += count;
if (res.data.isEmpty || res.subData == null) { if (res.data.isEmpty || res.subData == null) {
isFinished = true; isFinished = true;
@@ -394,6 +418,8 @@ Future<void> importNetworkFolder(
continue; continue;
} }
} }
// 跳出循环, 表示已经完成, 强制为 true, 避免死循环
isFinished = true;
} }
bool isCanceled = false; bool isCanceled = false;
@@ -401,6 +427,7 @@ Future<void> importNetworkFolder(
bool isErrored() => errorMsg != null; bool isErrored() => errorMsg != null;
void Function()? updateDialog; void Function()? updateDialog;
void Function()? closeDialog;
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
@@ -408,6 +435,7 @@ Future<void> importNetworkFolder(
return StatefulBuilder( return StatefulBuilder(
builder: (context, setState) { builder: (context, setState) {
updateDialog = () => setState(() {}); updateDialog = () => setState(() {});
closeDialog = () => Navigator.pop(context);
return ContentDialog( return ContentDialog(
title: isFinished title: isFinished
? "Finished".tl ? "Finished".tl
@@ -423,8 +451,11 @@ Future<void> importNetworkFolder(
value: isFinished ? 1 : null, value: isFinished ? 1 : null,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text("Imported @c comics".tlParams({ Text("Imported @a comics, loaded @b pages, received @c comics"
"c": current, .tlParams({
"a": current,
"b": requestCount,
"c": receivedComics,
})), })),
const SizedBox(height: 4), const SizedBox(height: 4),
if (isErrored()) Text("Error: $errorMsg"), if (isErrored()) Text("Error: $errorMsg"),
@@ -462,4 +493,18 @@ Future<void> importNetworkFolder(
break; 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

@@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
@@ -12,9 +11,11 @@ import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.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/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -95,7 +96,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
barrierDismissible: true, barrierDismissible: true,
fullscreenDialog: true, fullscreenDialog: true,
opaque: false, opaque: false,
barrierColor: Colors.black.withOpacity(0.36), barrierColor: Colors.black.toOpacity(0.36),
pageBuilder: (context, animation, secondary) { pageBuilder: (context, animation, secondary) {
return Align( return Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
@@ -153,14 +154,16 @@ class _FavoritesPageState extends State<FavoritesPage> {
); );
} }
if (!isNetwork) { if (!isNetwork) {
return _LocalFavoritesPage(folder: folder!, key: Key(folder!)); return _LocalFavoritesPage(
folder: folder!, key: PageStorageKey("local_$folder"));
} else { } else {
var favoriteData = getFavoriteDataOrNull(folder!); var favoriteData = getFavoriteDataOrNull(folder!);
if (favoriteData == null) { if (favoriteData == null) {
folder = null; folder = null;
return buildBody(); return buildBody();
} else { } else {
return NetworkFavoritePage(favoriteData, key: Key(folder!)); return NetworkFavoritePage(favoriteData,
key: PageStorageKey("network_$folder"));
} }
} }
} }

View File

@@ -38,7 +38,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}); });
} else { } else {
setState(() { setState(() {
comics = LocalFavoritesManager().search(keyword); comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword);
}); });
} }
} }
@@ -50,11 +50,16 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder); var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a; networkSource = a;
networkFolder = b; networkFolder = b;
LocalFavoritesManager().addListener(updateComics);
super.initState(); super.initState();
} }
@override @override
Widget build(BuildContext context) { void dispose() {
super.dispose();
LocalFavoritesManager().removeListener(updateComics);
}
void selectAll() { void selectAll() {
setState(() { setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
@@ -70,8 +75,47 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}); });
} }
var body = Scaffold( bool downloadComic(FavoriteItem c) {
body: SmoothCustomScrollView(slivers: [ 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) {
Widget body = SmoothCustomScrollView(
controller: scrollController,
slivers: [
if (!searchMode && !multiSelectMode) if (!searchMode && !multiSelectMode)
SliverAppbar( SliverAppbar(
style: context.width < changePoint style: context.width < changePoint
@@ -99,17 +143,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
message: "Sync".tl, message: "Sync".tl,
child: Flyout( child: Flyout(
flyoutBuilder: (context) { flyoutBuilder: (context) {
var sourceName = ComicSource.find(networkSource!)?.name ?? final GlobalKey<_SelectUpdatePageNumState>
networkSource!; selectUpdatePageNumKey =
var text = "The folder is Linked to @source".tlParams({ GlobalKey<_SelectUpdatePageNumState>();
"source": sourceName, var updatePageWidget = _SelectUpdatePageNum(
}); networkSource: networkSource!,
if (networkFolder != null && networkFolder!.isNotEmpty) { networkFolder: networkFolder,
text += "\n${"Source Folder".tl}: $networkFolder"; key: selectUpdatePageNumKey,
} );
return FlyoutContent( return FlyoutContent(
title: "Sync".tl, title: "Sync".tl,
content: Text(text), content: updatePageWidget,
actions: [ actions: [
Button.filled( Button.filled(
child: Text("Update".tl), child: Text("Update".tl),
@@ -117,6 +161,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
context.pop(); context.pop();
importNetworkFolder( importNetworkFolder(
networkSource!, networkSource!,
selectUpdatePageNumKey
.currentState!.updatePageNum,
widget.folder, widget.folder,
networkFolder!, networkFolder!,
).then( ).then(
@@ -300,6 +346,11 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}, },
); );
}), }),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
onClick: downloadSelected,
),
]), ]),
], ],
) )
@@ -336,8 +387,64 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
SliverGridComics( SliverGridComics(
comics: comics, comics: comics,
selections: selectedComics, selections: selectedComics,
onTap: multiSelectMode menuBuilder: (c) {
? (c) { return [
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
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(() { setState(() {
if (selectedComics.containsKey(c as FavoriteItem)) { if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c); selectedComics.remove(c);
@@ -347,10 +454,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} }
lastSelectedIndex = comics.indexOf(c); lastSelectedIndex = comics.indexOf(c);
}); });
} } else if (appdata.settings["onClickFavorite"] == "viewDetail") {
: (c) {
App.mainNavigatorKey?.currentContext App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey)); ?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
} else {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
}
}, },
onLongPressed: (c) { onLongPressed: (c) {
setState(() { setState(() {
@@ -387,7 +501,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}); });
}, },
), ),
]), ],
);
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( return PopScope(
canPop: !multiSelectMode && !searchMode, canPop: !multiSelectMode && !searchMode,
@@ -569,7 +693,6 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
(c as FavoriteItem).type, (c as FavoriteItem).type,
); );
} }
updateComics();
_cancel(); _cancel();
} }
} }
@@ -592,12 +715,19 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
late var comics = LocalFavoritesManager().getAllComics(widget.name); late var comics = LocalFavoritesManager().getAllComics(widget.name);
bool changed = false; bool changed = false;
Color lightenColor(Color color, double lightenValue) { static int _floatToInt8(double x) {
int red = (color.red + ((255 - color.red) * lightenValue)).round(); return (x * 255.0).round() & 0xff;
int green = (color.green + ((255 - color.green) * lightenValue)).round(); }
int blue = (color.blue + ((255 - color.blue) * lightenValue)).round();
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 @override
@@ -648,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, key: reorderWidgetKey,
scrollController: _scrollController, scrollController: _scrollController,
longPressDelay: App.isDesktop longPressDelay: App.isDesktop
@@ -659,14 +800,14 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
onReorder: (reorderFunc) { onReorder: (reorderFunc) {
changed = true; changed = true;
setState(() { setState(() {
comics = reorderFunc(comics) as List<FavoriteItem>; comics = reorderFunc(comics);
}); });
widget.onReorder(comics); widget.onReorder(comics);
}, },
dragChildBoxDecoration: BoxDecoration( dragChildBoxDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
color: lightenColor( color: lightenColor(
Theme.of(context).splashColor.withOpacity(1), Theme.of(context).splashColor.withAlpha(255),
0.2, 0.2,
), ),
), ),
@@ -683,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

@@ -20,8 +20,7 @@ Future<bool> _deleteComic(
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: "Remove".tl, title: "Remove".tl,
content: Text("Remove comic from favorite?".tl) content: Text("Remove comic from favorite?".tl).paddingHorizontal(16),
.paddingHorizontal(16),
actions: [ actions: [
Button.filled( Button.filled(
isLoading: loading, isLoading: loading,
@@ -94,9 +93,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
return ComicList( return ComicList(
key: comicListKey, key: comicListKey,
leadingSliver: SliverAppbar( leadingSliver: SliverAppbar(
style: context.width < changePoint style:
? AppbarStyle.shadow context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
: AppbarStyle.blur,
leading: Tooltip( leading: Tooltip(
message: "Folders".tl, message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth child: context.width <= _kTwoPanelChangeWidth
@@ -117,7 +115,7 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
icon: Icons.sync, icon: Icons.sync,
text: "Convert to local".tl, text: "Convert to local".tl,
onClick: () { onClick: () {
importNetworkFolder(widget.data.key, null, null); importNetworkFolder(widget.data.key, 9999999, null, null);
}, },
) )
]), ]),
@@ -166,6 +164,7 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
), ),
]; ];
}, },
enablePageStorage: true,
); );
} }
} }
@@ -214,9 +213,8 @@ class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var sliverAppBar = SliverAppbar( var sliverAppBar = SliverAppbar(
style: context.width < changePoint style:
? AppbarStyle.shadow context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
: AppbarStyle.blur,
leading: Tooltip( leading: Tooltip(
message: "Folders".tl, message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth child: context.width <= _kTwoPanelChangeWidth
@@ -430,8 +428,7 @@ class _FolderTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: "Delete".tl, title: "Delete".tl,
content: Text("Delete folder?".tl) content: Text("Delete folder?".tl).paddingHorizontal(16),
.paddingHorizontal(16),
actions: [ actions: [
Button.filled( Button.filled(
isLoading: loading, isLoading: loading,
@@ -548,6 +545,7 @@ class _FavoriteFolder extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ComicList( return ComicList(
key: comicListKey, key: comicListKey,
enablePageStorage: true,
leadingSliver: SliverAppbar( leadingSliver: SliverAppbar(
title: Text(title), title: Text(title),
actions: [ actions: [
@@ -556,7 +554,7 @@ class _FavoriteFolder extends StatelessWidget {
icon: Icons.sync, icon: Icons.sync,
text: "Convert to local".tl, text: "Convert to local".tl,
onClick: () { onClick: () {
importNetworkFolder(data.key, title, folderID); importNetworkFolder(data.key, 9999999, title, folderID);
}, },
) )
]), ]),

View File

@@ -179,7 +179,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? context.colorScheme.primaryContainer.withOpacity(0.36) ? context.colorScheme.primaryContainer.toOpacity(0.36)
: null, : null,
border: Border( border: Border(
left: BorderSide( left: BorderSide(
@@ -214,7 +214,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? context.colorScheme.primaryContainer.withOpacity(0.36) ? context.colorScheme.primaryContainer.toOpacity(0.36)
: null, : null,
border: Border( border: Border(
left: BorderSide( left: BorderSide(

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_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
class HistoryPage extends StatefulWidget { class HistoryPage extends StatefulWidget {
@@ -78,33 +76,7 @@ class _HistoryPageState extends State<HistoryPage> {
], ],
), ),
SliverGridComics( SliverGridComics(
comics: comics.map( comics: comics,
(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 == ComicType.local
? 'local'
: e.type.comicSource?.key ?? "Unknown:${e.type.value}",
null,
null,
);
},
).toList(),
badgeBuilder: (c) { badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name; return ComicSource.find(c.sourceKey)?.name;
}, },

View File

@@ -6,17 +6,18 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_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/pages/search_page.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/import_comic.dart'; import 'package:venera/utils/import_comic.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'local_comics_page.dart'; import 'local_comics_page.dart';
@@ -35,6 +36,7 @@ class HomePage extends StatelessWidget {
const _Local(), const _Local(),
const _ComicSourceWidget(), const _ComicSourceWidget(),
const _AccountsWidget(), const _AccountsWidget(),
const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
], ],
); );
@@ -53,7 +55,7 @@ class _SearchBar extends StatelessWidget {
width: double.infinity, width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Material( child: Material(
color: context.colorScheme.surfaceContainer, color: context.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
@@ -83,7 +85,8 @@ class _SyncDataWidget extends StatefulWidget {
State<_SyncDataWidget> createState() => _SyncDataWidgetState(); State<_SyncDataWidget> createState() => _SyncDataWidgetState();
} }
class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver { class _SyncDataWidgetState extends State<_SyncDataWidget>
with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -162,14 +165,12 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
icon: const Icon(Icons.cloud_upload_outlined), icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async { onPressed: () async {
DataSync().uploadData(); DataSync().uploadData();
} }),
),
IconButton( IconButton(
icon: const Icon(Icons.cloud_download_outlined), icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async { onPressed: () async {
DataSync().downloadData(); DataSync().downloadData();
} }),
),
], ],
), ),
), ),
@@ -264,22 +265,8 @@ class _HistoryState extends State<_History> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: history.length, itemCount: history.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
var cover = history[index].cover; return SimpleComicTile(
ImageProvider imageProvider = CachedImageProvider( comic: history[index],
cover,
sourceKey: history[index].type.comicSource?.key,
cid: history[index].id,
);
if (!cover.isURL) {
var localComic = LocalManager().find(
history[index].id,
history[index].type,
);
if (localComic != null) {
imageProvider = FileImage(localComic.coverFile);
}
}
return InkWell(
onTap: () { onTap: () {
context.to( context.to(
() => ComicPage( () => ComicPage(
@@ -288,27 +275,7 @@ class _HistoryState extends State<_History> {
), ),
); );
}, },
borderRadius: BorderRadius.circular(8), ).paddingHorizontal(8).paddingVertical(2);
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).paddingBottom(16), ).paddingHorizontal(8).paddingBottom(16),
@@ -401,33 +368,8 @@ class _LocalState extends State<_Local> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: local.length, itemCount: local.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
return InkWell( return SimpleComicTile(comic: local[index])
onTap: () { .paddingHorizontal(8);
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,
),
),
);
}, },
), ),
).paddingHorizontal(8), ).paddingHorizontal(8),
@@ -511,13 +453,15 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
String info = [ String info = [
"Select a directory which contains the comic files.".tl, "Select a directory which contains the comic files.".tl,
"Select a directory which contains the comic directories.".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 "Select an EhViewer database and a download folder.".tl
][type]; ][type];
List<String> importMethods = [ List<String> importMethods = [
"Single Comic".tl, "Single Comic".tl,
"Multiple Comics".tl, "Multiple Comics".tl,
"A cbz file".tl, "An archive file".tl,
"Multiple archive files".tl,
"EhViewer downloads".tl "EhViewer downloads".tl
]; ];
@@ -549,7 +493,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
}, },
); );
}), }),
if(type != 3) if (type != 4)
ListTile( ListTile(
title: Text("Add to favorites".tl), title: Text("Add to favorites".tl),
trailing: Select( trailing: Select(
@@ -563,7 +507,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
}, },
), ),
).paddingHorizontal(8), ).paddingHorizontal(8),
if(!App.isIOS && !App.isMacOS) if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
CheckboxListTile( CheckboxListTile(
enabled: true, enabled: true,
title: Text("Copy to app local path".tl), title: Text("Copy to app local path".tl),
@@ -593,7 +537,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
onPressed: () { onPressed: () {
showDialog( showDialog(
context: context, context: context,
barrierColor: Colors.black.withOpacity(0.2), barrierColor: Colors.black.toOpacity(0.2),
builder: (context) { builder: (context) {
var help = ''; var help = '';
help += help +=
@@ -606,7 +550,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
help += help +=
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" "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; .tl;
help +="If you import an EhViewer's database, program will automatically create folders according to the download label in that database.".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( return ContentDialog(
title: "Help".tl, title: "Help".tl,
content: Text(help).paddingHorizontal(16), content: Text(help).paddingHorizontal(16),
@@ -639,13 +585,13 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
loading = true; loading = true;
}); });
var importer = ImportComic( var importer = ImportComic(
selectedFolder: selectedFolder, selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder);
copyToLocal: copyToLocalFolder);
var result = switch (type) { var result = switch (type) {
0 => await importer.directory(true), 0 => await importer.directory(true),
1 => await importer.directory(false), 1 => await importer.directory(false),
2 => await importer.cbz(), 2 => await importer.cbz(),
3 => await importer.ehViewer(), 3 => await importer.multipleCbz(),
4 => await importer.ehViewer(),
int() => true, int() => true,
}; };
if (result) { if (result) {
@@ -750,6 +696,30 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}).toList(), }).toList(),
).paddingHorizontal(16).paddingBottom(16), ).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),
], ],
), ),
), ),
@@ -925,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);
}
}

View File

@@ -0,0 +1,539 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.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/consts.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/image_favorites_page/type.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
part "image_favorites_item.dart";
part "image_favorites_photo_view.dart";
class ImageFavoritesPage extends StatefulWidget {
const ImageFavoritesPage({super.key, this.initialKeyword});
final String? initialKeyword;
@override
State<ImageFavoritesPage> createState() => _ImageFavoritesPageState();
}
class _ImageFavoritesPageState extends State<ImageFavoritesPage> {
late ImageFavoriteSortType sortType;
late TimeRange timeFilterSelect;
late int numFilterSelect;
// 所有的图片收藏
List<ImageFavoritesComic> comics = [];
late var controller =
TextEditingController(text: widget.initialKeyword ?? "");
String get keyword => controller.text;
// 进入关键词搜索模式
bool searchMode = false;
bool multiSelectMode = false;
// 多选的时候选中的图片
Map<ImageFavorite, bool> selectedImageFavorites = {};
void update() {
if (mounted) {
setState(() {});
}
}
void updateImageFavorites() async {
comics = searchMode
? ImageFavoriteManager().search(keyword)
: ImageFavoriteManager().getAll();
sortImageFavorites();
update();
}
void sortImageFavorites() {
comics = searchMode
? ImageFavoriteManager().search(keyword)
: ImageFavoriteManager().getAll();
// 筛选到最终列表
comics = comics.where((ele) {
bool isFilter = true;
if (timeFilterSelect != TimeRange.all) {
isFilter = timeFilterSelect.contains(ele.time);
}
if (numFilterSelect != numFilterList[0]) {
isFilter = ele.images.length > numFilterSelect;
}
return isFilter;
}).toList();
// 给列表排序
switch (sortType) {
case ImageFavoriteSortType.title:
comics.sort((a, b) => a.title.compareTo(b.title));
case ImageFavoriteSortType.timeAsc:
comics.sort((a, b) => a.time.compareTo(b.time));
case ImageFavoriteSortType.timeDesc:
comics.sort((a, b) => b.time.compareTo(a.time));
case ImageFavoriteSortType.maxFavorites:
comics.sort((a, b) => b.images.length
.compareTo(a.images.length));
case ImageFavoriteSortType.favoritesCompareComicPages:
comics.sort((a, b) {
double tempA = a.images.length / a.maxPageFromEp;
double tempB = b.images.length / b.maxPageFromEp;
return tempB.compareTo(tempA);
});
}
}
@override
void initState() {
if (widget.initialKeyword != null) {
searchMode = true;
}
sortType = ImageFavoriteSortType.values.firstWhereOrNull(
(e) => e.value == appdata.implicitData["image_favorites_sort"]) ??
ImageFavoriteSortType.title;
timeFilterSelect = TimeRange.fromString(
appdata.implicitData["image_favorites_time_filter"]);
numFilterSelect = appdata.implicitData["image_favorites_number_filter"] ??
numFilterList[0];
updateImageFavorites();
ImageFavoriteManager().addListener(updateImageFavorites);
super.initState();
}
@override
void dispose() {
ImageFavoriteManager().removeListener(updateImageFavorites);
scrollController.dispose();
super.dispose();
}
Widget buildMultiSelectMenu() {
return MenuButton(entries: [
MenuEntry(
icon: Icons.delete_outline,
text: "Delete".tl,
onClick: () {
ImageFavoriteManager()
.deleteImageFavorite(selectedImageFavorites.keys);
setState(() {
multiSelectMode = false;
selectedImageFavorites.clear();
});
},
)
]);
}
var scrollController = ScrollController();
void selectAll() {
for (var c in comics) {
for (var i in c.images) {
selectedImageFavorites[i] = true;
}
}
update();
}
void deSelect() {
setState(() {
selectedImageFavorites.clear();
});
}
void addSelected(ImageFavorite i) {
if (selectedImageFavorites[i] == null) {
selectedImageFavorites[i] = true;
} else {
selectedImageFavorites.remove(i);
}
if (selectedImageFavorites.isEmpty) {
multiSelectMode = false;
} else {
multiSelectMode = true;
}
update();
}
@override
Widget build(BuildContext context) {
List<Widget> selectActions = [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: "Select All".tl,
onPressed: selectAll),
IconButton(
icon: const Icon(Icons.deselect),
tooltip: "Deselect".tl,
onPressed: deSelect),
buildMultiSelectMenu(),
];
var scrollWidget = SmoothCustomScrollView(
controller: scrollController,
slivers: [
if (!searchMode && !multiSelectMode)
SliverAppbar(
title: Text("Image Favorites".tl),
actions: [
Tooltip(
message: "Search".tl,
child: IconButton(
icon: const Icon(Icons.search),
onPressed: () {
setState(() {
searchMode = true;
});
},
),
),
Tooltip(
message: "Sort".tl,
child: IconButton(
isSelected: timeFilterSelect != TimeRange.all ||
numFilterSelect != numFilterList[0],
icon: const Icon(Icons.sort_rounded),
onPressed: sort,
),
),
Tooltip(
message: multiSelectMode
? "Exit Multi-Select".tl
: "Multi-Select".tl,
child: IconButton(
icon: const Icon(Icons.checklist),
onPressed: () {
setState(() {
multiSelectMode = !multiSelectMode;
});
},
),
),
],
)
else if (multiSelectMode)
SliverAppbar(
leading: Tooltip(
message: "Cancel".tl,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
multiSelectMode = false;
selectedImageFavorites.clear();
});
},
),
),
title: Text(selectedImageFavorites.length.toString()),
actions: selectActions,
)
else if (searchMode)
SliverAppbar(
leading: Tooltip(
message: "Cancel".tl,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
controller.clear();
setState(() {
searchMode = false;
controller.clear();
updateImageFavorites();
});
},
),
),
title: TextField(
autofocus: true,
controller: controller,
decoration: InputDecoration(
hintText: "Search".tl,
border: InputBorder.none,
),
onChanged: (v) {
updateImageFavorites();
},
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return _ImageFavoritesItem(
imageFavoritesComic: comics[index],
selectedImageFavorites: selectedImageFavorites,
addSelected: addSelected,
multiSelectMode: multiSelectMode,
finalImageFavoritesComicList: comics,
);
},
childCount: comics.length,
),
),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
],
);
Widget 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: context.width > changePoint
? scrollWidget.paddingHorizontal(8)
: scrollWidget,
),
);
return PopScope(
canPop: !multiSelectMode && !searchMode,
onPopInvokedWithResult: (didPop, result) {
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedImageFavorites.clear();
});
} else if (searchMode) {
controller.clear();
searchMode = false;
updateImageFavorites();
}
},
child: body,
);
}
void sort() {
showDialog(
context: context,
builder: (context) {
return _ImageFavoritesDialog(
initSortType: sortType,
initTimeFilterSelect: timeFilterSelect,
initNumFilterSelect: numFilterSelect,
updateConfig: (sortType, timeFilter, numFilter) {
setState(() {
this.sortType = sortType;
timeFilterSelect = timeFilter;
numFilterSelect = numFilter;
});
sortImageFavorites();
},
);
},
);
}
}
class _ImageFavoritesDialog extends StatefulWidget {
const _ImageFavoritesDialog({
required this.initSortType,
required this.initTimeFilterSelect,
required this.initNumFilterSelect,
required this.updateConfig,
});
final ImageFavoriteSortType initSortType;
final TimeRange initTimeFilterSelect;
final int initNumFilterSelect;
final Function updateConfig;
@override
State<_ImageFavoritesDialog> createState() => _ImageFavoritesDialogState();
}
class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
List<String> optionTypes = ['Sort', 'Filter'];
late var sortType = widget.initSortType;
late var numFilter = widget.initNumFilterSelect;
late TimeRangeType timeRangeType;
DateTime? start;
DateTime? end;
@override
void initState() {
super.initState();
timeRangeType = switch (widget.initTimeFilterSelect) {
TimeRange.all => TimeRangeType.all,
TimeRange.lastWeek => TimeRangeType.lastWeek,
TimeRange.lastMonth => TimeRangeType.lastMonth,
TimeRange.lastHalfYear => TimeRangeType.lastHalfYear,
TimeRange.lastYear => TimeRangeType.lastYear,
_ => TimeRangeType.custom,
};
if (timeRangeType == TimeRangeType.custom) {
end = widget.initTimeFilterSelect.end;
start = end!.subtract(widget.initTimeFilterSelect.duration);
}
}
@override
Widget build(BuildContext context) {
Widget tabBar = Material(
borderRadius: BorderRadius.circular(8),
child: FilledTabBar(
key: PageStorageKey(optionTypes),
tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(),
),
).paddingTop(context.padding.top);
return ContentDialog(
content: DefaultTabController(
length: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
tabBar,
TabViewBody(children: [
Column(
children: ImageFavoriteSortType.values
.map(
(e) => RadioListTile<ImageFavoriteSortType>(
title: Text(e.value.tl),
value: e,
groupValue: sortType,
onChanged: (v) {
setState(() {
sortType = v!;
});
},
),
)
.toList(),
),
Column(
children: [
ListTile(
title: Text("Time Filter".tl),
trailing: Select(
current: timeRangeType.value.tl,
values:
TimeRangeType.values.map((e) => e.value.tl).toList(),
minWidth: 64,
onTap: (index) {
setState(() {
timeRangeType = TimeRangeType.values[index];
});
},
),
),
if (timeRangeType == TimeRangeType.custom)
Column(
children: [
ListTile(
title: Text("Start Time".tl),
trailing: TextButton(
onPressed: () async {
final date = await showDatePicker(
context: context,
initialDate: start ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: end ?? DateTime.now(),
);
if (date != null) {
setState(() {
start = date;
});
}
},
child: Text(start == null
? "Select Date".tl
: DateFormat("yyyy-MM-dd").format(start!)),
),
),
ListTile(
title: Text("End Time".tl),
trailing: TextButton(
onPressed: () async {
final date = await showDatePicker(
context: context,
initialDate: end ?? DateTime.now(),
firstDate: start ?? DateTime(2000),
lastDate: DateTime.now(),
);
if (date != null) {
setState(() {
end = date;
});
}
},
child: Text(end == null
? "Select Date".tl
: DateFormat("yyyy-MM-dd").format(end!)),
),
),
],
),
ListTile(
title: Text("Image Favorites Greater Than".tl),
trailing: Select(
current: numFilter.toString(),
values: numFilterList.map((e) => e.toString()).toList(),
minWidth: 64,
onTap: (index) {
setState(() {
numFilter = numFilterList[index];
});
},
),
)
],
)
]),
],
),
),
actions: [
FilledButton(
onPressed: () {
appdata.implicitData["image_favorites_sort"] = sortType.value;
TimeRange timeRange;
if (timeRangeType == TimeRangeType.custom) {
timeRange = TimeRange(
end: end,
duration: end!.difference(start!),
);
} else {
timeRange = switch (timeRangeType) {
TimeRangeType.all => TimeRange.all,
TimeRangeType.lastWeek => TimeRange.lastWeek,
TimeRangeType.lastMonth => TimeRange.lastMonth,
TimeRangeType.lastHalfYear => TimeRange.lastHalfYear,
TimeRangeType.lastYear => TimeRange.lastYear,
_ => TimeRange.all,
};
}
appdata.implicitData["image_favorites_time_filter"] =
timeRange.toString();
appdata.implicitData["image_favorites_number_filter"] = numFilter;
appdata.writeImplicitData();
if (mounted) {
Navigator.pop(context);
widget.updateConfig(sortType, timeRange, numFilter);
}
},
child: Text("Confirm".tl),
),
],
);
}
}

View File

@@ -0,0 +1,253 @@
part of 'image_favorites_page.dart';
class ImageFavoritesPhotoView extends StatefulWidget {
const ImageFavoritesPhotoView({
super.key,
required this.comic,
required this.imageFavorite,
});
final ImageFavoritesComic comic;
final ImageFavorite imageFavorite;
@override
State<ImageFavoritesPhotoView> createState() =>
_ImageFavoritesPhotoViewState();
}
class _ImageFavoritesPhotoViewState extends State<ImageFavoritesPhotoView> {
late PageController controller;
Map<ImageFavorite, bool> cancelImageFavorites = {};
var images = <ImageFavorite>[];
int currentPage = 0;
bool isAppBarShow = false;
@override
void initState() {
var current = 0;
for (var ep in widget.comic.imageFavoritesEp) {
for (var image in ep.imageFavorites) {
images.add(image);
if (image == widget.imageFavorite) {
current = images.length - 1;
}
}
}
currentPage = current;
controller = PageController(initialPage: current);
super.initState();
}
void onPop() {
List<ImageFavorite> tempList = cancelImageFavorites.entries
.where((e) => e.value == true)
.map((e) => e.key)
.toList();
if (tempList.isNotEmpty) {
ImageFavoriteManager().deleteImageFavorite(tempList);
showToast(
message: "Delete @a images".tlParams({'a': tempList.length}),
context: context);
}
}
PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) {
var image = images[index];
return PhotoViewGalleryPageOptions(
// 图片加载器 支持本地、网络
imageProvider: ImageFavoritesProvider(image),
// 初始化大小 全部展示
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
onTapUp: (context, details, controllerValue) {
setState(() {
isAppBarShow = !isAppBarShow;
});
},
heroAttributes: PhotoViewHeroAttributes(
tag: "${image.sourceKey}${image.ep}${image.page}",
),
);
}
@override
Widget build(BuildContext context) {
return PopScope(
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) {
onPop();
}
},
child: Listener(
onPointerSignal: (event) {
if (HardwareKeyboard.instance.isControlPressed) {
return;
}
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) {
if (controller.page! >= images.length - 1) {
return;
}
controller.nextPage(
duration: Duration(milliseconds: 180), curve: Curves.ease);
} else {
if (controller.page! <= 0) {
return;
}
controller.previousPage(
duration: Duration(milliseconds: 180), curve: Curves.ease);
}
}
},
child: Stack(children: [
Positioned.fill(
child: PhotoViewGallery.builder(
backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface,
),
builder: _buildItem,
itemCount: images.length,
loadingBuilder: (context, event) => Center(
child: SizedBox(
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
backgroundColor: context.colorScheme.surfaceContainerHigh,
value: event == null || event.expectedTotalBytes == null
? null
: event.cumulativeBytesLoaded /
event.expectedTotalBytes!,
),
),
),
pageController: controller,
onPageChanged: (index) {
setState(() {
currentPage = index;
});
},
),
),
buildPageInfo(),
AnimatedPositioned(
top: isAppBarShow ? 0 : -(context.padding.top + 52),
left: 0,
right: 0,
duration: Duration(milliseconds: 180),
child: buildAppBar(),
),
]),
),
);
}
Widget buildPageInfo() {
var text = "${currentPage + 1}/${images.length}";
return Positioned(
height: 40,
left: 0,
right: 0,
bottom: 0,
child: Center(
child: Stack(
children: [
Text(
text,
style: TextStyle(
fontSize: 14,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.4
..color = context.colorScheme.onInverseSurface,
),
),
Text(text),
],
),
),
);
}
Widget buildAppBar() {
return Material(
color: context.colorScheme.surface.toOpacity(0.72),
child: BlurEffect(
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.5,
),
),
),
height: 52,
child: Row(
children: [
const SizedBox(width: 8),
IconButton(
icon: Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
},
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.comic.title,
style: TextStyle(fontSize: 18),
),
),
IconButton(
icon: Icon(Icons.more_vert),
onPressed: showMenu,
),
const SizedBox(width: 8),
],
),
).paddingTop(context.padding.top),
),
);
}
void showMenu() {
showMenuX(
context,
Offset(context.width, context.padding.top),
[
MenuEntry(
icon: Icons.image_outlined,
text: "Save Image".tl,
onClick: () async {
var temp = images[currentPage];
var imageProvider = ImageFavoritesProvider(temp);
var data = await imageProvider.load(null, null);
var fileType = detectFileType(data);
var fileName = "${currentPage + 1}.${fileType.ext}";
await saveFile(filename: fileName, data: data);
},
),
MenuEntry(
icon: Icons.menu_book_outlined,
text: "Read".tl,
onClick: () async {
var comic = widget.comic;
var ep = images[currentPage].ep;
var page = images[currentPage].page;
App.rootContext.to(
() => ReaderWithLoading(
id: comic.id,
sourceKey: comic.sourceKey,
initialEp: ep,
initialPage: page,
),
);
},
),
],
);
}
}

View File

@@ -0,0 +1,101 @@
import 'package:venera/utils/ext.dart';
enum ImageFavoriteSortType {
title("Title"),
timeAsc("Time Asc"),
timeDesc("Time Desc"),
maxFavorites("Favorite Num"), // 单本收藏数最多排序
favoritesCompareComicPages("Favorite Num Compare Comic Pages"); // 单本收藏数比上总页数
final String value;
const ImageFavoriteSortType(this.value);
}
const numFilterList = [0, 1, 2, 5, 10, 20, 50, 100];
class TimeRange {
/// End of the range, null means now
final DateTime? end;
/// Duration of the range
final Duration duration;
/// Create a time range
const TimeRange({this.end, required this.duration});
static const all = TimeRange(end: null, duration: Duration.zero);
static const lastWeek = TimeRange(end: null, duration: Duration(days: 7));
static const lastMonth = TimeRange(end: null, duration: Duration(days: 30));
static const lastHalfYear =
TimeRange(end: null, duration: Duration(days: 180));
static const lastYear = TimeRange(end: null, duration: Duration(days: 365));
@override
String toString() {
return "${end?.millisecond}:${duration.inMilliseconds}";
}
/// Parse a time range from a string, return [TimeRange.all] if failed
factory TimeRange.fromString(String? str) {
if (str == null) {
return TimeRange.all;
}
final parts = str.split(":");
if (parts.length != 2 || !parts[0].isInt || !parts[1].isInt) {
return TimeRange.all;
}
final end = parts[0] == "null"
? null
: DateTime.fromMillisecondsSinceEpoch(int.parse(parts[0]));
final duration = Duration(milliseconds: int.parse(parts[1]));
return TimeRange(end: end, duration: duration);
}
/// Check if a time is in the range
bool contains(DateTime time) {
if (end != null && time.isAfter(end!)) {
return false;
}
if (duration == Duration.zero) {
return true;
}
final start = end == null
? DateTime.now().subtract(duration)
: end!.subtract(duration);
return time.isAfter(start);
}
@override
bool operator ==(Object other) {
return other is TimeRange && other.end == end && other.duration == duration;
}
@override
int get hashCode => end.hashCode ^ duration.hashCode;
static const List<TimeRange> values = [
all,
lastWeek,
lastMonth,
lastHalfYear,
lastYear,
];
}
enum TimeRangeType {
all("All"),
lastWeek("Last Week"),
lastMonth("Last Month"),
lastHalfYear("Last Half Year"),
lastYear("Last Year"),
custom("Custom");
final String value;
const TimeRangeType(this.value);
}

View File

@@ -2,11 +2,15 @@ import 'package:flutter/material.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/cbz.dart'; import 'package:venera/utils/cbz.dart';
import 'package:venera/utils/epub.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
class LocalComicsPage extends StatefulWidget { class LocalComicsPage extends StatefulWidget {
@@ -27,7 +31,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
bool multiSelectMode = false; bool multiSelectMode = false;
Map<Comic, bool> selectedComics = {}; Map<LocalComic, bool> selectedComics = {};
void update() { void update() {
if (keyword.isEmpty) { if (keyword.isEmpty) {
@@ -114,8 +118,45 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
); );
} }
@override Widget buildMultiSelectMenu() {
Widget build(BuildContext context) { return MenuButton(entries: [
MenuEntry(
icon: Icons.delete_outline,
text: "Delete".tl,
onClick: () {
deleteComics(selectedComics.keys.toList()).then((value) {
if (value) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
}
});
},
),
MenuEntry(
icon: Icons.favorite_border,
text: "Add to favorites".tl,
onClick: () {
addFavorite(selectedComics.keys.toList());
},
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.chrome_reader_mode_outlined,
text: "View Detail".tl,
onClick: () {
context.to(() => ComicPage(
id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey,
));
},
),
if (selectedComics.length == 1)
...exportActions(selectedComics.keys.first),
]);
}
void selectAll() { void selectAll() {
setState(() { setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
@@ -137,25 +178,8 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
}); });
} }
void selectRange() { @override
setState(() { Widget build(BuildContext context) {
List<int> l = [];
selectedComics.forEach((k, v) {
l.add(comics.indexOf(k as LocalComic));
});
if (l.isEmpty) {
return;
}
l.sort();
int start = l.first;
int end = l.last;
selectedComics.clear();
selectedComics.addEntries(List.generate(end - start + 1, (i) {
return MapEntry(comics[start + i], true);
}));
});
}
List<Widget> selectActions = [ List<Widget> selectActions = [
IconButton( IconButton(
icon: const Icon(Icons.select_all), icon: const Icon(Icons.select_all),
@@ -169,19 +193,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
icon: const Icon(Icons.flip), icon: const Icon(Icons.flip),
tooltip: "Invert Selection".tl, tooltip: "Invert Selection".tl,
onPressed: invertSelection), onPressed: invertSelection),
IconButton( buildMultiSelectMenu(),
icon: const Icon(Icons.border_horizontal_outlined),
tooltip: "Select in range".tl,
onPressed: selectRange),
]; ];
var body = Scaffold( List<Widget> normalActions = [
body: SmoothCustomScrollView(
slivers: [
if (!searchMode && !multiSelectMode)
SliverAppbar(
title: Text("Local".tl),
actions: [
Tooltip( Tooltip(
message: "Search".tl, message: "Search".tl,
child: IconButton( child: IconButton(
@@ -209,38 +224,35 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
}, },
), ),
), ),
Tooltip( ];
message: multiSelectMode
? "Exit Multi-Select".tl var body = Scaffold(
: "Multi-Select".tl, body: SmoothCustomScrollView(
child: IconButton( slivers: [
icon: const Icon(Icons.checklist), if (!searchMode)
onPressed: () {
setState(() {
multiSelectMode = !multiSelectMode;
});
},
),
),
],
)
else if (multiSelectMode)
SliverAppbar( SliverAppbar(
leading: Tooltip( leading: Tooltip(
message: "Cancel".tl, message: multiSelectMode ? "Cancel".tl : "Back".tl,
child: IconButton( child: IconButton(
icon: const Icon(Icons.close),
onPressed: () { onPressed: () {
if (multiSelectMode) {
setState(() { setState(() {
multiSelectMode = false; multiSelectMode = false;
selectedComics.clear(); selectedComics.clear();
}); });
} else {
context.pop();
}
}, },
icon: multiSelectMode
? const Icon(Icons.close)
: const Icon(Icons.arrow_back),
), ),
), ),
title: Text( title: multiSelectMode
"Selected @c comics".tlParams({"c": selectedComics.length})), ? Text(selectedComics.length.toString())
actions: selectActions, : Text("Local".tl),
actions: multiSelectMode ? selectActions : normalActions,
) )
else if (searchMode) else if (searchMode)
SliverAppbar( SliverAppbar(
@@ -272,18 +284,27 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
SliverGridComics( SliverGridComics(
comics: comics, comics: comics,
selections: selectedComics, selections: selectedComics,
onTap: multiSelectMode onLongPressed: (c) {
? (c) { setState(() {
multiSelectMode = true;
selectedComics[c as LocalComic] = true;
});
},
onTap: (c) {
if (multiSelectMode) {
setState(() { setState(() {
if (selectedComics.containsKey(c as LocalComic)) { if (selectedComics.containsKey(c as LocalComic)) {
selectedComics.remove(c); selectedComics.remove(c);
} else { } else {
selectedComics[c] = true; selectedComics[c] = true;
} }
}); if (selectedComics.isEmpty) {
multiSelectMode = false;
} }
: (c) { });
} else {
(c as LocalComic).read(); (c as LocalComic).read();
}
}, },
menuBuilder: (c) { menuBuilder: (c) {
return [ return [
@@ -291,76 +312,17 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
icon: Icons.delete, icon: Icons.delete,
text: "Delete".tl, text: "Delete".tl,
onClick: () { onClick: () {
showDialog( deleteComics([c as LocalComic]).then((value) {
context: context, if (value && multiSelectMode) {
builder: (context) { setState(() {
bool removeComicFile = true; multiSelectMode = false;
return StatefulBuilder(builder: (context, state) { selectedComics.clear();
return ContentDialog( });
title: "Delete".tl, }
content: CheckboxListTile(
title:
Text("Also remove files on disk".tl),
value: removeComicFile,
onChanged: (v) {
state(() {
removeComicFile = !removeComicFile;
}); });
}, },
), ),
actions: [ ...exportActions(c as LocalComic),
FilledButton(
onPressed: () {
context.pop();
if (multiSelectMode) {
for (var comic in selectedComics.keys) {
LocalManager().deleteComic(
comic as LocalComic,
removeComicFile);
}
setState(() {
selectedComics.clear();
});
} else {
LocalManager().deleteComic(
c as LocalComic, removeComicFile);
}
},
child: Text("Confirm".tl),
),
],
);
});
});
}),
MenuEntry(
icon: Icons.outbox_outlined,
text: "Export as cbz".tl,
onClick: () async {
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
if (multiSelectMode) {
for (var comic in selectedComics.keys) {
var file = await CBZ.export(comic as LocalComic);
await saveFile(filename: file.name, file: file);
await file.delete();
}
setState(() {
selectedComics.clear();
});
} else {
var file = await CBZ.export(c as LocalComic);
await saveFile(filename: file.name, file: file);
await file.delete();
}
} catch (e) {
context.showMessage(message: e.toString());
}
controller.close();
}),
]; ];
}, },
), ),
@@ -387,4 +349,120 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
child: body, child: body,
); );
} }
Future<bool> deleteComics(List<LocalComic> comics) async {
bool isDeleted = false;
await showDialog(
context: App.rootContext,
builder: (context) {
bool removeComicFile = true;
return StatefulBuilder(builder: (context, state) {
return ContentDialog(
title: "Delete".tl,
content: CheckboxListTile(
title: Text("Also remove files on disk".tl),
value: removeComicFile,
onChanged: (v) {
state(() {
removeComicFile = !removeComicFile;
});
},
),
actions: [
FilledButton(
onPressed: () {
context.pop();
for (var comic in comics) {
LocalManager().deleteComic(
comic,
removeComicFile,
);
}
isDeleted = true;
},
child: Text("Confirm".tl),
),
],
);
});
},
);
return isDeleted;
}
List<MenuEntry> exportActions(LocalComic c) {
return [
MenuEntry(
icon: Icons.outbox_outlined,
text: "Export as cbz".tl,
onClick: () async {
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
var file = await CBZ.export(c);
await saveFile(filename: file.name, file: file);
await file.delete();
} catch (e, s) {
context.showMessage(message: e.toString());
Log.error("CBZ Export", e, s);
}
controller.close();
}),
MenuEntry(
icon: Icons.picture_as_pdf_outlined,
text: "Export as pdf".tl,
onClick: () async {
var cache = FilePath.join(App.cachePath, 'temp.pdf');
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
await createPdfFromComicIsolate(
comic: c,
savePath: cache,
);
await saveFile(
file: File(cache),
filename: "${c.title}.pdf",
);
} catch (e, s) {
Log.error("PDF Export", e, s);
context.showMessage(message: e.toString());
} finally {
controller.close();
File(cache).deleteIgnoreError();
}
},
),
MenuEntry(
icon: Icons.import_contacts_outlined,
text: "Export as epub".tl,
onClick: () async {
var controller = showLoadingDialog(
context,
allowCancel: false,
);
File? file;
try {
file = await createEpubWithLocalComic(
c,
);
await saveFile(
file: file,
filename: "${c.title}.epub",
);
} catch (e, s) {
Log.error("EPUB Export", e, s);
context.showMessage(message: e.toString());
} finally {
controller.close();
file?.deleteIgnoreError();
}
},
)
];
}
} }

View File

@@ -37,9 +37,6 @@ class _MainPageState extends State<MainPage> {
} }
void checkUpdates() async { void checkUpdates() async {
if (!appdata.settings['checkUpdateOnStart']) {
return;
}
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch; var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) { if (now - lastCheck < 24 * 60 * 60 * 1000) {
@@ -47,9 +44,11 @@ class _MainPageState extends State<MainPage> {
} }
appdata.implicitData['lastCheckUpdate'] = now; appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData(); appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false); await checkUpdateUi(false);
await ComicSourcePage.checkComicSourceUpdate(true); }
} }
@override @override
@@ -63,9 +62,15 @@ class _MainPageState extends State<MainPage> {
final _pages = [ final _pages = [
const HomePage(), const HomePage(),
const FavoritesPage(), const FavoritesPage(
const ExplorePage(), key: PageStorageKey('favorites'),
const CategoriesPage(), ),
const ExplorePage(
key: PageStorageKey('explore'),
),
const CategoriesPage(
key: PageStorageKey('categories'),
),
]; ];
var index = 0; var index = 0;

View File

@@ -20,7 +20,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
static const _kTapToTurnPagePercent = 0.3; static const _kTapToTurnPagePercent = 0.3;
_DragListener? dragListener; final _dragListeners = <_DragListener>[];
int fingers = 0; int fingers = 0;
@@ -45,7 +45,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_lastTapMoveDistance = Offset.zero; _lastTapMoveDistance = Offset.zero;
_tapGestureRecognizer.addPointer(event); _tapGestureRecognizer.addPointer(event);
if (_dragInProgress) { if (_dragInProgress) {
dragListener?.onEnd?.call(); for (var dragListener in _dragListeners) {
dragListener.onStart?.call(event.position);
}
_dragInProgress = false; _dragInProgress = false;
} }
Future.delayed(_kLongPressMinTime, () { Future.delayed(_kLongPressMinTime, () {
@@ -55,8 +57,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_longPressInProgress = true; _longPressInProgress = true;
} else { } else {
_dragInProgress = true; _dragInProgress = true;
dragListener?.onStart?.call(event.position); for (var dragListener in _dragListeners) {
dragListener?.onMove?.call(_lastTapMoveDistance!); dragListener.onStart?.call(event.position);
dragListener.onMove?.call(_lastTapMoveDistance!);
}
} }
} }
}); });
@@ -66,7 +70,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_lastTapMoveDistance = event.delta + _lastTapMoveDistance!; _lastTapMoveDistance = event.delta + _lastTapMoveDistance!;
} }
if (_dragInProgress) { if (_dragInProgress) {
dragListener?.onMove?.call(event.delta); for (var dragListener in _dragListeners) {
dragListener.onMove?.call(event.delta);
}
} }
}, },
onPointerUp: (event) { onPointerUp: (event) {
@@ -75,7 +81,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
onLongPressedUp(event.position); onLongPressedUp(event.position);
} }
if (_dragInProgress) { if (_dragInProgress) {
dragListener?.onEnd?.call(); for (var dragListener in _dragListeners) {
dragListener.onEnd?.call();
}
_dragInProgress = false; _dragInProgress = false;
} }
_lastTapPointer = null; _lastTapPointer = null;
@@ -87,7 +95,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
onLongPressedUp(event.position); onLongPressedUp(event.position);
} }
if (_dragInProgress) { if (_dragInProgress) {
dragListener?.onEnd?.call(); for (var dragListener in _dragListeners) {
dragListener.onEnd?.call();
}
_dragInProgress = false; _dragInProgress = false;
} }
_lastTapPointer = null; _lastTapPointer = null;
@@ -103,6 +113,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
} }
void onMouseWheel(bool forward) { void onMouseWheel(bool forward) {
if (HardwareKeyboard.instance.isControlPressed) {
return;
}
if (context.reader.mode.key.startsWith('gallery')) { if (context.reader.mode.key.startsWith('gallery')) {
if (forward) { if (forward) {
if (!context.reader.toNextPage()) { if (!context.reader.toNextPage()) {
@@ -258,6 +271,14 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
void onLongPressedDown(Offset location) { void onLongPressedDown(Offset location) {
context.reader._imageViewController?.handleLongPressDown(location); context.reader._imageViewController?.handleLongPressDown(location);
} }
void addDragListener(_DragListener listener) {
_dragListeners.add(listener);
}
void removeDragListener(_DragListener listener) {
_dragListeners.remove(listener);
}
} }
class _DragListener { class _DragListener {
@@ -265,5 +286,5 @@ class _DragListener {
void Function(Offset offset)? onMove; void Function(Offset offset)? onMove;
void Function()? onEnd; void Function()? onEnd;
_DragListener({this.onStart, this.onMove, this.onEnd}); _DragListener({this.onMove, this.onEnd});
} }

View File

@@ -25,7 +25,7 @@ class _ReaderImagesState extends State<_ReaderImages> {
if (inProgress) return; if (inProgress) return;
inProgress = true; inProgress = true;
if (reader.type == ComicType.local || if (reader.type == ComicType.local ||
(await LocalManager() (LocalManager()
.isDownloaded(reader.cid, reader.type, reader.chapter))) { .isDownloaded(reader.cid, reader.type, reader.chapter))) {
try { try {
var images = await LocalManager() var images = await LocalManager()
@@ -83,7 +83,8 @@ class _ReaderImagesState extends State<_ReaderImages> {
); );
} else { } else {
if (reader.mode.isGallery) { if (reader.mode.isGallery) {
return _GalleryMode(key: Key(reader.mode.key)); return _GalleryMode(
key: Key('${reader.mode.key}_${reader.imagesPerPage}'));
} else { } else {
return _ContinuousMode(key: Key(reader.mode.key)); return _ContinuousMode(key: Key(reader.mode.key));
} }
@@ -110,6 +111,10 @@ class _GalleryModeState extends State<_GalleryMode>
late _ReaderState reader; late _ReaderState reader;
int get totalPages => ((reader.images!.length + reader.imagesPerPage - 1) /
reader.imagesPerPage)
.ceil();
@override @override
void initState() { void initState() {
reader = context.reader; reader = context.reader;
@@ -124,8 +129,14 @@ class _GalleryModeState extends State<_GalleryMode>
void cache(int current) { void cache(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) { for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= reader.maxPage && !cached[i]) { if (i <= totalPages && !cached[i]) {
_precacheImage(i, context); int startIndex = (i - 1) * reader.imagesPerPage;
int endIndex =
math.min(startIndex + reader.imagesPerPage, reader.images!.length);
for (int i = startIndex; i < endIndex; i++) {
precacheImage(
_createImageProviderFromKey(reader.images![i], context), context);
}
cached[i] = true; cached[i] = true;
} }
} }
@@ -141,32 +152,46 @@ class _GalleryModeState extends State<_GalleryMode>
scrollDirection: reader.mode == ReaderMode.galleryTopToBottom scrollDirection: reader.mode == ReaderMode.galleryTopToBottom
? Axis.vertical ? Axis.vertical
: Axis.horizontal, : Axis.horizontal,
itemCount: reader.images!.length + 2, itemCount: totalPages + 2,
builder: (BuildContext context, int index) { builder: (BuildContext context, int index) {
ImageProvider? imageProvider; if (index == 0 || index == totalPages + 1) {
if (index != 0 && index != reader.images!.length + 1) {
imageProvider = _createImageProvider(index, context);
} else {
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
scaleStateController: PhotoViewScaleStateController(), scaleStateController: PhotoViewScaleStateController(),
child: const SizedBox(), child: const SizedBox(),
); );
} } else {
int pageIndex = index - 1;
int startIndex = pageIndex * reader.imagesPerPage;
int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length);
List<String> pageImages =
reader.images!.sublist(startIndex, endIndex);
cached[index] = true; cached[index] = true;
cache(index); cache(index);
photoViewControllers[index] ??= PhotoViewController(); photoViewControllers[index] = PhotoViewController();
if (reader.imagesPerPage == 1) {
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
controller: photoViewControllers[index], controller: photoViewControllers[index],
imageProvider: imageProvider, imageProvider:
_createImageProviderFromKey(pageImages[0], context),
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) { errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry); return NetworkError(message: error.toString(), retry: retry);
}, },
); );
}
return PhotoViewGalleryPageOptions.customChild(
controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
child: buildPageImages(pageImages),
);
}
}, },
pageController: controller, pageController: controller,
loadingBuilder: (context, event) => Center( loadingBuilder: (context, event) => Center(
@@ -186,9 +211,9 @@ class _GalleryModeState extends State<_GalleryMode>
if (!reader.toPrevChapter()) { if (!reader.toPrevChapter()) {
reader.toPage(1); reader.toPage(1);
} }
} else if (i == reader.maxPage + 1) { } else if (i == totalPages + 1) {
if (!reader.toNextChapter()) { if (!reader.toNextChapter()) {
reader.toPage(reader.maxPage); reader.toPage(totalPages);
} }
} else { } else {
reader.setPage(i); reader.setPage(i);
@@ -198,9 +223,30 @@ class _GalleryModeState extends State<_GalleryMode>
); );
} }
Widget buildPageImages(List<String> images) {
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
? Axis.vertical
: Axis.horizontal;
List<Widget> imageWidgets = images.map((imageKey) {
ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context);
return Expanded(
child: Image(
image: imageProvider,
fit: BoxFit.contain,
),
);
}).toList();
return axis == Axis.vertical
? Column(children: imageWidgets)
: Row(children: imageWidgets);
}
@override @override
Future<void> animateToPage(int page) { Future<void> animateToPage(int page) {
if ((page - controller.page!).abs() > 1) { if ((page - controller.page!.round()).abs() > 1) {
controller.jumpToPage(page > controller.page! ? page - 1 : page + 1); controller.jumpToPage(page > controller.page! ? page - 1 : page + 1);
} }
return controller.animateToPage( return controller.animateToPage(
@@ -217,6 +263,10 @@ class _GalleryModeState extends State<_GalleryMode>
@override @override
void handleDoubleTap(Offset location) { void handleDoubleTap(Offset location) {
if (appdata.settings['quickCollectImage'] == 'DoubleTap') {
context.readerScaffold.addImageFavorite();
return;
}
var controller = photoViewControllers[reader.page]!; var controller = photoViewControllers[reader.page]!;
controller.onDoubleClick?.call(); controller.onDoubleClick?.call();
} }
@@ -310,6 +360,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
var isCTRLPressed = false; var isCTRLPressed = false;
static var _isMouseScrolling = false; static var _isMouseScrolling = false;
var fingers = 0; var fingers = 0;
bool disableScroll = false;
@override @override
void initState() { void initState() {
@@ -380,7 +431,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
? Axis.vertical ? Axis.vertical
: Axis.horizontal, : Axis.horizontal,
reverse: reader.mode == ReaderMode.continuousRightToLeft, reverse: reader.mode == ReaderMode.continuousRightToLeft,
physics: isCTRLPressed || _isMouseScrolling physics: isCTRLPressed || _isMouseScrolling || disableScroll
? const NeverScrollableScrollPhysics() ? const NeverScrollableScrollPhysics()
: const ClampingScrollPhysics(), : const ClampingScrollPhysics(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -414,6 +465,11 @@ class _ContinuousModeState extends State<_ContinuousMode>
widget = Listener( widget = Listener(
onPointerDown: (event) { onPointerDown: (event) {
fingers++; fingers++;
if (fingers > 1 && !disableScroll) {
setState(() {
disableScroll = true;
});
}
futurePosition = null; futurePosition = null;
if (_isMouseScrolling) { if (_isMouseScrolling) {
setState(() { setState(() {
@@ -423,6 +479,11 @@ class _ContinuousModeState extends State<_ContinuousMode>
}, },
onPointerUp: (event) { onPointerUp: (event) {
fingers--; fingers--;
if (fingers <= 1 && disableScroll) {
setState(() {
disableScroll = false;
});
}
}, },
onPointerPanZoomUpdate: (event) { onPointerPanZoomUpdate: (event) {
if (event.scale == 1.0) { if (event.scale == 1.0) {
@@ -507,6 +568,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override @override
void handleDoubleTap(Offset location) { void handleDoubleTap(Offset location) {
if (appdata.settings['quickCollectImage'] == 'DoubleTap') {
context.readerScaffold.addImageFavorite();
return;
}
double target; double target;
if (photoViewController.scale != if (photoViewController.scale !=
photoViewController.getInitialScale?.call()) { photoViewController.getInitialScale?.call()) {
@@ -600,19 +665,22 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
} }
ImageProvider _createImageProviderFromKey(
String imageKey, BuildContext context) {
var reader = context.reader;
return ReaderImageProvider(
imageKey,
reader.type.comicSource?.key,
reader.cid,
reader.eid,
reader.page,
);
}
ImageProvider _createImageProvider(int page, BuildContext context) { ImageProvider _createImageProvider(int page, BuildContext context) {
var reader = context.reader; var reader = context.reader;
var imageKey = reader.images![page - 1]; var imageKey = reader.images![page - 1];
if (imageKey.startsWith('file://')) { return _createImageProviderFromKey(imageKey, context);
return FileImage(openFilePlatform(imageKey.replaceFirst("file://", '')));
} else {
return ReaderImageProvider(
imageKey,
reader.type.comicSource!.key,
reader.cid,
reader.eid,
);
}
} }
void _precacheImage(int page, BuildContext context) { void _precacheImage(int page, BuildContext context) {

View File

@@ -0,0 +1,121 @@
part of 'reader.dart';
class ReaderWithLoading extends StatefulWidget {
const ReaderWithLoading({
super.key,
required this.id,
required this.sourceKey,
this.initialEp,
this.initialPage,
});
final String id;
final String sourceKey;
final int? initialEp;
final int? initialPage;
@override
State<ReaderWithLoading> createState() => _ReaderWithLoadingState();
}
class _ReaderWithLoadingState
extends LoadingState<ReaderWithLoading, ReaderProps> {
@override
Widget buildContent(BuildContext context, ReaderProps data) {
return Reader(
type: data.type,
cid: data.cid,
name: data.name,
chapters: data.chapters,
history: data.history,
initialChapter: widget.initialEp ?? data.history.ep,
initialPage: widget.initialPage ?? data.history.page,
author: data.author,
tags: data.tags,
);
}
@override
Future<Res<ReaderProps>> loadData() async {
var comicSource = ComicSource.find(widget.sourceKey);
var history = HistoryManager().findSync(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
if (comicSource == null) {
var localComic = LocalManager().find(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
if (localComic == null) {
return Res.error("comic not found");
}
return Res(
ReaderProps(
type: ComicType.fromKey(widget.sourceKey),
cid: widget.id,
name: localComic.title,
chapters: localComic.chapters,
history: history ??
History.fromModel(
model: localComic,
ep: 0,
page: 0,
),
author: localComic.subtitle,
tags: localComic.tags,
),
);
} else {
var comic = await comicSource.loadComicInfo!(widget.id);
if (comic.error) {
return Res.fromErrorRes(comic);
}
return Res(
ReaderProps(
type: ComicType.fromKey(widget.sourceKey),
cid: widget.id,
name: comic.data.title,
chapters: comic.data.chapters,
history: history ??
History.fromModel(
model: comic.data,
ep: 0,
page: 0,
),
author: comic.data.findAuthor() ?? "",
tags: comic.data.plainTags,
),
);
}
}
}
class ReaderProps {
final ComicType type;
final String cid;
final String name;
final Map<String, String>? chapters;
final History history;
final String author;
final List<String> tags;
const ReaderProps({
required this.type,
required this.cid,
required this.name,
required this.chapters,
required this.history,
required this.author,
required this.tags,
});
}

View File

@@ -1,6 +1,7 @@
library venera_reader; library;
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@@ -8,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_memory_info/flutter_memory_info.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
@@ -16,14 +18,21 @@ import 'package:venera/components/custom_slider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:venera/utils/volume.dart'; import 'package:venera/utils/volume.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
@@ -33,6 +42,7 @@ part 'scaffold.dart';
part 'images.dart'; part 'images.dart';
part 'gesture.dart'; part 'gesture.dart';
part 'comic_image.dart'; part 'comic_image.dart';
part 'loading.dart';
extension _ReaderContext on BuildContext { extension _ReaderContext on BuildContext {
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!; _ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
@@ -51,15 +61,21 @@ class Reader extends StatefulWidget {
required this.history, required this.history,
this.initialPage, this.initialPage,
this.initialChapter, this.initialChapter,
required this.author,
required this.tags,
}); });
final ComicType type; final ComicType type;
final String author;
final List<String> tags;
final String cid; final String cid;
final String name; final String name;
/// Map<Chapter ID, Chapter Name>. /// key: Chapter ID, value: Chapter Name
/// null if the comic is a gallery /// null if the comic is a gallery
final Map<String, String>? chapters; final Map<String, String>? chapters;
@@ -82,7 +98,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} }
@override @override
int get maxPage => images?.length ?? 1; int get maxPage =>
((images?.length ?? 1) + imagesPerPage - 1) ~/ imagesPerPage;
ComicType get type => widget.type; ComicType get type => widget.type;
@@ -94,6 +111,32 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
late ReaderMode mode; late ReaderMode mode;
int get imagesPerPage => appdata.settings['readerScreenPicNumber'] ?? 1;
int _lastImagesPerPage = appdata.settings['readerScreenPicNumber'] ?? 1;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_checkImagesPerPageChange();
}
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
}
}
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
}
History? history; History? history;
@override @override
@@ -107,6 +150,12 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
void initState() { void initState() {
page = widget.initialPage ?? 1; page = widget.initialPage ?? 1;
chapter = widget.initialChapter ?? 1; chapter = widget.initialChapter ?? 1;
if (page < 1) {
page = 1;
}
if (chapter < 1) {
chapter = 1;
}
mode = ReaderMode.fromKey(appdata.settings['readerMode']); mode = ReaderMode.fromKey(appdata.settings['readerMode']);
history = widget.history; history = widget.history;
Future.microtask(() { Future.microtask(() {
@@ -116,9 +165,31 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
if (appdata.settings['enableTurnPageByVolumeKey']) { if (appdata.settings['enableTurnPageByVolumeKey']) {
handleVolumeEvent(); handleVolumeEvent();
} }
setImageCacheSize();
Future.delayed(const Duration(milliseconds: 200), () {
LocalFavoritesManager().onRead(cid, type);
});
super.initState(); super.initState();
} }
void setImageCacheSize() async {
var availableRAM = await MemoryInfo.getFreePhysicalMemorySize();
if (availableRAM == null) return;
int maxImageCacheSize;
if (availableRAM < 1 << 30) {
maxImageCacheSize = 100 << 20;
} else if (availableRAM < 2 << 30) {
maxImageCacheSize = 200 << 20;
} else if (availableRAM < 4 << 30) {
maxImageCacheSize = 300 << 20;
} else {
maxImageCacheSize = 500 << 20;
}
Log.info("Reader",
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize");
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
}
@override @override
void dispose() { void dispose() {
autoPageTurningTimer?.cancel(); autoPageTurningTimer?.cancel();
@@ -128,11 +199,13 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
Future.microtask(() { Future.microtask(() {
DataSync().onDataChanged(); DataSync().onDataChanged();
}); });
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20;
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_checkImagesPerPageChange();
return KeyboardListener( return KeyboardListener(
focusNode: focusNode, focusNode: focusNode,
autofocus: true, autofocus: true,
@@ -161,7 +234,11 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
if (history != null) { if (history != null) {
history!.page = page; history!.page = page;
history!.ep = chapter; history!.ep = chapter;
if (maxPage > 1) {
history!.maxPage = maxPage;
}
history!.readEpisode.add(chapter); history!.readEpisode.add(chapter);
history!.time = DateTime.now();
HistoryManager().addHistory(history!); HistoryManager().addHistory(history!);
} }
} }
@@ -245,7 +322,8 @@ abstract mixin class _ReaderLocation {
bool toPage(int page) { bool toPage(int page) {
if (_validatePage(page)) { if (_validatePage(page)) {
if (page == this.page) { if (page == this.page) {
if(!(chapter == 1 && page == 1) && !(chapter == maxChapter && page == maxPage)) { if (!(chapter == 1 && page == 1) &&
!(chapter == maxChapter && page == maxPage)) {
return false; return false;
} }
} }

View File

@@ -18,7 +18,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
bool get isOpen => _isOpen; bool get isOpen => _isOpen;
bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft || bool get isReversed =>
context.reader.mode == ReaderMode.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft; context.reader.mode == ReaderMode.continuousRightToLeft;
int showFloatingButtonValue = 0; int showFloatingButtonValue = 0;
@@ -29,6 +30,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
_ReaderGestureDetectorState? _gestureDetectorState; _ReaderGestureDetectorState? _gestureDetectorState;
_DragListener? _floatingButtonDragListener;
void setFloatingButton(int value) { void setFloatingButton(int value) {
lastValue = showFloatingButtonValue; lastValue = showFloatingButtonValue;
if (value == 0) { if (value == 0) {
@@ -37,12 +40,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0; fABValue.value = 0;
update(); update();
} }
_gestureDetectorState!.dragListener = null; if (_floatingButtonDragListener != null) {
_gestureDetectorState!.removeDragListener(_floatingButtonDragListener!);
_floatingButtonDragListener = null;
}
} }
var readerMode = context.reader.mode; var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) { if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1; showFloatingButtonValue = 1;
_gestureDetectorState!.dragListener = _DragListener( _floatingButtonDragListener = _DragListener(
onMove: (offset) { onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) { if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy; fABValue.value -= offset.dy;
@@ -62,10 +68,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0; fABValue.value = 0;
}, },
); );
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update(); update();
} else if (value == -1 && showFloatingButtonValue == 0) { } else if (value == -1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = -1; showFloatingButtonValue = -1;
_gestureDetectorState!.dragListener = _DragListener( _floatingButtonDragListener = _DragListener(
onMove: (offset) { onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) { if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value += offset.dy; fABValue.value += offset.dy;
@@ -85,10 +92,48 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0; fABValue.value = 0;
}, },
); );
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update(); update();
} }
} }
_DragListener? _imageFavoriteDragListener;
void addDragListener() async {
if (!mounted) return;
var readerMode = context.reader.mode;
// 横向阅读的时候, 如果纵向滑就触发收藏, 纵向阅读的时候, 如果横向滑动就触发收藏
if (appdata.settings['quickCollectImage'] == 'Swipe') {
if (_imageFavoriteDragListener == null) {
double distance = 0;
_imageFavoriteDragListener = _DragListener(
onMove: (offset) {
switch (readerMode) {
case ReaderMode.continuousTopToBottom:
case ReaderMode.galleryTopToBottom:
distance += offset.dx;
case ReaderMode.continuousLeftToRight:
case ReaderMode.galleryLeftToRight:
case ReaderMode.galleryRightToLeft:
case ReaderMode.continuousRightToLeft:
distance += offset.dy;
}
},
onEnd: () {
if (distance.abs() > 150) {
addImageFavorite();
}
distance = 0;
},
);
}
_gestureDetectorState!.addDragListener(_imageFavoriteDragListener!);
} else if (_imageFavoriteDragListener != null) {
_gestureDetectorState!.removeDragListener(_imageFavoriteDragListener!);
}
}
@override @override
void initState() { void initState() {
sliderFocus.canRequestFocus = false; sliderFocus.canRequestFocus = false;
@@ -101,6 +146,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
SystemChrome.setPreferredOrientations(DeviceOrientation.values); SystemChrome.setPreferredOrientations(DeviceOrientation.values);
} }
super.initState(); super.initState();
Future.delayed(const Duration(milliseconds: 200), addDragListener);
} }
@override @override
@@ -167,10 +213,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: Container( child: Container(
padding: EdgeInsets.only(top: context.padding.top), padding: EdgeInsets.only(top: context.padding.top),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.withOpacity(0.82), color: context.colorScheme.surface.toOpacity(0.82),
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: Colors.grey.withOpacity(0.5), color: Colors.grey.toOpacity(0.5),
width: 0.5, width: 0.5,
), ),
), ),
@@ -203,6 +249,123 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
} }
bool isLiked() {
return ImageFavoriteManager().has(
context.reader.cid,
context.reader.type.sourceKey,
context.reader.eid,
context.reader.page,
context.reader.chapter,
);
}
void addImageFavorite() {
try {
if (context.reader.images![0].contains('file://')) {
showToast(
message: "Local comic collection is not supported at present".tl,
context: context);
return;
}
String id = context.reader.cid;
int ep = context.reader.chapter;
String eid = context.reader.eid;
String title = context.reader.history!.title;
String subTitle = context.reader.history!.subtitle;
int maxPage = context.reader.images!.length;
int page = context.reader.page;
String sourceKey = context.reader.type.sourceKey;
String imageKey = context.reader.images![page - 1];
List<String> tags = context.reader.widget.tags;
String author = context.reader.widget.author;
var epName = context.reader.widget.chapters?.values
.elementAtOrNull(context.reader.chapter - 1) ??
"E${context.reader.chapter}";
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
if (isLiked()) {
if (page == firstPage) {
showToast(
message: "The cover cannot be uncollected here".tl,
context: context,
);
return;
}
ImageFavoriteManager().deleteImageFavorite([
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName)
]);
showToast(
message: "Uncollected the image".tl,
context: context,
seconds: 1,
);
} else {
var imageFavoritesComic = ImageFavoriteManager().find(id, sourceKey) ??
ImageFavoritesComic(
id,
[],
title,
sourceKey,
tags,
translatedTags,
DateTime.now(),
author,
{},
subTitle,
maxPage,
);
ImageFavorite imageFavorite =
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName);
ImageFavoritesEp? imageFavoritesEp =
imageFavoritesComic.imageFavoritesEp.firstWhereOrNull((e) {
return e.ep == ep;
});
if (imageFavoritesEp == null) {
if (page != firstPage) {
var copy = imageFavorite.copyWith(
page: firstPage,
isAutoFavorite: true,
imageKey: context.reader.images![0],
);
// 不是第一页的话, 自动塞一个封面进去
imageFavoritesEp = ImageFavoritesEp(
eid, ep, [copy, imageFavorite], epName, maxPage);
} else {
imageFavoritesEp =
ImageFavoritesEp(eid, ep, [imageFavorite], epName, maxPage);
}
imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp);
} else {
if (imageFavoritesEp.eid != eid) {
// 空字符串说明是从pica导入的, 那我们就手动刷一遍保证一致
if (imageFavoritesEp.eid == "") {
imageFavoritesEp.eid == eid;
} else {
// 避免多章节漫画源的章节顺序发生变化, 如果情况比较多, 做一个以eid为准更新ep的功能
showToast(
message:
"The chapter order of the comic may have changed, temporarily not supported for collection"
.tl,
context: context,
);
return;
}
}
imageFavoritesEp.imageFavorites.add(imageFavorite);
}
ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic);
showToast(
message: "Successfully collected".tl, context: context, seconds: 1);
}
update();
} catch (e, stackTrace) {
Log.error("Image Favorite", e, stackTrace);
showToast(message: e.toString(), context: context, seconds: 1);
}
}
Widget buildBottom() { Widget buildBottom() {
var text = "E${context.reader.chapter} : P${context.reader.page}"; var text = "E${context.reader.chapter} : P${context.reader.page}";
if (context.reader.widget.chapters == null) { if (context.reader.widget.chapters == null) {
@@ -263,6 +426,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
), ),
), ),
const Spacer(), const Spacer(),
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(
isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite),
),
if (App.isWindows) if (App.isWindows)
Tooltip( Tooltip(
message: "${"Full Screen".tl}(F12)", message: "${"Full Screen".tl}(F12)",
@@ -357,13 +527,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
return BlurEffect( return BlurEffect(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.withOpacity(0.82), color: context.colorScheme.surface.toOpacity(0.82),
border: Border( border: isOpen
? Border(
top: BorderSide( top: BorderSide(
color: Colors.grey.withOpacity(0.5), color: Colors.grey.toOpacity(0.5),
width: 0.5, width: 0.5,
), ),
), )
: null,
), ),
padding: EdgeInsets.only(bottom: context.padding.bottom), padding: EdgeInsets.only(bottom: context.padding.bottom),
child: child, child: child,
@@ -456,9 +628,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
var imagesOnScreen = var imagesOnScreen =
continuesState.itemPositionsListener.itemPositions.value; continuesState.itemPositionsListener.itemPositions.value;
var images = imagesOnScreen var images = imagesOnScreen
.map((e) => context.reader.images![e.index - 1]) .map((e) => context.reader.images!.elementAtOrNull(e.index - 1))
.whereType<String>()
.toList(); .toList();
String? selected; String? selected;
if (images.length > 1) {
await showPopUpWidget( await showPopUpWidget(
context, context,
PopUpWidgetScaffold( PopUpWidgetScaffold(
@@ -469,13 +643,14 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
ImageProvider image; ImageProvider image;
var imageKey = images[index]; var imageKey = images[index];
if (imageKey.startsWith('file://')) { if (imageKey.startsWith('file://')) {
image = FileImage(openFilePlatform(imageKey.replaceFirst("file://", ''))); image = FileImage(File(imageKey.replaceFirst("file://", '')));
} else { } else {
image = ReaderImageProvider( image = ReaderImageProvider(
imageKey, imageKey,
reader.type.comicSource!.key, reader.type.comicSource!.key,
reader.cid, reader.cid,
reader.eid, reader.eid,
reader.page,
); );
} }
return InkWell( return InkWell(
@@ -508,6 +683,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
), ),
), ),
); );
} else {
selected = images.first;
}
if (selected == null) { if (selected == null) {
return null; return null;
} else { } else {
@@ -515,7 +693,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
} }
if (imageKey.startsWith("file://")) { if (imageKey.startsWith("file://")) {
return await openFilePlatform(imageKey.substring(7)).readAsBytes(); return await File(imageKey.substring(7)).readAsBytes();
} else { } else {
return (await CacheManager().findCache( return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
@@ -554,7 +732,6 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
onChanged: (key) { onChanged: (key) {
if (key == "readerMode") { if (key == "readerMode") {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]); context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
App.rootContext.pop();
} }
if (key == "enableTurnPageByVolumeKey") { if (key == "enableTurnPageByVolumeKey") {
if (appdata.settings[key]) { if (appdata.settings[key]) {
@@ -563,6 +740,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context.reader.stopVolumeEvent(); context.reader.stopVolumeEvent();
} }
} }
if (key == "quickCollectImage") {
addDragListener();
}
context.reader.update(); context.reader.update();
}, },
), ),
@@ -641,7 +821,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceTint .surfaceTint
.withOpacity(0.2), .toOpacity(0.2),
child: const SizedBox.expand(), child: const SizedBox.expand(),
), ),
), ),

View File

@@ -1,12 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart'; import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/aggregated_search_page.dart';
import 'package:venera/pages/search_result_page.dart'; import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -27,6 +29,8 @@ class _SearchPageState extends State<SearchPage> {
String searchTarget = ""; String searchTarget = "";
bool aggregatedSearch = false;
var focusNode = FocusNode(); var focusNode = FocusNode();
var options = <String>[]; var options = <String>[];
@@ -36,6 +40,11 @@ class _SearchPageState extends State<SearchPage> {
} }
void search([String? text]) { void search([String? text]) {
if (aggregatedSearch) {
context
.to(() => AggregatedSearchPage(keyword: text ?? controller.text))
.then((_) => update());
} else {
context context
.to( .to(
() => SearchResultPage( () => SearchResultPage(
@@ -46,6 +55,7 @@ class _SearchPageState extends State<SearchPage> {
) )
.then((_) => update()); .then((_) => update());
} }
}
var suggestions = <Pair<String, TranslationType>>[]; var suggestions = <Pair<String, TranslationType>>[];
@@ -130,7 +140,9 @@ class _SearchPageState extends State<SearchPage> {
@override @override
void initState() { void initState() {
var defaultSearchTarget = appdata.settings['defaultSearchTarget']; var defaultSearchTarget = appdata.settings['defaultSearchTarget'];
if (defaultSearchTarget != null && if (defaultSearchTarget == "_aggregated_") {
aggregatedSearch = true;
} else if (defaultSearchTarget != null &&
ComicSource.find(defaultSearchTarget) != null) { ComicSource.find(defaultSearchTarget) != null) {
searchTarget = defaultSearchTarget; searchTarget = defaultSearchTarget;
} else { } else {
@@ -173,7 +185,7 @@ class _SearchPageState extends State<SearchPage> {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: buildSearchOptions(), child: buildSearchOptions(),
); );
yield buildSearchHistory(); yield _SearchHistory(search);
} }
} }
@@ -189,6 +201,7 @@ class _SearchPageState extends State<SearchPage> {
children: [ children: [
ListTile( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.search),
title: Text("Search in".tl), title: Text("Search in".tl),
), ),
Wrap( Wrap(
@@ -197,8 +210,9 @@ class _SearchPageState extends State<SearchPage> {
children: sources.map((e) { children: sources.map((e) {
return OptionChip( return OptionChip(
text: e.name, text: e.name,
isSelected: searchTarget == e.key, isSelected: searchTarget == e.key || aggregatedSearch,
onTap: () { onTap: () {
if (aggregatedSearch) return;
setState(() { setState(() {
searchTarget = e.key; searchTarget = e.key;
useDefaultOptions(); useDefaultOptions();
@@ -207,6 +221,23 @@ class _SearchPageState extends State<SearchPage> {
); );
}).toList(), }).toList(),
), ),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Aggregated Search".tl),
leading: Checkbox(
value: aggregatedSearch,
onChanged: (value) {
setState(() {
aggregatedSearch = value ?? false;
if (!aggregatedSearch &&
appdata.settings['defaultSearchTarget'] ==
"_aggregated_") {
searchTarget = sources.first.key;
}
});
},
),
),
], ],
), ),
), ),
@@ -221,6 +252,10 @@ class _SearchPageState extends State<SearchPage> {
} }
Widget buildSearchOptions() { Widget buildSearchOptions() {
if (aggregatedSearch) {
return const SliverToBoxAdapter(child: SizedBox());
}
var children = <Widget>[]; var children = <Widget>[];
final searchOptions = final searchOptions =
@@ -257,78 +292,6 @@ class _SearchPageState extends State<SearchPage> {
); );
} }
Widget buildSearchHistory() {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
return const Divider(
thickness: 0.6,
).paddingTop(16);
}
if (index == 1) {
return ListTile(
leading: const Icon(Icons.history),
contentPadding: EdgeInsets.zero,
title: Text("Search History".tl),
trailing: Flyout(
flyoutBuilder: (context) {
return FlyoutContent(
title: "Clear Search History".tl,
actions: [
FilledButton(
child: Text("Clear".tl),
onPressed: () {
appdata.clearSearchHistory();
context.pop();
update();
},
)
],
);
},
child: Builder(
builder: (context) {
return Tooltip(
message: "Clear".tl,
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
context
.findAncestorStateOfType<FlyoutState>()!
.show();
},
),
);
},
),
),
);
}
return InkWell(
onTap: () {
search(appdata.searchHistory[index - 2]);
},
child: Container(
decoration: BoxDecoration(
// color: context.colorScheme.surfaceContainer,
border: Border(
left: BorderSide(
color: context.colorScheme.outlineVariant,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(appdata.searchHistory[index - 2], style: ts.s14),
),
).paddingBottom(8).paddingHorizontal(4);
},
childCount: 2 + appdata.searchHistory.length,
),
).sliverPaddingHorizontal(16);
}
Widget buildSuggestions(BuildContext context) { Widget buildSuggestions(BuildContext context) {
bool check(String text, String key, String value) { bool check(String text, String key, String value) {
if (text.removeAllBlank == "") { if (text.removeAllBlank == "") {
@@ -548,3 +511,130 @@ class SearchOptionWidget extends StatelessWidget {
); );
} }
} }
class _SearchHistory extends StatefulWidget {
const _SearchHistory(this.search);
final void Function(String) search;
@override
State<_SearchHistory> createState() => _SearchHistoryState();
}
class _SearchHistoryState extends State<_SearchHistory> {
@override
Widget build(BuildContext context) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index == 0) {
return const SizedBox(
height: 16,
);
}
if (index == 1) {
return ListTile(
leading: const Icon(Icons.history),
contentPadding: EdgeInsets.zero,
title: Text("Search History".tl),
trailing: Flyout(
flyoutBuilder: (context) {
return FlyoutContent(
title: "Clear Search History".tl,
actions: [
FilledButton(
child: Text("Clear".tl),
onPressed: () {
appdata.clearSearchHistory();
context.pop();
setState(() {});
},
)
],
);
},
child: Builder(
builder: (context) {
return Tooltip(
message: "Clear".tl,
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
context
.findAncestorStateOfType<FlyoutState>()!
.show();
},
),
);
},
),
),
);
}
return buildItem(index - 2);
},
childCount: 2 + appdata.searchHistory.length,
),
).sliverPaddingHorizontal(16);
}
Widget buildItem(int index) {
void showMenu(Offset offset) {
showMenuX(
context,
offset,
[
MenuEntry(
icon: Icons.copy,
text: 'Copy'.tl,
onClick: () {
Clipboard.setData(
ClipboardData(text: appdata.searchHistory[index]));
},
),
MenuEntry(
icon: Icons.delete,
text: 'Delete'.tl,
onClick: () {
appdata.removeSearchHistory(appdata.searchHistory[index]);
appdata.saveData();
setState(() {});
},
),
],
);
}
return Builder(builder: (context) {
return InkWell(
onTap: () {
widget.search(appdata.searchHistory[index]);
},
onLongPress: () {
var renderBox = context.findRenderObject() as RenderBox;
var offset = renderBox.localToGlobal(Offset.zero);
showMenu(Offset(
offset.dx + renderBox.size.width / 2 - 121,
offset.dy + renderBox.size.height - 8,
));
},
onSecondaryTapUp: (details) {
showMenu(details.globalPosition);
},
child: Container(
decoration: BoxDecoration(
// color: context.colorScheme.surfaceContainer,
border: Border(
left: BorderSide(
color: context.colorScheme.outlineVariant,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(appdata.searchHistory[index], style: ts.s14),
),
).paddingBottom(8).paddingHorizontal(4);
});
}
}

View File

@@ -14,14 +14,14 @@ class SearchResultPage extends StatefulWidget {
super.key, super.key,
required this.text, required this.text,
required this.sourceKey, required this.sourceKey,
required this.options, this.options,
}); });
final String text; final String text;
final String sourceKey; final String sourceKey;
final List<String> options; final List<String>? options;
@override @override
State<SearchResultPage> createState() => _SearchResultPageState(); State<SearchResultPage> createState() => _SearchResultPageState();
@@ -45,8 +45,9 @@ class _SearchResultPageState extends State<SearchResultPage> {
if (suggestionsController.entry != null) { if (suggestionsController.entry != null) {
suggestionsController.remove(); suggestionsController.remove();
} }
text = checkAutoLanguage(text);
setState(() { setState(() {
this.text = text; this.text = text!;
}); });
appdata.addSearchHistory(text); appdata.addSearchHistory(text);
controller.currentText = text; controller.currentText = text;
@@ -92,14 +93,34 @@ class _SearchResultPageState extends State<SearchResultPage> {
super.dispose(); super.dispose();
} }
String checkAutoLanguage(String text) {
var setting = appdata.settings["autoAddLanguageFilter"] ?? 'none';
if (setting == 'none') {
return text;
}
var searchSource = sourceKey;
// TODO: Move it to a better place
const enabledSources = [
'nhentai',
'ehentai',
];
if (!enabledSources.contains(searchSource)) {
return text;
}
if (!text.contains('language:')) {
return '$text language:$setting';
}
return text;
}
@override @override
void initState() { void initState() {
sourceKey = widget.sourceKey;
controller = SearchBarController( controller = SearchBarController(
currentText: widget.text, currentText: checkAutoLanguage(widget.text),
onSearch: search, onSearch: search,
); );
sourceKey = widget.sourceKey; options = widget.options ?? const [];
options = widget.options;
validateOptions(); validateOptions();
text = widget.text; text = widget.text;
appdata.addSearchHistory(text); appdata.addSearchHistory(text);
@@ -162,6 +183,12 @@ class _SearchResultPageState extends State<SearchResultPage> {
child: IconButton( child: IconButton(
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune),
onPressed: () async { onPressed: () async {
if (suggestionOverlay != null) {
suggestionsController.remove();
}
var previousOptions = options;
var previousSourceKey = sourceKey;
await showDialog( await showDialog(
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
@@ -169,7 +196,11 @@ class _SearchResultPageState extends State<SearchResultPage> {
return _SearchSettingsDialog(state: this); return _SearchSettingsDialog(state: this);
}, },
); );
if (previousOptions != options || previousSourceKey != sourceKey) {
text = checkAutoLanguage(controller.text);
controller.currentText = text;
setState(() {}); setState(() {});
}
}, },
), ),
); );

View File

@@ -61,6 +61,10 @@ class _AboutSettingsState extends State<AboutSettings> {
}, },
).fixHeight(32), ).fixHeight(32),
).toSliver(), ).toSliver(),
_SwitchSetting(
title: "Check for updates on startup".tl,
settingKey: "checkUpdateOnStart",
).toSliver(),
ListTile( ListTile(
title: const Text("Github"), title: const Text("Github"),
trailing: const Icon(Icons.open_in_new), trailing: const Icon(Icons.open_in_new),
@@ -68,6 +72,13 @@ class _AboutSettingsState extends State<AboutSettings> {
launchUrlString("https://github.com/venera-app/venera"); launchUrlString("https://github.com/venera-app/venera");
}, },
).toSliver(), ).toSliver(),
ListTile(
title: const Text("Telegram"),
trailing: const Icon(Icons.open_in_new),
onTap: () {
launchUrlString("https://t.me/venera_release");
},
).toSliver(),
], ],
); );
} }
@@ -95,7 +106,9 @@ Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
return ContentDialog( return ContentDialog(
title: "New version available".tl, title: "New version available".tl,
content: Text( content: Text(
"A new version is available. Do you want to update now?".tl), "A new version is available. Do you want to update now?"
.tl)
.paddingHorizontal(8),
actions: [ actions: [
Button.text( Button.text(
onPressed: () { onPressed: () {

View File

@@ -34,25 +34,8 @@ class _AppSettingsState extends State<AppSettings> {
callback: () async { callback: () async {
String? result; String? result;
if (App.isAndroid) { if (App.isAndroid) {
var channel = const MethodChannel("venera/storage"); var picker = DirectoryPicker();
var permission = await channel.invokeMethod(''); result = (await picker.pickDirectory())?.path;
if (permission != true) {
context.showMessage(message: "Permission denied".tl);
return;
}
var path = await selectDirectory();
if (path != null) {
// check if the path is writable
var testFile = File(FilePath.join(path, "test"));
try {
await testFile.writeAsBytes([1]);
await testFile.delete();
} catch (e) {
context.showMessage(message: "Permission denied".tl);
return;
}
result = path;
}
} else if (App.isIOS) { } else if (App.isIOS) {
result = await selectDirectoryIOS(); result = await selectDirectoryIOS();
} else { } else {
@@ -124,18 +107,25 @@ class _AppSettingsState extends State<AppSettings> {
actionTitle: 'Export'.tl, actionTitle: 'Export'.tl,
).toSliver(), ).toSliver(),
_CallbackSetting( _CallbackSetting(
title: "Import App Data".tl, title: "Import App Data (Please restart after success)".tl,
callback: () async { callback: () async {
var controller = showLoadingDialog(context); var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera']); var file = await selectFile(ext: ['venera', 'picadata']);
if (file != null) { if (file != null) {
var cacheFile = File(FilePath.join(App.cachePath, "temp.venera")); var cacheFile =
File(FilePath.join(App.cachePath, "import_data_temp"));
await file.saveTo(cacheFile.path); await file.saveTo(cacheFile.path);
try { try {
if (file.name.endsWith('picadata')) {
await importPicaData(cacheFile);
} else {
await importAppData(cacheFile); await importAppData(cacheFile);
}
} catch (e, s) { } catch (e, s) {
Log.error("Import data", e.toString(), s); Log.error("Import data", e.toString(), s);
context.showMessage(message: "Failed to import data".tl); context.showMessage(message: "Failed to import data".tl);
} finally {
cacheFile.deleteIgnoreError();
} }
} }
controller.close(); controller.close();

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