177 Commits

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

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

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

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

View File

@@ -7,6 +7,10 @@ body:
attributes:
value: |
Thank you for reporting a problem, please complete the title and fill in the following information.
**Please do not report any issues related to config files.**
To report a bug related to the config file, please send it to the [config repository](https://github.com/venera-app/venera-configs).
- type: textarea
id: what-happened
attributes:
@@ -19,7 +23,8 @@ body:
attributes:
label: Version
description: |
App version
App version.
Please try to update if it is not the latest version
validations:
required: true

View File

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

View File

@@ -1,5 +1,4 @@
# venera
[![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/)
[![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
@@ -13,7 +12,6 @@ A comic reader that support reading local and network comics.
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
## Features
- Read local comics
- Use javascript to create comic sources
- Read comics from network sources
@@ -23,15 +21,13 @@ A comic reader that support reading local and network comics.
- Login to comment, rate, and other operations if the source supports
## Build from source
1. Clone the repository
2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install)
3. Install rust, see [rustup.rs](https://rustup.rs/)
4. Build for your platform: e.g. `flutter build apk`
## Create a new comic source
See [venera-configs](https://github.com/venera-app/venera-configs)
See [Comic Source](doc/comic_source.md)
## Thanks

View File

@@ -83,20 +83,31 @@ android {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
}
signingConfig signingConfigs.release
applicationVariants.all { variant ->
variant.outputs.all { output ->
def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (abi != null) {
outputFileName = "venera-${variant.versionName}-${abi}.apk"
def abiVersionCode = project.ext.abiCodes.get(abi)
if (abiVersionCode != null) {
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
} else {
outputFileName = "venera-${variant.versionName}.apk"
versionCodeOverride = variant.versionCode * 10
}
debug {
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
}
signingConfig signingConfigs.debug
}
}
applicationVariants.all { variant ->
variant.outputs.all { output ->
def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (variant.buildType.name == "release") {
if (abi != null) {
outputFileName = "venera-${variant.versionName}-${abi}.apk"
def abiVersionCode = project.ext.abiCodes.get(abi)
if (abiVersionCode != null) {
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
} else {
outputFileName = "venera-${variant.versionName}.apk"
versionCodeOverride = variant.versionCode * 10
}
} else if (variant.buildType.name == "debug") {
versionCodeOverride = variant.versionCode * 10 + 4
}
}
}

View File

@@ -496,7 +496,7 @@ 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}}
* @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
*/
@@ -921,7 +921,7 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
* @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined}
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null
* @param isFavorite {boolean | null | undefined} - favorite status.
* @param subId {string?} - a param which is passed to comments api
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[]?} - related comics
@@ -1086,6 +1086,19 @@ class ComicSource {
});
}
translation = {}
/**
* Translate given string with the current locale using the translation object.
* @param key {string}
* @returns {string}
* @since 1.2.5
*/
translate(key) {
let locale = APP.locale;
return this.translation[locale]?.[key] ?? key;
}
init() { }
static sources = {}
@@ -1205,6 +1218,10 @@ class Image {
}
}
/**
* UI related apis
* @since 1.2.0
*/
let UI = {
/**
* Show a message
@@ -1222,7 +1239,9 @@ let UI = {
* Show a dialog. Any action will close the dialog.
* @param title {string}
* @param content {string}
* @param actions {{text:string, callback: () => void}[]}
* @param actions {{text:string, callback: () => void | Promise<void>, style: "text"|"filled"|"danger"}[]} - If callback returns a promise, the button will show a loading indicator until the promise is resolved.
* @returns {Promise<void>} - Resolved when the dialog is closed.
* @since 1.2.1
*/
showDialog: (title, content, actions) => {
sendMessage({
@@ -1245,4 +1264,97 @@ let UI = {
url: url,
})
},
/**
* Show a loading dialog.
* @param onCancel {() => void | null | undefined} - Called when the loading dialog is canceled. If [onCancel] is null, the dialog cannot be canceled by the user.
* @returns {number} - A number that can be used to cancel the loading dialog.
* @since 1.2.1
*/
showLoading: (onCancel) => {
return sendMessage({
method: 'UI',
function: 'showLoading',
onCancel: onCancel
})
},
/**
* Cancel a loading dialog.
* @param id {number} - returned by [showLoading]
* @since 1.2.1
*/
cancelLoading: (id) => {
sendMessage({
method: 'UI',
function: 'cancelLoading',
id: id
})
},
/**
* Show an input dialog
* @param title {string}
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
*/
showInputDialog: (title, validator) => {
return sendMessage({
method: 'UI',
function: 'showInputDialog',
title: title,
validator: validator
})
},
/**
* Show a select dialog
* @param title {string}
* @param options {string[]}
* @param initialIndex {number?}
* @returns {Promise<number | null>} - The selected index. If the dialog is canceled, return null.
*/
showSelectDialog: (title, options, initialIndex) => {
return sendMessage({
method: 'UI',
function: 'showSelectDialog',
title: title,
options: options,
initialIndex: initialIndex
})
}
}
/**
* App related apis
* @since 1.2.1
*/
let APP = {
/**
* Get the app version
* @returns {string} - The app version
*/
get version() {
return appVersion // defined in the engine
},
/**
* Get current app locale
* @returns {string} - The app locale, in the format of [languageCode]_[countryCode]
*/
get locale() {
return sendMessage({
method: 'getLocale'
})
},
/**
* Get current running platform
* @returns {string} - The platform name, "android", "ios", "windows", "macos", "linux"
*/
get platform() {
return sendMessage({
method: 'getPlatform'
})
}
}

View File

@@ -106,7 +106,8 @@
"Continuous (Right to Left)": "连续(从右到左)",
"Continuous (Top to Bottom)": "连续(从上到下)",
"Auto page turning interval": "自动翻页间隔",
"The number of pic in screen (Only Gallery Mode)": "同屏幕图片数量(仅画廊模式)",
"The number of pic in screen for landscape (Only Gallery Mode)": "横屏同屏幕图片数量(仅画廊模式)",
"The number of pic in screen for portrait (Only Gallery Mode)": "竖屏同屏幕图片数量(仅画廊模式)",
"Theme Mode": "主题模式",
"System": "系统",
"Light": "浅色",
@@ -139,8 +140,8 @@
"Block": "屏蔽",
"Add new favorite to": "添加新收藏到",
"Move favorite after reading": "阅读后移动收藏",
"Delete folder?" : "除文件夾?",
"Delete folder '@f' ?" : "删除文件夹 '@f' ",
"Delete folder?" : "除文件夹?",
"Delete folder '@f' ?" : "删除文件夹 '@f' ?",
"Import from file": "从文件导入",
"Failed to import": "导入失败",
"Cache Limit": "缓存限制",
@@ -148,11 +149,6 @@
"Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
"Help": "帮助",
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有当目录满足以下条件之一时,才被视为漫画:",
"1. The directory only contains image files." : "1. 目录只包含图片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n",
"Export as cbz": "导出为cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一个归档文件",
@@ -165,7 +161,7 @@
"Date Desc": "日期降序",
"Start": "开始",
"Export App Data": "导出应用数据",
"Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)",
"Import App Data": "导入应用数据",
"Export": "导出",
"Download Threads": "下载线程数",
"Update Time": "更新时间",
@@ -194,6 +190,7 @@
"Operation": "操作",
"Upload": "上传",
"Saved": "已保存",
"Saved Failed": "保存失败",
"Sync Data": "同步数据",
"Syncing Data": "正在同步数据",
"Data Sync": "数据同步",
@@ -234,7 +231,7 @@
"Clear History": "清除历史",
"Are you sure you want to clear your history?": "确定要清除您的历史记录吗?",
"No Explore Pages": "没有探索页面",
"Add a comic source in home page": "在主页添加一个漫画源",
"Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面",
"Chapter @ep": "第 @ep 章",
@@ -318,7 +315,54 @@
"Imported @a comics": "已导入 @a 本漫画",
"New Version": "新版本",
"@c updates": "@c 项更新",
"No updates": "无更新"
"No updates": "无更新",
"Set comic source list url": "设置漫画源列表URL",
"Deselect All": "取消全选",
"Add keyword": "添加关键词",
"Keyword": "关键词",
"Manage": "管理",
"Verify": "验证",
"Cloudflare verification required": "需要Cloudflare验证",
"Success": "成功",
"Compressing": "压缩中",
"Exporting": "导出中",
"Search Sources": "搜索源",
"Removed": "已移除",
"Added to favorites": "已添加到收藏",
"Not added": "未添加",
"Create a folder": "新建收藏夹",
"Created successfully": "创建成功",
"name": "名称",
"Reverse tap to turn Pages": "反转点击翻页",
"Show all": "显示全部",
"Number of images preloaded": "预加载图片数量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading": "上次阅读",
"Replies": "回复",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates." : "选择一个文件夹以追更",
"Choose Folder": "选择文件夹",
"No folders available": "没有可用的文件夹",
"Updating comics...": "更新漫画中...",
"Automatic update checking enabled." : "已启用自动更新检查",
"The app will check for updates at most once a day." : "APP将每天最多检查一次更新",
"Change Folder": "更改文件夹",
"Check Now": "立即检查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫画",
"The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新",
"Disable": "禁用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功后, APP将自动与服务器同步数据",
"Cache cleared": "缓存已清除",
"Disabled": "已禁用",
"WebDAV Auto Sync": "WebDAV 自动同步",
"Mark all as read": "全部标记为已读",
"Do you want to mark all as read?" : "您要全部标记为已读吗?",
"Swipe down for previous chapter": "向下滑动查看上一章",
"Swipe up for next chapter": "向上滑动查看下一章"
},
"zh_TW": {
"Home": "首頁",
@@ -328,7 +372,7 @@
"Settings": "設定",
"Search": "搜尋",
"History": "歷史",
"Local": "本",
"Local": "本",
"Import": "匯入",
"Comic Source": "漫畫源",
"Accounts": "帳戶",
@@ -340,14 +384,14 @@
"help": "幫助",
"Select": "選擇",
"Selected @a comics": "已選擇 @a 部漫畫",
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 載 @b 頁, 接收到 @c 部漫畫",
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 載 @b 頁, 接收到 @c 部漫畫",
"Downloading": "下載中",
"Back": "後退",
"Delete": "刪除",
"Full Screen": "全螢幕",
"Auto Page Turning": "自動翻頁",
"Chapters": "章節",
"Save Image": "存圖片",
"Save Image": "存圖片",
"Share": "分享",
"Details": "詳情",
"Description": "描述",
@@ -355,19 +399,19 @@
"Add to favorites": "加入收藏",
"Error": "錯誤",
"Retry": "重試",
"Folders": "文件夾",
"Delete Folder": "刪除文件夾",
"Folders": "資料夾",
"Delete Folder": "刪除資料夾",
"Rename": "重新命名",
"Reorder": "重新排序",
"Network": "網路",
"more": "更多",
"Select a folder": "選擇一個文件夾",
"Folder": "文件夾",
"Select a folder": "選擇一個資料夾",
"Folder": "資料夾",
"Confirm": "確認",
"Remove comic from favorite?": "從收藏中移除漫畫?",
"Move": "移動",
"Move to folder": "移動到文件夾",
"Copy to folder": "複製到文件夾",
"Move to folder": "移動到資料夾",
"Copy to folder": "複製到資料夾",
"Delete Comic": "刪除漫畫",
"Delete @c comics?": "刪除 @c 本漫畫?",
"Add comic source": "添加漫畫源",
@@ -379,43 +423,43 @@
"Check updates": "檢查更新",
"Edit": "編輯",
"Update": "更新",
"Log in": "登",
"Log in": "登",
"Log out": "登出",
"Re-login": "重新登",
"Click if login expired": "點擊此處如果登已過期",
"Login": "登",
"Username": "用戶名",
"Re-login": "重新登",
"Click if login expired": "點擊此處如果登已過期",
"Login": "登",
"Username": "使用者名稱",
"Password": "密碼",
"Continue": "繼續",
"Create Account": "建帳戶",
"Create Account": "建帳戶",
"Next": "前進",
"Login with webview": "過網頁登",
"Login with webview": "過網頁登",
"Read": "閱讀",
"Download": "下載",
"Favorite": "收藏",
"Comments": "評論",
"Information": "信息",
"Information": "資訊",
"Uploader": "上傳者",
"Upload Time": "上傳時間",
"Preview": "預覽",
"Comment": "評論",
"Submit": "提交",
"Add": "添加",
"New Folder": "新建文件夾",
"New Folder": "建立資料夾",
"Reading": "閱讀中",
"Appearance": "外觀",
"Local Favorites": "本收藏",
"Local Favorites": "本收藏",
"APP": "應用",
"About": "關於",
"Display mode of comic tile": "漫畫縮圖的顯示模式",
"Display mode of comic tile": "漫畫縮圖的顯示模式",
"Detailed": "詳細",
"Brief": "簡潔",
"Size of comic tile": "漫畫縮圖的大小",
"Size of comic tile": "漫畫縮圖的大小",
"Explore Pages": "探索頁面",
"Category Pages": "分類頁面",
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
"Keyword blocking": "關鍵詞屏蔽",
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
"Keyword blocking": "關鍵字封鎖",
"Tap to turn Pages": "點擊翻頁",
"Page animation": "頁面動畫",
"Reading mode": "閱讀模式",
@@ -426,10 +470,11 @@
"Continuous (Right to Left)": "連續(從右到左)",
"Continuous (Top to Bottom)": "連續(從上到下)",
"Auto page turning interval": "自動翻頁間隔",
"The number of pic in screen (Only Gallery Mode)": "同螢幕圖片數量(僅畫廊模式)",
"The number of pic in screen for landscape (Only Gallery Mode)": "橫向同螢幕圖片數量(僅畫廊模式)",
"The number of pic in screen for portrait (Only Gallery Mode)": "直向同螢幕圖片數量(僅畫廊模式)",
"Theme Mode": "主題模式",
"System": "系統",
"Light": "色",
"Light": "色",
"Dark": "深色",
"Theme Color": "主題顏色",
"Red": "紅色",
@@ -439,40 +484,35 @@
"Orange": "橙色",
"Blue": "藍色",
"App": "應用",
"Data": "數據",
"Storage Path for local comics": "本漫畫的儲路徑",
"Set New Storage Path": "設新的儲路徑",
"Set": "設",
"Cache Size": "緩存大小",
"Clear Cache": "清除緩存",
"Data": "資料",
"Storage Path for local comics": "本漫畫的儲路徑",
"Set New Storage Path": "設新的儲路徑",
"Set": "設",
"Cache Size": "快取大小",
"Clear Cache": "清除快取",
"Clear": "清除",
"Log": "日誌",
"Open Log": "打開日誌",
"Open": "打開",
"User": "用戶",
"User": "使用者",
"Language": "語言",
"Proxy": "代理",
"Venera is a free and open-source app for comic reading.": "Venera是一個免費的開源漫畫閱讀應用。",
"Check for updates": "檢查更新",
"Check": "檢查",
"Network Favorite Pages": "網路收藏頁面",
"Block": "屏蔽",
"Block": "封鎖",
"Add new favorite to": "添加新收藏到",
"Move favorite after reading": "閱讀後移動收藏",
"Delete folder?" : "刪除文件夾?",
"Delete folder '@f' ?" : "刪除文件夾 '@f' ",
"Delete folder?" : "刪除資料夾?",
"Delete folder '@f' ?" : "刪除資料夾 '@f' ",
"Import from file": "從文件匯入",
"Failed to import": "匯入失敗",
"Cache Limit": "緩存限制",
"Set Cache Limit": "設置緩存限制",
"Cache Limit": "快取限制",
"Set Cache Limit": "設定快取限制",
"Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫資料夾的目錄",
"Help": "幫助",
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有當目錄滿足以下條件之一時,才被視為漫畫:",
"1. The directory only contains image files." : "1. 目錄只包含圖片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n",
"Export as cbz": "匯出為cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一個歸檔文件",
@@ -485,15 +525,15 @@
"Date Desc": "日期降序",
"Start": "開始",
"Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據",
"Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)",
"Export App Data": "匯出應用資料",
"Import App Data": "匯入應用資料",
"Export": "匯出",
"Download Threads": "下載線程數",
"Download Threads": "下載執行緒數",
"Update Time": "更新時間",
"Copy ID": "複製ID",
"Copy URL": "複製URL",
"Create": "建",
"Folder Name": "文件夾名稱",
"Create": "建",
"Folder Name": "資料夾名稱",
"Ranking": "排行",
"Download Selected": "下載選中",
"Download All": "下載全部",
@@ -504,9 +544,9 @@
"Updates Available": "更新可用",
"Unselected": "未選擇",
"Long press and drag to reorder.": "長按並拖動以重新排序。",
"Limit image width": "限圖片寬度",
"Limit image width": "限圖片寬度",
"When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式",
"Open link": "打開鏈接",
"Open link": "打開連結",
"Open comic": "打開漫畫",
"Move To First": "移動到最前",
"Cancel": "取消",
@@ -514,15 +554,16 @@
"Pause": "暫停",
"Operation": "操作",
"Upload": "上傳",
"Saved": "已存",
"Sync Data": "同步數據",
"Syncing Data": "正在同步數據",
"Data Sync": "數據同步",
"Saved": "已存",
"Saved Failed": "儲存失敗",
"Sync Data": "同步資料",
"Syncing Data": "正在同步資料",
"Data Sync": "資料同步",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個文件夾",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
"Added": "已添加",
"Turn page by volume keys": "使用音量鍵翻頁",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量信息",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量資訊",
"EhViewer downloads": "EhViewer下載",
"Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料匯出的db檔案與存放下載內容的目錄",
"(EhViewer)Default": "(EhViewer)預設",
@@ -536,45 +577,45 @@
"Select in range": "區間選擇",
"Finished": "已完成",
"Updating": "更新中",
"Update Comics Info": "更新漫畫信息",
"Create Folder": "新建文件夾",
"Select an image on screen": "選擇幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載列",
"Update Comics Info": "更新漫畫資訊",
"Create Folder": "建立資料夾",
"Select an image on screen": "選擇幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載列",
"Authorization Required": "需要身份驗證",
"Sync": "同步",
"The folder is Linked to @source": "文件夾已關聯到 @source",
"Source Folder": "源文件夾",
"Use a config file": "使用配置文件",
"The folder is Linked to @source": "資料夾已關聯到 @source",
"Source Folder": "來源資料夾",
"Use a config file": "使用設定檔",
"Comic Source list": "漫畫源列表",
"View": "查看",
"Copy": "複製",
"Copied": "已複製",
"Search History": "搜歷史",
"Clear Search History": "清除搜歷史",
"Search in": "搜於",
"Search History": "搜歷史",
"Clear Search History": "清除搜歷史",
"Search in": "搜於",
"Clear History": "清除歷史",
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
"No Explore Pages": "沒有探索頁面",
"Add a comic source in home page": "在主頁添加一個漫畫源",
"Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁",
"Also remove files on disk": "同時刪除磁上的文件",
"Copy to app local path": "將漫畫複製到本儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本收藏",
"Also remove files on disk": "同時刪除磁上的文件",
"Copy to app local path": "將漫畫複製到本儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本收藏",
"Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf",
"Export as epub": "匯出為epub",
"Aggregated Search": "聚合搜",
"No search results found": "未找到搜結果",
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載列",
"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": "本收藏暫不支",
"Local comic collection is not supported at present": "本收藏暫不支",
"The cover cannot be uncollected here": "封面不能在此取消收藏",
"Uncollected the image": "取消收藏圖片",
"Successfully collected": "收藏成功",
@@ -583,7 +624,7 @@
"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": "漫畫的章節順序可能發生了變化, 暫不支收藏此章節",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支收藏此章節",
"Author: ": "作者: ",
"Tags: ": "標籤: ",
"Comics(number): ": "漫畫(數量): ",
@@ -591,7 +632,7 @@
"Time Filter": "時間篩選",
"Image Favorites Greater Than": "圖片收藏數大於",
"Collection time": "收藏時間",
"Not enable": "不用",
"Not enable": "不用",
"Double Tap": "雙擊",
"Swipe": "滑動",
"favoritesCompareComicPages": "收藏數與漫畫頁數比較",
@@ -602,7 +643,7 @@
"Favorite Num": "收藏數",
"Favorite Num Compare Comic Pages": "收藏數比漫畫頁數",
"All": "全部",
"Last Week": "上",
"Last Week": "上",
"Last Month": "上月",
"Last Half Year": "半年",
"Last Year": "一年",
@@ -623,22 +664,69 @@
"No valid comics found" : "未找到有效的漫畫",
"Enable DNS Overrides": "啟用DNS覆寫",
"DNS Overrides": "DNS覆寫",
"Custom Image Processing": "自定義圖片處理",
"Custom Image Processing": "自圖片處理",
"Enable": "啟用",
"Aggregated": "聚合",
"Default Search Target": "默認搜索目標",
"Default Search Target": "預設搜尋目標",
"Auto Language Filters": "自動語言篩選",
"Check for updates on startup": "啟動時檢查更新",
"Start Time": "開始時間",
"End Time": "結束時間",
"Custom": "自定義",
"Reset": "重",
"Custom": "自",
"Reset": "重",
"Tags": "標籤",
"Authors": "作者",
"Comics": "漫畫",
"Imported @a comics": "已匯入 @a 部漫畫",
"New Version": "新版本",
"@c updates": "@c 項更新",
"No updates": "無更新"
"No updates": "無更新",
"Set comic source list url": "設定漫畫源列表URL",
"Deselect All": "取消全選",
"Add keyword": "添加關鍵字",
"Keyword": "關鍵字",
"Manage": "管理",
"Verify": "驗證",
"Cloudflare verification required": "需要Cloudflare驗證",
"Success": "成功",
"Compressing": "壓縮中",
"Exporting": "匯出中",
"Search Sources": "搜尋源",
"Removed": "已移除",
"Added to favorites": "已添加到收藏",
"Not added": "未添加",
"Create a folder": "建立收藏夾",
"Created successfully": "建立成功",
"name": "名稱",
"Reverse tap to turn Pages": "反轉點擊翻頁",
"Show all": "顯示全部",
"Number of images preloaded": "預載入圖片數量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading": "上次閱讀",
"Replies": "回覆",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates." : "選擇一個資料夾以追更",
"Choose Folder": "選擇資料夾",
"No folders available": "沒有可用的資料夾",
"Updating comics...": "更新漫畫中...",
"Automatic update checking enabled." : "已啟用自動更新檢查",
"The app will check for updates at most once a day." : "APP將每天最多檢查一次更新",
"Change Folder": "更改資料夾",
"Check Now": "立即檢查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫畫",
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
"Disable": "停用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與服務器同步數據",
"Cache cleared": "緩存已清除",
"Disabled": "已禁用",
"WebDAV Auto Sync": "WebDAV 自動同步",
"Mark all as read": "全部標記為已讀",
"Do you want to mark all as read?" : "您要全部標記為已讀嗎?",
"Swipe down for previous chapter": "向下滑動查看上一章",
"Swipe up for next chapter": "向上滑動查看下一章"
}
}
}

11
debian/build.py vendored
View File

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

6
debian/debian.yaml vendored
View File

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

View File

@@ -5,4 +5,5 @@ Comment=venera
Terminal=false
Type=Application
Categories=Utility
Keywords=Flutter;comic;images;
Keywords=Flutter;comic;images;
Icon=venera

655
doc/comic_source.md Normal file
View File

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

61
doc/import_comic.md Normal file
View File

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

513
doc/js_api.md Normal file
View File

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

View File

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

View File

@@ -1,12 +1,14 @@
part of 'components.dart';
class Appbar extends StatefulWidget implements PreferredSizeWidget {
const Appbar(
{required this.title,
this.leading,
this.actions,
this.backgroundColor,
super.key});
const Appbar({
required this.title,
this.leading,
this.actions,
this.backgroundColor,
this.style = AppbarStyle.blur,
super.key,
});
final Widget title;
@@ -16,6 +18,8 @@ class Appbar extends StatefulWidget implements PreferredSizeWidget {
final Color? backgroundColor;
final AppbarStyle style;
@override
State<Appbar> createState() => _AppbarState();
@@ -108,10 +112,18 @@ class _AppbarState extends State<Appbar> {
],
).paddingTop(context.padding.top),
);
return BlurEffect(
blur: _scrolledUnder ? 15 : 0,
child: content,
);
if (widget.style == AppbarStyle.shadow) {
return Material(
color: context.colorScheme.surface,
elevation: _scrolledUnder ? 2 : 0,
child: content,
);
} else {
return BlurEffect(
blur: _scrolledUnder ? 15 : 0,
child: content,
);
}
}
}
@@ -256,18 +268,28 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
}
}
class FilledTabBar extends StatefulWidget {
const FilledTabBar({super.key, this.controller, required this.tabs});
class AppTabBar extends StatefulWidget {
const AppTabBar({
super.key,
this.controller,
required this.tabs,
this.actionButton,
this.withUnderLine = true,
});
final TabController? controller;
final List<Tab> tabs;
final Widget? actionButton;
final bool withUnderLine;
@override
State<FilledTabBar> createState() => _FilledTabBarState();
State<AppTabBar> createState() => _AppTabBarState();
}
class _FilledTabBarState extends State<FilledTabBar> {
class _AppTabBarState extends State<AppTabBar> {
late TabController _controller;
late List<GlobalKey> keys;
@@ -315,7 +337,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
}
@override
void didUpdateWidget(covariant FilledTabBar oldWidget) {
void didUpdateWidget(covariant AppTabBar oldWidget) {
if (widget.controller != oldWidget.controller) {
_controller = widget.controller ?? DefaultTabController.of(context);
_controller.animation!.addListener(onTabChanged);
@@ -366,25 +388,29 @@ class _FilledTabBarState extends State<FilledTabBar> {
painter: painter,
child: _TabRow(
callback: _tabLayoutCallback,
children: List.generate(widget.tabs.length, buildTab),
children: List.generate(widget.tabs.length, buildTab)
..addIfNotNull(widget.actionButton?.padding(tabPadding)),
),
).paddingHorizontal(4),
);
},
);
return Container(
key: tabBarKey,
height: _kTabHeight,
width: double.infinity,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: widget.tabs.isEmpty ? const SizedBox() : child);
key: tabBarKey,
height: _kTabHeight,
width: double.infinity,
decoration: widget.withUnderLine
? BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
)
: null,
child: widget.tabs.isEmpty ? const SizedBox() : child,
);
}
int? previousIndex;
@@ -544,7 +570,7 @@ class _IndicatorPainter extends CustomPainter {
var rect = Rect.fromLTWH(
tabLeft + padding.left + horizontalPadding,
_FilledTabBarState._kTabHeight - 3.6,
_AppTabBarState._kTabHeight - 3.6,
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
3,
);
@@ -606,6 +632,7 @@ class _TabViewBodyState extends State<TabViewBody> {
void didChangeDependencies() {
super.didChangeDependencies();
_controller = widget.controller ?? DefaultTabController.of(context);
_currentIndex = _controller.index;
_controller.addListener(updateIndex);
}
@@ -621,7 +648,6 @@ class _TabViewBodyState extends State<TabViewBody> {
}
}
class SearchBarController {
_SearchBarMixin? _state;
@@ -894,3 +920,42 @@ class _SearchBarState extends State<AppSearchBar> with _SearchBarMixin {
);
}
}
class TabActionButton extends StatelessWidget {
const TabActionButton({
super.key,
required this.icon,
required this.text,
required this.onPressed,
});
final Icon icon;
final String text;
final void Function() onPressed;
static const _kTabHeight = 46.0;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Container(
height: _kTabHeight,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: IconTheme(
data: IconThemeData(size: 20, color: context.colorScheme.primary),
child: Row(
children: [
icon,
const SizedBox(width: 8),
Text(text, style: ts.withColor(context.colorScheme.primary)),
],
),
),
),
);
}
}

View File

@@ -55,7 +55,7 @@ class _CodeEditorState extends State<CodeEditor> {
Widget buildLineNumbers() {
return SizedBox(
width: 32,
width: 36,
child: Column(
children: [
for (var i = 1; i <= lineCount; i++)

View File

@@ -23,14 +23,16 @@ ImageProvider? _findImageProvider(Comic comic) {
}
class ComicTile extends StatelessWidget {
const ComicTile(
{super.key,
required this.comic,
this.enableLongPressed = true,
this.badge,
this.menuOptions,
this.onTap,
this.onLongPressed});
const ComicTile({
super.key,
required this.comic,
this.enableLongPressed = true,
this.badge,
this.menuOptions,
this.onTap,
this.onLongPressed,
this.heroID,
});
final Comic comic;
@@ -44,6 +46,8 @@ class ComicTile extends StatelessWidget {
final VoidCallback? onLongPressed;
final int? heroID;
void _onTap() {
if (onTap != null) {
onTap!();
@@ -55,6 +59,7 @@ class ComicTile extends StatelessWidget {
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
heroID: heroID,
),
);
}
@@ -137,8 +142,7 @@ class ComicTile extends StatelessWidget {
.isExist(comic.id, ComicType(comic.sourceKey.hashCode))
: false;
var history = appdata.settings['showHistoryStatusOnTile']
? HistoryManager()
.findSync(comic.id, ComicType(comic.sourceKey.hashCode))
? HistoryManager().find(comic.id, ComicType(comic.sourceKey.hashCode))
: null;
if (history?.page == 0) {
history!.page = 1;
@@ -210,63 +214,94 @@ class ComicTile extends StatelessWidget {
Widget _buildDetailedMode(BuildContext context) {
return LayoutBuilder(builder: (context, constrains) {
final height = constrains.maxHeight - 16;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _onTap,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row(
children: [
Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
width: height * 0.68,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
),
SizedBox.fromSize(
size: const Size(16, 5),
),
Expanded(
child: _ComicDescription(
title: comic.maxPage == null
? comic.title.replaceAll("\n", "")
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
subtitle: comic.subtitle ?? '',
description: comic.description,
badge: badge ?? comic.language,
tags: comic.tags,
maxLines: 2,
enableTranslate: ComicSource.find(comic.sourceKey)
?.enableTagsTranslate ??
false,
rating: comic.stars,
),
),
],
Widget image = Container(
width: height * 0.68,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
));
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
);
if (heroID != null) {
image = Hero(
tag: "cover$heroID",
child: image,
);
}
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _onTap,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row(
children: [
image,
SizedBox.fromSize(
size: const Size(16, 5),
),
Expanded(
child: _ComicDescription(
title: comic.maxPage == null
? comic.title.replaceAll("\n", "")
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
subtitle: comic.subtitle ?? '',
description: comic.description,
badge: badge ?? comic.language,
tags: comic.tags,
maxLines: 2,
enableTranslate:
ComicSource.find(comic.sourceKey)?.enableTagsTranslate ??
false,
rating: comic.stars,
),
),
],
),
),
);
});
}
Widget _buildBriefMode(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
Widget image = Container(
decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.toOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
);
if (heroID != null) {
image = Hero(
tag: "cover$heroID",
child: image,
);
}
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _onTap,
@@ -278,24 +313,7 @@ class ComicTile extends StatelessWidget {
child: Stack(
children: [
Positioned.fill(
child: Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.toOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
),
child: image,
),
Align(
alignment: Alignment.bottomRight,
@@ -356,14 +374,13 @@ class ComicTile extends StatelessWidget {
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
child: TextScroll(
child: Text(
comic.title.replaceAll('\n', ''),
mode: TextScrollMode.endless,
maxLines: 1,
overflow: TextOverflow.clip,
style: const TextStyle(
fontWeight: FontWeight.w500,
),
delayBefore: Duration(milliseconds: 500),
velocity: const Velocity(pixelsPerSecond: Offset(40, 0)),
),
),
],
@@ -446,7 +463,9 @@ class ComicTile extends StatelessWidget {
children: [
for (var word in all)
OptionChip(
text: word,
text: (comic.tags?.contains(word) ?? false)
? word.translateTagIfNeed
: word,
isSelected: words.contains(word),
onTap: () {
setState(() {
@@ -539,10 +558,8 @@ class _ComicDescription extends StatelessWidget {
softWrap: true,
overflow: TextOverflow.ellipsis,
),
const SizedBox(
height: 4,
),
if (tags != null)
const SizedBox(height: 4),
if (tags != null && tags!.isNotEmpty)
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
if (constraints.maxHeight < 22) {
@@ -551,7 +568,7 @@ class _ComicDescription extends StatelessWidget {
int cnt = (constraints.maxHeight - 22).toInt() ~/ 25;
return Container(
clipBehavior: Clip.antiAlias,
height: 22 + cnt * 25,
height: 21 + cnt * 24,
width: double.infinity,
decoration: const BoxDecoration(),
child: Wrap(
@@ -563,31 +580,30 @@ class _ComicDescription extends StatelessWidget {
children: [
for (var s in tags!)
Container(
height: 22,
padding: const EdgeInsets.fromLTRB(3, 2, 3, 2),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45,
height: 21,
padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45,
),
decoration: BoxDecoration(
color: s == "Unavailable"
? context.colorScheme.errorContainer
: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
decoration: BoxDecoration(
color: s == "Unavailable"
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius:
const BorderRadius.all(Radius.circular(8)),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
))),
),
),
],
),
).toAlign(Alignment.topCenter);
@@ -608,23 +624,26 @@ class _ComicDescription extends StatelessWidget {
style: const TextStyle(
fontSize: 12.0,
),
maxLines: (tags == null || tags!.isEmpty) ? 3 : 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (badge != null)
Container(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(8)),
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Center(
child: Text(
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
style: const TextStyle(fontSize: 12),
),
child: Center(
child: Text(
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
style: const TextStyle(fontSize: 12),
),
)),
),
),
],
)
],
@@ -738,16 +757,27 @@ class SliverGridComics extends StatefulWidget {
class _SliverGridComicsState extends State<SliverGridComics> {
List<Comic> comics = [];
List<int> heroIDs = [];
static int _nextHeroID = 0;
void generateHeroID() {
heroIDs.clear();
for (var i = 0; i < comics.length; i++) {
heroIDs.add(_nextHeroID++);
}
}
@override
void didUpdateWidget(covariant SliverGridComics oldWidget) {
if (oldWidget.comics != widget.comics) {
if (!oldWidget.comics.isEqualTo(widget.comics)) {
comics.clear();
for (var comic in widget.comics) {
if (isBlocked(comic) == null) {
comics.add(comic);
}
}
generateHeroID();
}
super.didUpdateWidget(oldWidget);
}
@@ -759,6 +789,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
comics.add(comic);
}
}
generateHeroID();
HistoryManager().addListener(update);
super.initState();
}
@@ -784,6 +815,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
Widget build(BuildContext context) {
return _SliverGridComics(
comics: comics,
heroIDs: heroIDs,
selection: widget.selections,
onLastItemBuild: widget.onLastItemBuild,
badgeBuilder: widget.badgeBuilder,
@@ -797,6 +829,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
class _SliverGridComics extends StatelessWidget {
const _SliverGridComics({
required this.comics,
required this.heroIDs,
this.onLastItemBuild,
this.badgeBuilder,
this.menuBuilder,
@@ -807,6 +840,8 @@ class _SliverGridComics extends StatelessWidget {
final List<Comic> comics;
final List<int> heroIDs;
final Map<Comic, bool>? selection;
final void Function()? onLastItemBuild;
@@ -838,6 +873,7 @@ class _SliverGridComics extends StatelessWidget {
onLongPressed: onLongPressed != null
? () => onLongPressed!(comics[index])
: null,
heroID: heroIDs[index],
);
if (selection == null) {
return comic;
@@ -1521,14 +1557,15 @@ class SimpleComicTile extends StatelessWidget {
return AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ?? () {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
onTap: onTap ??
() {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
child: Container(
width: 92,
height: 114,

View File

@@ -9,7 +9,6 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:syntax_highlight/syntax_highlight.dart';
import 'package:text_scroll/text_scroll.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/app_page_route.dart';
import 'package:venera/foundation/appdata.dart';
@@ -24,7 +23,7 @@ import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';

View File

@@ -58,26 +58,12 @@ class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
},
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
child: AnimatedPhysicalModel(
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),
),
],
),
elevation: isHovered ? 3 : 1,
color: context.colorScheme.surface,
shadowColor: context.colorScheme.shadow,
borderRadius: BorderRadius.circular(widget.borderRadius),
child: widget.child,
),
),

View File

@@ -277,35 +277,38 @@ class _AnimatedImageState extends State<AnimatedImage>
if (_imageInfo != null) {
if (widget.part != null) {
return CustomPaint(
result = CustomPaint(
isComplex: true,
painter: ImagePainter(
image: _imageInfo!.image,
part: widget.part!,
fit: widget.fit ?? BoxFit.cover,
),
child: SizedBox(
width: widget.width,
height: widget.height,
),
);
} else {
result = RawImage(
image: _imageInfo?.image,
width: widget.width,
height: widget.height,
debugImageLabel: _imageInfo?.debugLabel,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: BoxFit.cover,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
}
result = RawImage(
image: _imageInfo?.image,
width: widget.width,
height: widget.height,
debugImageLabel: _imageInfo?.debugLabel,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: BoxFit.cover,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
} else if (_lastException != null) {
result = const Center(
child: Icon(Icons.error),
@@ -362,10 +365,13 @@ class ImagePainter extends CustomPainter {
final ImagePart part;
final BoxFit fit;
/// Render a part of the image.
const ImagePainter({
required this.image,
this.part = const ImagePart(),
this.fit = BoxFit.cover,
});
@override
@@ -377,7 +383,8 @@ class ImagePainter extends CustomPainter {
part.y2 ?? image.height.toDouble(),
),
);
final Rect dst = Offset.zero & size;
var fitted = applyBoxFit(fit, Size(src.width, src.height), size).destination;
var dst = Alignment.center.inscribe(fitted, Offset.zero & size);
canvas.drawImageRect(image, src, dst, Paint());
}

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

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

View File

@@ -2,7 +2,10 @@ part of 'components.dart';
class SliverGridViewWithFixedItemHeight extends StatelessWidget {
const SliverGridViewWithFixedItemHeight(
{required this.delegate, required this.maxCrossAxisExtent, required this.itemHeight, super.key});
{required this.delegate,
required this.maxCrossAxisExtent,
required this.itemHeight,
super.key});
final SliverChildDelegate delegate;
@@ -62,7 +65,8 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
@override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || oldDelegate.itemHeight != itemHeight) {
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
oldDelegate.itemHeight != itemHeight) {
return true;
}
return false;
@@ -70,28 +74,29 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
}
class SliverGridDelegateWithComics extends SliverGridDelegate {
SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]);
SliverGridDelegateWithComics();
final bool useBriefMode;
final bool useBriefMode = appdata.settings['comicDisplayMode'] == 'brief';
final double? scale;
final double scale = (appdata.settings['comicTileScale'] as num).toDouble();
@override
SliverGridLayout getLayout(SliverConstraints constraints) {
if (appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode) {
if (useBriefMode) {
return getBriefModeLayout(
constraints,
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(),
scale,
);
} else {
return getDetailedModeLayout(
constraints,
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(),
scale,
);
}
}
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale) {
SliverGridLayout getDetailedModeLayout(
SliverConstraints constraints, double scale) {
const minCrossAxisExtent = 360;
final itemHeight = 152 * scale;
final width = constraints.crossAxisExtent;
@@ -106,11 +111,14 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
reverseCrossAxis: false);
}
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale) {
SliverGridLayout getBriefModeLayout(
SliverConstraints constraints, double scale) {
final maxCrossAxisExtent = 192.0 * scale;
const childAspectRatio = 0.68;
const childAspectRatio = 0.64;
const crossAxisSpacing = 0.0;
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
int crossAxisCount =
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
.ceil();
// Ensure a minimum count of 1, can be zero and result in an infinite extent
// below when the window size is 0.
crossAxisCount = math.max(1, crossAxisCount);
@@ -132,6 +140,26 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
@override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
return true;
if (oldDelegate is! SliverGridDelegateWithComics) return true;
if (oldDelegate.scale != scale ||
oldDelegate.useBriefMode != useBriefMode) {
return true;
}
return false;
}
}
class SliverLazyToBoxAdapter extends StatelessWidget {
/// Creates a sliver that contains a single box widget which can be lazy loaded.
const SliverLazyToBoxAdapter({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SliverList.list(children: [
SizedBox(),
child,
]);
}
}

View File

@@ -6,6 +6,7 @@ class NetworkError extends StatelessWidget {
required this.message,
this.retry,
this.withAppbar = true,
this.buttonText,
});
final String message;
@@ -14,6 +15,8 @@ class NetworkError extends StatelessWidget {
final bool withAppbar;
final String? buttonText;
@override
Widget build(BuildContext context) {
var cfe = CloudflareException.fromString(message);
@@ -54,13 +57,15 @@ class NetworkError extends StatelessWidget {
if (cfe != null)
FilledButton(
onPressed: () => passCloudflare(
CloudflareException.fromString(message)!, retry!),
CloudflareException.fromString(message)!,
retry!,
),
child: Text('Verify'.tl),
)
else
FilledButton(
onPressed: retry,
child: Text('Retry'.tl),
child: Text(buttonText ?? 'Retry'.tl),
),
],
),
@@ -127,7 +132,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
if (res.success) {
return res;
} else {
if(!mounted) return res;
if (!mounted) return res;
if (retry >= 3) {
return res;
}
@@ -185,7 +190,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
isLoading = true;
Future.microtask(() {
loadDataWithRetry().then((value) async {
if(!mounted) return;
if (!mounted) return;
if (value.success) {
data = value.data;
await onDataLoaded();
@@ -318,21 +323,11 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
}
Widget buildError(BuildContext context, String error) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(error, maxLines: 3),
const SizedBox(height: 12),
Button.outlined(
onPressed: () {
reset();
},
child: const Text("Retry"),
)
],
),
).paddingHorizontal(16);
return NetworkError(
withAppbar: false,
message: error,
retry: reset,
);
}
@override

View File

@@ -168,7 +168,15 @@ Future<void> showConfirmDialog({
}
class LoadingDialogController {
void Function()? closeDialog;
double? _progress;
String? _message;
void Function()? _closeDialog;
void Function(double? value)? _serProgress;
void Function(String message)? _setMessage;
bool closed = false;
@@ -177,63 +185,86 @@ class LoadingDialogController {
return;
}
closed = true;
if (closeDialog == null) {
Future.microtask(closeDialog!);
if (_closeDialog == null) {
Future.microtask(_closeDialog!);
} else {
closeDialog!();
_closeDialog!();
}
}
void setProgress(double? value) {
if (closed) {
return;
}
_serProgress?.call(value);
}
void setMessage(String message) {
if (closed) {
return;
}
_setMessage?.call(message);
}
}
LoadingDialogController showLoadingDialog(BuildContext context,
{void Function()? onCancel,
bool barrierDismissible = true,
bool allowCancel = true,
String? message,
String cancelButtonText = "Cancel"}) {
LoadingDialogController showLoadingDialog(
BuildContext context, {
void Function()? onCancel,
bool barrierDismissible = true,
bool allowCancel = true,
String? message,
String cancelButtonText = "Cancel",
bool withProgress = false,
}) {
var controller = LoadingDialogController();
controller._message = message;
if (withProgress) {
controller._progress = 0;
}
var loadingDialogRoute = DialogRoute(
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return Dialog(
child: Container(
width: 100,
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const SizedBox(
width: 30,
height: 30,
child: CircularProgressIndicator(),
),
const SizedBox(
width: 16,
),
Text(
message ?? 'Loading',
style: const TextStyle(fontSize: 16),
),
const Spacer(),
if (allowCancel)
TextButton(
onPressed: () {
controller.close();
onCancel?.call();
},
child: Text(cancelButtonText.tl))
],
),
),
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return StatefulBuilder(builder: (context, setState) {
controller._serProgress = (value) {
setState(() {
controller._progress = value;
});
};
controller._setMessage = (message) {
setState(() {
controller._message = message;
});
};
return ContentDialog(
title: controller._message ?? 'Loading',
content: LinearProgressIndicator(
value: controller._progress,
backgroundColor: context.colorScheme.surfaceContainer,
).paddingHorizontal(16).paddingVertical(16),
actions: [
FilledButton(
onPressed: allowCancel
? () {
controller.close();
onCancel?.call();
}
: null,
child: Text(cancelButtonText.tl),
)
],
);
});
},
);
var navigator = Navigator.of(context, rootNavigator: true);
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
controller.closeDialog = () {
controller._closeDialog = () {
navigator.removeRoute(loadingDialogRoute);
};
@@ -402,3 +433,57 @@ void showInfoDialog({
},
);
}
Future<int?> showSelectDialog({
required String title,
required List<String> options,
int? initialIndex,
}) async {
int? current = initialIndex;
await showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: title,
content: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Select(
current: current == null ? "" : options[current!],
values: options,
minWidth: 156,
onTap: (i) {
setState(() {
current = i;
});
},
)
],
),
),
actions: [
TextButton(
onPressed: () {
current = null;
context.pop();
},
child: Text('Cancel'.tl),
),
FilledButton(
onPressed: current == null ? null : context.pop,
child: Text('Confirm'.tl),
),
],
);
},
);
},
);
return current;
}

View File

@@ -98,8 +98,17 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
_controller.position.maxScrollExtent,
);
if (_futurePosition == old) return;
_controller.animateTo(_futurePosition!,
duration: _fastAnimationDuration, curve: Curves.linear);
var target = _futurePosition!;
_controller.animateTo(
_futurePosition!,
duration: _fastAnimationDuration,
curve: Curves.linear,
).then((_) {
var current = _controller.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
}
});
}
},
child: ScrollControllerProvider._(

View File

@@ -6,166 +6,99 @@ 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/state_controller.dart';
import 'package:window_manager/window_manager.dart';
const _kTitleBarHeight = 36.0;
class WindowFrameController extends StateController {
bool useDarkTheme = false;
bool isHideWindowFrame = false;
void setDarkTheme() {
useDarkTheme = true;
update();
}
void resetTheme() {
useDarkTheme = false;
update();
}
VoidCallback openSideBar = () {};
void hideWindowFrame() {
isHideWindowFrame = true;
update();
}
void showWindowFrame() {
isHideWindowFrame = false;
update();
}
}
class WindowFrame extends StatelessWidget {
class WindowFrame extends StatefulWidget {
const WindowFrame(this.child, {super.key});
final Widget child;
@override
Widget build(BuildContext context) {
StateController.putIfNotExists<WindowFrameController>(
WindowFrameController());
if (App.isMobile) return child;
return StateBuilder<WindowFrameController>(builder: (controller) {
if (controller.isHideWindowFrame) return child;
var body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
child: child,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
brightness: controller.useDarkTheme ? Brightness.dark : null,
),
child: Builder(builder: (context) {
return SizedBox(
height: _kTitleBarHeight,
child: Row(
children: [
if (App.isMacOS)
const DragToMoveArea(
child: SizedBox(
height: double.infinity,
width: 16,
),
).paddingRight(52)
else
const SizedBox(width: 12),
Expanded(
child: DragToMoveArea(
child: Text(
'Venera',
style: TextStyle(
fontSize: 13,
color: (controller.useDarkTheme ||
context.brightness == Brightness.dark)
? Colors.white
: Colors.black,
),
).toAlign(Alignment.centerLeft).paddingLeft(4+(App.isMacOS?25:0)),
),
),
if (kDebugMode)
const TextButton(
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) const WindowButtons()
],
),
);
}),
),
),
)
],
);
if (App.isLinux) {
return VirtualWindowFrame(child: body);
} else {
return body;
}
});
}
Widget buildMenuButton(
WindowFrameController controller, BuildContext context) {
return InkWell(
onTap: () {
controller.openSideBar();
},
child: SizedBox(
width: 42,
height: double.infinity,
child: Center(
child: CustomPaint(
size: const Size(18, 20),
painter: _MenuPainter(
color: (controller.useDarkTheme ||
Theme.of(context).brightness == Brightness.dark)
? Colors.white
: Colors.black),
),
),
));
}
State<WindowFrame> createState() => _WindowFrameState();
}
class _MenuPainter extends CustomPainter {
final Color color;
_MenuPainter({this.color = Colors.black});
class _WindowFrameState extends State<WindowFrame> {
bool isHideWindowFrame = false;
bool useDarkTheme = false;
@override
void paint(Canvas canvas, Size size) {
final paint = getPaint(color);
final path = Path()
..moveTo(0, size.height / 4)
..lineTo(size.width, size.height / 4)
..moveTo(0, size.height / 4 * 2)
..lineTo(size.width, size.height / 4 * 2)
..moveTo(0, size.height / 4 * 3)
..lineTo(size.width, size.height / 4 * 3);
canvas.drawPath(path, paint);
Widget build(BuildContext context) {
if (App.isMobile) return widget.child;
if (isHideWindowFrame) return widget.child;
var body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
child: widget.child,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
brightness: useDarkTheme ? Brightness.dark : null,
),
child: Builder(builder: (context) {
return SizedBox(
height: _kTitleBarHeight,
child: Row(
children: [
if (App.isMacOS)
const DragToMoveArea(
child: SizedBox(
height: double.infinity,
width: 16,
),
).paddingRight(52)
else
const SizedBox(width: 12),
Expanded(
child: DragToMoveArea(
child: Text(
'Venera',
style: TextStyle(
fontSize: 13,
color: (useDarkTheme ||
context.brightness == Brightness.dark)
? Colors.white
: Colors.black,
),
)
.toAlign(Alignment.centerLeft)
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
),
),
if (kDebugMode)
const TextButton(
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) const WindowButtons()
],
),
);
}),
),
),
)
],
);
if (App.isLinux) {
return VirtualWindowFrame(child: body);
} else {
return body;
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class WindowButtons extends StatefulWidget {
@@ -489,7 +422,7 @@ class WindowPlacement {
static Future<WindowPlacement> get current async {
var rect = await windowManager.getBounds();
if(validate(rect)) {
if (validate(rect)) {
lastValidRect = rect;
} else {
rect = lastValidRect ?? defaultPlacement.rect;
@@ -635,4 +568,4 @@ TransitionBuilder VirtualWindowFrameInit() {
void debug() {
ComicSource.reload();
}
}

View File

@@ -3,14 +3,17 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/history.dart';
import 'appdata.dart';
import 'favorites.dart';
import 'local.dart';
export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.2.0";
final version = "1.3.1";
bool get isAndroid => Platform.isAndroid;
@@ -51,8 +54,16 @@ class _App {
BuildContext get rootContext => rootNavigatorKey.currentContext!;
final Appdata data = appdata;
final HistoryManager history = HistoryManager();
final LocalFavoritesManager favorites = LocalFavoritesManager();
final LocalManager local = LocalManager();
void rootPop() {
rootNavigatorKey.currentState?.pop();
rootNavigatorKey.currentState?.maybePop();
}
void pop() {
@@ -66,6 +77,10 @@ class _App {
Future<void> init() async {
cachePath = (await getApplicationCacheDirectory()).path;
dataPath = (await getApplicationSupportDirectory()).path;
await data.init();
await history.init();
await favorites.init();
await local.init();
}
Function? _forceRebuildHandler;

View File

@@ -6,8 +6,8 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/io.dart';
class _Appdata {
final _Settings settings = _Settings();
class Appdata {
final Settings settings = Settings();
var searchHistory = <String>[];
@@ -90,13 +90,15 @@ class _Appdata {
/// Sync data from another device
void syncData(Map<String, dynamic> data) {
for (var key in data.keys) {
if (_disableSync.contains(key)) {
continue;
if (data['settings'] is Map) {
var settings = data['settings'] as Map<String, dynamic>;
for (var key in settings.keys) {
if (!_disableSync.contains(key)) {
this.settings[key] = settings[key];
}
}
settings[key] = data[key];
}
searchHistory = List.from(data['searchHistory']);
searchHistory = List.from(data['searchHistory'] ?? []);
saveData();
}
@@ -108,10 +110,10 @@ class _Appdata {
}
}
final appdata = _Appdata();
final appdata = Appdata();
class _Settings with ChangeNotifier {
_Settings();
class Settings with ChangeNotifier {
Settings();
final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief
@@ -124,14 +126,17 @@ class _Settings with ChangeNotifier {
'explore_pages': [],
'categories': [],
'favorites': [],
'searchSources': null,
'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false,
'blockedWords': [],
'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumber': 1, // 1 - 5
'readerScreenPicNumberForLandscape': 1, // 1 - 5
'readerScreenPicNumberForPortrait': 1, // 1 - 5
'enableTapToTurnPages': true,
'reverseTapToTurnPages': false,
'enablePageAnimation': true,
'language': 'system', // system, zh-CN, zh-TW, en-US
'cacheSize': 2048, // in MB
@@ -153,6 +158,9 @@ class _Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'preloadImageCount': 4,
'followUpdatesFolder': null,
};
operator [](String key) {
@@ -181,9 +189,9 @@ const defaultCustomImageProcessing = '''
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
*/
function processImage(image, cid, eid, page, sourceKey) {
let image = new Promise((resolve, reject) => {
let futureImage = new Promise((resolve, reject) => {
resolve(image);
});
return image;
return futureImage;
}
''';

View File

@@ -417,7 +417,7 @@ class SearchOptions {
const SearchOptions(this.options, this.label, this.type, this.defaultVal);
String get defaultValue => defaultVal ?? options.keys.first;
String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? "";
}
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(

View File

@@ -37,6 +37,8 @@ class FavoriteData {
final AddOrDelFavFunc? addOrDelFavorite;
final bool singleFolderForSingleComic;
const FavoriteData({
required this.key,
required this.title,
@@ -49,6 +51,7 @@ class FavoriteData {
this.allFavoritesId,
this.addOrDelFavorite,
this.isOldToNewSort,
this.singleFolderForSingleComic = false,
});
}

View File

@@ -128,7 +128,7 @@ class ComicDetails with HistoryMixin {
final Map<String, List<String>> tags;
/// id-name
final Map<String, String>? chapters;
final ComicChapters? chapters;
final List<String>? thumbnails;
@@ -177,9 +177,7 @@ class ComicDetails with HistoryMixin {
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
chapters = json["chapters"] == null
? null
: Map<String, String>.from(json["chapters"]),
chapters = ComicChapters.fromJsonOrNull(json["chapters"]),
sourceKey = json["sourceKey"],
comicId = json["comicId"],
thumbnails = ListOrNull.from(json["thumbnails"]),
@@ -260,6 +258,41 @@ class ComicDetails with HistoryMixin {
}
return null;
}
String? _validateUpdateTime(String time) {
time = time.split(" ").first;
var segments = time.split("-");
if (segments.length != 3) return null;
var year = int.tryParse(segments[0]);
var month = int.tryParse(segments[1]);
var day = int.tryParse(segments[2]);
if (year == null || month == null || day == null) return null;
if (year < 2000 || year > 3000) return null;
if (month < 1 || month > 12) return null;
if (day < 1 || day > 31) return null;
return "$year-$month-$day";
}
String? findUpdateTime() {
if (updateTime != null) {
return _validateUpdateTime(updateTime!);
}
const acceptedNamespaces = [
"更新",
"最後更新",
"最后更新",
"update",
"last update",
];
for (var entry in tags.entries) {
if (acceptedNamespaces.contains(entry.key.toLowerCase()) &&
entry.value.isNotEmpty) {
var value = entry.value.first;
return _validateUpdateTime(value);
}
}
return null;
}
}
class ArchiveInfo {
@@ -272,3 +305,122 @@ class ArchiveInfo {
description = json["description"],
id = json["id"];
}
class ComicChapters {
final Map<String, String>? _chapters;
final Map<String, Map<String, String>>? _groupedChapters;
/// Create a ComicChapters object with a flat map
const ComicChapters(Map<String, String> this._chapters)
: _groupedChapters = null;
/// Create a ComicChapters object with a grouped map
const ComicChapters.grouped(
Map<String, Map<String, String>> this._groupedChapters)
: _chapters = null;
factory ComicChapters.fromJson(dynamic json) {
if (json is! Map) throw ArgumentError("Invalid json type");
var chapters = <String, String>{};
var groupedChapters = <String, Map<String, String>>{};
for (var entry in json.entries) {
var key = entry.key;
var value = entry.value;
if (key is! String) throw ArgumentError("Invalid key type");
if (value is Map) {
groupedChapters[key] = Map.from(value);
} else {
chapters[key] = value.toString();
}
}
if (chapters.isNotEmpty) {
return ComicChapters(chapters);
} else {
return ComicChapters.grouped(groupedChapters);
}
}
static fromJsonOrNull(dynamic json) {
if (json == null) return null;
return ComicChapters.fromJson(json);
}
Map<String, dynamic> toJson() {
if (_chapters != null) {
return _chapters;
} else {
return _groupedChapters!;
}
}
/// Whether the chapters are grouped
bool get isGrouped => _groupedChapters != null;
/// All group names
Iterable<String> get groups => _groupedChapters?.keys ?? [];
/// All chapters.
/// If the chapters are grouped, all groups will be merged.
Map<String, String> get allChapters {
if (_chapters != null) return _chapters;
var res = <String, String>{};
for (var entry in _groupedChapters!.values) {
res.addAll(entry);
}
return res;
}
/// Get a group of chapters by name
Map<String, String> getGroup(String group) {
return _groupedChapters![group] ?? {};
}
/// Get a group of chapters by index(0-based)
Map<String, String> getGroupByIndex(int index) {
return _groupedChapters!.values.elementAt(index);
}
/// Get total number of chapters
int get length {
return isGrouped
? _groupedChapters!.values.map((e) => e.length).reduce((a, b) => a + b)
: _chapters!.length;
}
/// Get the number of groups
int get groupCount => _groupedChapters?.length ?? 0;
/// Iterate all chapter ids
Iterable<String> get ids sync* {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
yield* entry.keys;
}
} else {
yield* _chapters!.keys;
}
}
/// Iterate all chapter titles
Iterable<String> get titles sync* {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
yield* entry.values;
}
} else {
yield* _chapters!.values;
}
}
String? operator [](String key) {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
if (entry.containsKey(key)) return entry[key];
}
return null;
} else {
return _chapters![key];
}
}
}

View File

@@ -620,6 +620,7 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic = _getValue("favorites.singleFolderForSingleComic");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) {
@@ -773,6 +774,7 @@ class ComicSourceParser {
deleteFolder: deleteFolder,
addOrDelFavorite: addOrDelFavFunc,
isOldToNewSort: isOldToNewSort,
singleFolderForSingleComic: singleFolderForSingleComic ?? false,
);
}

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/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/utils/tags_translation.dart';
import 'dart:io';
@@ -154,6 +155,50 @@ class FavoriteItemWithFolderInfo extends FavoriteItem {
);
}
class FavoriteItemWithUpdateInfo extends FavoriteItem {
String? updateTime;
DateTime? lastCheckTime;
bool hasNewUpdate;
FavoriteItemWithUpdateInfo(
FavoriteItem item,
this.updateTime,
this.hasNewUpdate,
int? lastCheckTime,
) : lastCheckTime = lastCheckTime == null
? null
: DateTime.fromMillisecondsSinceEpoch(lastCheckTime),
super(
id: item.id,
name: item.name,
coverPath: item.coverPath,
author: item.author,
type: item.type,
tags: item.tags,
);
@override
String get description {
var updateTime = this.updateTime ?? "Unknown";
var sourceName = type.comicSource?.name ?? "Unknown";
return "$updateTime | $sourceName";
}
@override
operator ==(Object other) {
return other is FavoriteItemWithUpdateInfo &&
other.updateTime == updateTime &&
other.hasNewUpdate == hasNewUpdate &&
super == other;
}
@override
int get hashCode =>
super.hashCode ^ updateTime.hashCode ^ hasNewUpdate.hashCode;
}
class LocalFavoritesManager with ChangeNotifier {
factory LocalFavoritesManager() =>
cache ?? (cache = LocalFavoritesManager._create());
@@ -429,7 +474,8 @@ class LocalFavoritesManager with ChangeNotifier {
/// add comic to a folder.
/// return true if success, false if already exists
bool addComic(String folder, FavoriteItem comic, [int? order]) {
bool addComic(String folder, FavoriteItem comic,
[int? order, String? updateTime]) {
_modifiedAfterLastCache = true;
if (!existsFolder(folder)) {
throw Exception("Folder does not exists");
@@ -468,6 +514,18 @@ class LocalFavoritesManager with ChangeNotifier {
values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, minValue(folder) - 1]);
}
if (updateTime != null) {
var columns = _db.select("""
pragma table_info("$folder");
""");
if (columns.any((element) => element["name"] == "last_update_time")) {
_db.execute("""
update "$folder"
set last_update_time = ?
where id == ? and type == ?;
""", [updateTime, comic.id, comic.type.value]);
}
}
notifyListeners();
return true;
}
@@ -596,9 +654,11 @@ class LocalFavoritesManager with ChangeNotifier {
void onRead(String id, ComicType type) async {
if (appdata.settings['moveFavoriteAfterRead'] == "none") {
markAsRead(id, type);
return;
}
_modifiedAfterLastCache = true;
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
for (final folder in folderNames) {
var rows = _db.select("""
select * from "$folder"
@@ -627,9 +687,13 @@ class LocalFavoritesManager with ChangeNotifier {
UPDATE "$folder"
SET
$updateLocationSql
${followUpdatesFolder == folder ? "has_new_update = 0," : ""}
time = ?
WHERE id == ?;
""", [newTime, id]);
WHERE id == ? and type == ?;
""", [newTime, id, type.value]);
if (followUpdatesFolder == folder) {
updateFollowUpdatesUI();
}
}
}
notifyListeners();
@@ -733,7 +797,7 @@ class LocalFavoritesManager with ChangeNotifier {
}
}
void updateInfo(String folder, FavoriteItem comic) {
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
_db.execute("""
update "$folder"
set name = ?, author = ?, cover_path = ?, tags = ?
@@ -746,7 +810,9 @@ class LocalFavoritesManager with ChangeNotifier {
comic.id,
comic.type.value
]);
notifyListeners();
if (notify) {
notifyListeners();
}
}
String folderToJson(String folder) {
@@ -783,7 +849,134 @@ class LocalFavoritesManager with ChangeNotifier {
}
}
void prepareTableForFollowUpdates(String table) {
// check if the table has the column "last_update_time" "has_new_update" "last_check_time"
var columns = _db.select("""
pragma table_info("$table");
""");
if (!columns.any((element) => element["name"] == "last_update_time")) {
_db.execute("""
alter table "$table"
add column last_update_time TEXT;
""");
}
if (!columns.any((element) => element["name"] == "has_new_update")) {
_db.execute("""
alter table "$table"
add column has_new_update int;
""");
}
_db.execute("""
update "$table"
set has_new_update = 0;
""");
if (!columns.any((element) => element["name"] == "last_check_time")) {
_db.execute("""
alter table "$table"
add column last_check_time int;
""");
}
}
void updateUpdateTime(
String folder,
String id,
ComicType type,
String updateTime,
) {
var oldTime = _db.select("""
select last_update_time from "$folder"
where id == ? and type == ?;
""", [id, type.value]).first['last_update_time'];
var hasNewUpdate = oldTime != updateTime;
_db.execute("""
update "$folder"
set last_update_time = ?, has_new_update = ?, last_check_time = ?
where id == ? and type == ?;
""", [
updateTime,
hasNewUpdate ? 1 : 0,
DateTime.now().millisecondsSinceEpoch,
id,
type.value,
]);
}
void updateCheckTime(
String folder,
String id,
ComicType type,
) {
_db.execute("""
update "$folder"
set last_check_time = ?
where id == ? and type == ?;
""", [DateTime.now().millisecondsSinceEpoch, id, type.value]);
}
int countUpdates(String folder) {
return _db.select("""
select count(*) as c from "$folder"
where has_new_update == 1;
""").first['c'];
}
List<FavoriteItemWithUpdateInfo> getUpdates(String folder) {
if (!existsFolder(folder)) {
return [];
}
var res = _db.select("""
select * from "$folder"
where has_new_update == 1;
""");
return res
.map(
(e) => FavoriteItemWithUpdateInfo(
FavoriteItem.fromRow(e),
e['last_update_time'],
e['has_new_update'] == 1,
e['last_check_time'],
),
)
.toList();
}
List<FavoriteItemWithUpdateInfo> getComicsWithUpdatesInfo(String folder) {
if (!existsFolder(folder)) {
return [];
}
var res = _db.select("""
select * from "$folder";
""");
return res
.map(
(e) => FavoriteItemWithUpdateInfo(
FavoriteItem.fromRow(e),
e['last_update_time'],
e['has_new_update'] == 1,
e['last_check_time'],
),
)
.toList();
}
void markAsRead(String id, ComicType type) {
var folder = appdata.settings['followUpdatesFolder'];
if (!existsFolder(folder)) {
return;
}
_db.execute("""
update "$folder"
set has_new_update = 0
where id == ? and type == ?;
""", [id, type.value]);
}
void close() {
_db.dispose();
}
void notifyChanges() {
notifyListeners();
}
}

View File

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

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:ffi' as ffi;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -49,17 +50,24 @@ class History implements Comic {
@override
String cover;
/// index of chapters. 1-based.
int ep;
/// index of pages. 1-based.
int page;
/// index of chapter groups. 1-based.
/// If [group] is not null, [ep] is the index of chapter in the group.
int? group;
@override
String id;
/// readEpisode is a set of episode numbers that have been read.
///
/// The number of episodes is 1-based.
Set<int> readEpisode;
/// For normal chapters, it is a set of chapter numbers.
/// For grouped chapters, it is a set of strings in the format of "group_number-chapter_number".
/// 1-based.
Set<String> readEpisode;
@override
int? maxPage;
@@ -68,29 +76,17 @@ class History implements Comic {
{required HistoryMixin model,
required this.ep,
required this.page,
Set<int>? readChapters,
this.group,
Set<String>? readChapters,
DateTime? time})
: type = model.historyType,
title = model.title,
subtitle = model.subTitle ?? '',
cover = model.cover,
id = model.id,
readEpisode = readChapters ?? <int>{},
readEpisode = readChapters ?? <String>{},
time = time ?? DateTime.now();
Map<String, dynamic> toMap() => {
"type": type.value,
"time": time.millisecondsSinceEpoch,
"title": title,
"subtitle": subtitle,
"cover": cover,
"ep": ep,
"page": page,
"id": id,
"readEpisode": readEpisode.toList(),
"max_page": maxPage
};
History.fromMap(Map<String, dynamic> map)
: type = HistoryType(map["type"]),
time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
@@ -100,8 +96,9 @@ class History implements Comic {
ep = map["ep"],
page = map["page"],
id = map["id"],
readEpisode = Set<int>.from(
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}),
readEpisode = Set<String>.from(
(map["readEpisode"] as List<dynamic>?)?.toSet() ??
const <String>{}),
maxPage = map["max_page"];
@override
@@ -118,35 +115,11 @@ class History implements Comic {
ep = row["ep"],
page = row["page"],
id = row["id"],
readEpisode = Set<int>.from((row["readEpisode"] as String)
readEpisode = Set<String>.from((row["readEpisode"] as String)
.split(',')
.where((element) => element != "")
.map((e) => int.parse(e))),
maxPage = row["max_page"];
static Future<History> findOrCreate(
HistoryMixin model, {
int ep = 0,
int page = 0,
}) async {
var history = await HistoryManager().find(model.id, model.historyType);
if (history != null) {
return history;
}
history = History.fromModel(model: model, ep: ep, page: page);
HistoryManager().addHistory(history);
return history;
}
static Future<History> createIfNull(
History? history, HistoryMixin model) async {
if (history != null) {
return history;
}
history = History.fromModel(model: model, ep: 0, page: 0);
HistoryManager().addHistory(history);
return history;
}
.where((element) => element != "")),
maxPage = row["max_page"],
group = row["chapter_group"];
@override
bool operator ==(Object other) {
@@ -210,7 +183,11 @@ class HistoryManager with ChangeNotifier {
int get length => _db.select("select count(*) from history;").first[0] as int;
Map<String, bool>? _cachedHistory;
/// Cache of history ids. Improve the performance of find operation.
Map<String, bool>? _cachedHistoryIds;
/// Cache records recently modified by the app. Improve the performance of listeners.
final cachedHistories = <String, History>{};
bool isInitialized = false;
@@ -231,23 +208,73 @@ class HistoryManager with ChangeNotifier {
ep int,
page int,
readEpisode text,
max_page int
max_page int,
chapter_group int
);
""");
var columns = _db.select("PRAGMA table_info(history);");
if (!columns.any((element) => element["name"] == "chapter_group")) {
_db.execute("alter table history add column chapter_group int;");
}
notifyListeners();
ImageFavoriteManager().init();
isInitialized = true;
}
static const _insertHistorySql = """
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page, chapter_group)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""";
static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
return Isolate.run(() {
var db = sqlite3.fromPointer(ffi.Pointer.fromAddress(dbAddr));
db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
newItem.cover,
newItem.time.millisecondsSinceEpoch,
newItem.type.value,
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage,
newItem.group
]);
});
}
bool _haveAsyncTask = false;
/// Create a isolate to add history to prevent blocking the UI thread.
Future<void> addHistoryAsync(History newItem) async {
while (_haveAsyncTask) {
await Future.delayed(Duration(milliseconds: 20));
}
_haveAsyncTask = true;
await _addHistoryAsync(_db.handle.address, newItem);
_haveAsyncTask = false;
if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners();
}
/// add history. if exists, update time.
///
/// This function would be called when user start reading.
Future<void> addHistory(History newItem) async {
_db.execute("""
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [
void addHistory(History newItem) {
_db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
@@ -257,9 +284,18 @@ class HistoryManager with ChangeNotifier {
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage
newItem.maxPage,
newItem.group
]);
updateCache();
if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners();
}
@@ -278,27 +314,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners();
}
Future<History?> find(String id, ComicType type) async {
return findSync(id, type);
}
void updateCache() {
_cachedHistory = {};
_cachedHistoryIds = {};
var res = _db.select("""
select * from history;
select id from history;
""");
for (var element in res) {
_cachedHistory![element["id"] as String] = true;
_cachedHistoryIds![element["id"] as String] = true;
}
for (var key in cachedHistories.keys.toList()) {
if (!_cachedHistoryIds!.containsKey(key)) {
cachedHistories.remove(key);
}
}
}
History? findSync(String id, ComicType type) {
if (_cachedHistory == null) {
History? find(String id, ComicType type) {
if (_cachedHistoryIds == null) {
updateCache();
}
if (!_cachedHistory!.containsKey(id)) {
if (!_cachedHistoryIds!.containsKey(id)) {
return null;
}
if (cachedHistories.containsKey(id)) {
return cachedHistories[id];
}
var res = _db.select("""
select * from history

View File

@@ -396,7 +396,7 @@ class ImageFavoriteManager with ChangeNotifier {
var token = ServicesBinding.rootIsolateToken!;
var count = ImageFavoriteManager().length;
if (count == 0) {
return Future.value(ImageFavoritesComputed([], [], []));
return Future.value(ImageFavoritesComputed([], [], [], 0));
} else if (count > 100) {
return Isolate.run(() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
@@ -436,8 +436,10 @@ class ImageFavoriteManager with ChangeNotifier {
Map<String, int> authorCount = {};
Map<ImageFavoritesComic, int> comicImageCount = {};
Map<ImageFavoritesComic, int> comicMaxPages = {};
int count = 0;
for (var comic in comics) {
count += comic.images.length;
for (var tag in comic.tags) {
String finalTag = tag;
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
@@ -492,6 +494,7 @@ class ImageFavoriteManager with ChangeNotifier {
.map((comic) => TextWithCount(comic.key.title, comic.value))
.take(maxLength)
.toList(),
count,
);
}
@@ -524,11 +527,14 @@ class ImageFavoritesComputed {
/// 基于喜欢的图片数排序
final List<TextWithCount> comics;
final int count;
/// 计算后的图片收藏数据
const ImageFavoritesComputed(
this.tags,
this.authors,
this.comics,
this.count,
);
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;

View File

@@ -97,7 +97,7 @@ class ImageFavoritesProvider
if (localComic == null) {
return null;
}
var epIndex = localComic.chapters?.keys.toList().indexOf(eid) ?? -1;
var epIndex = localComic.chapters?.ids.toList().indexOf(eid) ?? -1;
if (epIndex == -1 && localComic.hasChapters) {
return null;
}

View File

@@ -63,7 +63,8 @@ class ReaderImageProvider
})()
''');
if (func is JSInvokable) {
var result = func.invoke([imageBytes, cid, eid, page, sourceKey]);
var autoFreeFunc = JSAutoFreeFunction(func);
var result = autoFreeFunc([imageBytes, cid, eid, page, sourceKey]);
if (result is Uint8List) {
imageBytes = result;
} else if (result is Future) {
@@ -76,9 +77,9 @@ class ReaderImageProvider
if (image is Uint8List) {
imageBytes = image;
} else if (image is Future) {
JSInvokable? onCancel;
JSAutoFreeFunction? onCancel;
if (result['onCancel'] is JSInvokable) {
onCancel = result['onCancel'];
onCancel = JSAutoFreeFunction(result['onCancel']);
}
if (onCancel == null) {
var futureImage = await image;
@@ -96,9 +97,7 @@ class ReaderImageProvider
checkStop();
}
catch(e) {
onCancel.invoke([]);
onCancel.free();
func.free();
onCancel([]);
rethrow;
}
await Future.delayed(Duration(milliseconds: 50));
@@ -107,10 +106,8 @@ class ReaderImageProvider
imageBytes = futureImage;
}
}
onCancel?.free();
}
}
func.free();
}
}
return imageBytes!;

View File

@@ -3,7 +3,6 @@ import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:dio/io.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html;
import 'package:html/dom.dart' as dom;
@@ -20,9 +19,8 @@ import 'package:pointycastle/block/modes/cbc.dart';
import 'package:pointycastle/block/modes/cfb.dart';
import 'package:pointycastle/block/modes/ecb.dart';
import 'package:pointycastle/block/modes/ofb.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:uuid/uuid.dart';
import 'package:venera/components/components.dart';
import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
@@ -42,7 +40,7 @@ class JavaScriptRuntimeException implements Exception {
}
}
class JsEngine with _JSEngineApi, _JsUiApi {
class JsEngine with _JSEngineApi, JsUiApi {
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache;
@@ -156,7 +154,11 @@ class JsEngine with _JSEngineApi, _JsUiApi {
case "delay":
return Future.delayed(Duration(milliseconds: message["time"]));
case "UI":
handleUIMessage(Map.from(message));
return handleUIMessage(Map.from(message));
case "getLocale":
return "${App.locale.languageCode}_${App.locale.countryCode}";
case "getPlatform":
return Platform.operatingSystem;
}
}
return null;
@@ -679,6 +681,7 @@ class JSAutoFreeFunction {
/// Automatically free the function when it's not used anymore
JSAutoFreeFunction(this.func) {
func.dup();
finalizer.attach(this, func);
}
@@ -687,48 +690,6 @@ class JSAutoFreeFunction {
}
static final finalizer = Finalizer<JSInvokable>((func) {
func.free();
func.destroy();
});
}
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

@@ -9,7 +9,6 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'app.dart';
@@ -34,7 +33,7 @@ class LocalComic with HistoryMixin implements Comic {
/// key: chapter id, value: chapter title
///
/// chapter id is the name of the directory in `LocalManager.path/$directory`
final Map<String, String>? chapters;
final ComicChapters? chapters;
bool get hasChapters => chapters != null;
@@ -67,7 +66,7 @@ class LocalComic with HistoryMixin implements Comic {
subtitle = row[2] as String,
tags = List.from(jsonDecode(row[3] as String)),
directory = row[4] as String,
chapters = MapOrNull.from(jsonDecode(row[5] as String)),
chapters = ComicChapters.fromJsonOrNull(jsonDecode(row[5] as String)),
cover = row[6] as String,
comicType = ComicType(row[7] as int),
downloadedChapters = List.from(jsonDecode(row[8] as String)),
@@ -99,14 +98,15 @@ class LocalComic with HistoryMixin implements Comic {
"tags": tags,
"description": description,
"sourceKey": sourceKey,
"chapters": chapters?.toJson(),
};
}
@override
int? get maxPage => null;
void read() async {
var history = await HistoryManager().find(id, comicType);
void read() {
var history = HistoryManager().find(id, comicType);
App.rootContext.to(
() => Reader(
type: comicType,
@@ -115,6 +115,7 @@ class LocalComic with HistoryMixin implements Comic {
chapters: chapters,
initialChapter: history?.ep,
initialPage: history?.page,
initialChapterGroup: history?.group,
history: history ??
History.fromModel(
model: this,
@@ -391,7 +392,7 @@ class LocalManager with ChangeNotifier {
var directory = Directory(comic.baseDir);
if (comic.hasChapters) {
var cid =
ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
directory = Directory(FilePath.join(directory.path, cid));
}
var files = <File>[];
@@ -425,7 +426,7 @@ class LocalManager with ChangeNotifier {
if (comic == null) return false;
if (comic.chapters == null || ep == null) return true;
return comic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(ep - 1));
.contains(comic.chapters!.ids.elementAt(ep - 1));
}
List<DownloadTask> downloadingTasks = [];
@@ -509,9 +510,9 @@ class LocalManager with ChangeNotifier {
var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true);
}
// Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
// Deleting a local comic means that it's no longer available, thus both favorite and history should be deleted.
if (c.comicType == ComicType.local) {
if (HistoryManager().findSync(c.id, c.comicType) != null) {
if (HistoryManager().find(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType);
}
var folders = LocalFavoritesManager().find(c.id, c.comicType);

View File

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

View File

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

View File

@@ -1,14 +1,13 @@
import 'dart:async';
import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flex_seed_scheme/flex_seed_scheme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/auth_page.dart';
import 'package:venera/pages/main_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/io.dart';
import 'package:window_manager/window_manager.dart';
import 'components/components.dart';
@@ -18,21 +17,11 @@ import 'foundation/appdata.dart';
import 'init.dart';
void main(List<String> args) {
if (runWebViewTitleBarWidget(args)) {
return;
}
if (runWebViewTitleBarWidget(args)) return;
overrideIO(() {
runZonedGuarded(() async {
await Rhttp.init();
WidgetsFlutterBinding.ensureInitialized();
await init();
if (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");
};
runApp(const MyApp());
if (App.isDesktop) {
await windowManager.ensureInitialized();
@@ -55,7 +44,7 @@ void main(List<String> args) {
});
}
}, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack");
Log.error("Unhandled Exception", error, stack);
});
});
}
@@ -73,6 +62,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
App.registerForceRebuild(forceRebuild);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addObserver(this);
checkUpdates();
super.initState();
}
@@ -143,6 +133,38 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
};
}
ThemeData getTheme(
Color primary,
Color? secondary,
Color? tertiary,
Brightness brightness,
) {
String? font;
List<String>? fallback;
if (App.isWindows) {
font = 'Segoe UI';
fallback = [
'Segoe UI',
'Microsoft YaHei',
'PingFang SC',
'Noto Sans CJK',
'Arial',
'sans-serif'
];
}
return ThemeData(
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
brightness: brightness,
tones: FlexTones.vividBackground(brightness),
),
fontFamily: font,
fontFamilyFallback: fallback,
);
}
@override
Widget build(BuildContext context) {
Widget home;
@@ -156,50 +178,29 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
home = const MainPage();
}
return DynamicColorBuilder(builder: (light, dark) {
Color? primary, secondary, tertiary;
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,
);
primary = translateColorSetting();
} else {
light = ColorScheme.fromSeed(
seedColor: light.primary,
surface: Colors.white,
);
dark = ColorScheme.fromSeed(
seedColor: dark.primary,
brightness: Brightness.dark,
surface: Colors.black,
);
primary = light.primary;
secondary = light.secondary;
tertiary = light.tertiary;
}
return MaterialApp(
home: home,
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: light,
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
),
theme: getTheme(primary, secondary, tertiary, Brightness.light),
navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData(
colorScheme: dark,
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
),
darkTheme: getTheme(primary, secondary, tertiary, Brightness.dark),
themeMode: switch (appdata.settings['theme_mode']) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system
},
localizationsDelegates: const [
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
locale: () {
@@ -215,9 +216,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
};
}(),
supportedLocales: const [
Locale('en'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
Locale('en'),
],
builder: (context, widget) {
ErrorWidget.builder = (details) {

View File

@@ -1,5 +1,5 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:venera/network/app_dio.dart';
class NetworkCache {
final Uri uri;
@@ -117,7 +117,7 @@ class NetworkCacheManager implements Interceptor {
var o = options.copyWith(
method: "HEAD",
);
var dio = Dio();
var dio = AppDio();
var response = await dio.fetch(o);
if (response.statusCode == 200 &&
compareHeaders(cache.responseHeaders, response.headers.map)) {
@@ -135,6 +135,8 @@ class NetworkCacheManager implements Interceptor {
}
static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) {
a = Map.from(a);
b = Map.from(b);
const shouldIgnore = [
'cache-time',
'prevent-parallel',

View File

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

View File

@@ -59,6 +59,16 @@ abstract class DownloadTask with ChangeNotifier {
return null;
}
}
@override
bool operator ==(Object other) {
return other is DownloadTask &&
other.id == id &&
other.comicType == comicType;
}
@override
int get hashCode => Object.hash(id, comicType);
}
class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
@@ -220,7 +230,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
runRecorder();
if (comic == null) {
var res = await runWithRetry(() async {
_message = "Fetching comic info...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicInfo!(comicId);
if (r.error) {
throw r.errorMessage!;
@@ -260,7 +272,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
await LocalManager().saveCurrentDownloadingTasks();
if (_cover == null) {
var res = await runWithRetry(() async {
_message = "Downloading cover...";
notifyListeners();
var res = await _runWithRetry(() async {
Uint8List? data;
await for (var progress
in ImageDownloader.loadThumbnail(comic!.cover, source.key)) {
@@ -272,8 +286,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
throw "Failed to download cover";
}
var fileType = detectFileType(data);
var file =
File(FilePath.join(path!, "cover${fileType.ext}"));
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
file.writeAsBytesSync(data);
return "file://${file.path}";
});
@@ -290,7 +303,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (_images == null) {
if (comic!.chapters == null) {
var res = await runWithRetry(() async {
_message = "Fetching image list...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicPages!(comicId, null);
if (r.error) {
throw r.errorMessage!;
@@ -312,7 +327,10 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} else {
_images = {};
_totalCount = 0;
for (var i in comic!.chapters!.keys) {
int cpCount = 0;
int totalCpCount =
chapters?.length ?? comic!.chapters!.allChapters.length;
for (var i in comic!.chapters!.allChapters.keys) {
if (chapters != null && !chapters!.contains(i)) {
continue;
}
@@ -320,7 +338,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_totalCount += _images![i]!.length;
continue;
}
var res = await runWithRetry(() async {
_message = "Fetching image list ($cpCount/$totalCpCount)...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicPages!(comicId, i);
if (r.error) {
throw r.errorMessage!;
@@ -403,7 +423,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
"comic": comic?.toJson(),
"chapters": chapters,
"path": path,
"cover": cover,
"cover": _cover,
"images": _images,
"downloadedCount": _downloadedCount,
"totalCount": _totalCount,
@@ -458,8 +478,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}).toList(),
directory: Directory(path!).name,
chapters: comic!.chapters,
cover:
File(_cover!.split("file://").last).name,
cover: File(_cover!.split("file://").last).name,
comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [],
createdAt: DateTime.now(),
@@ -478,7 +497,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
int get hashCode => Object.hash(comicId, source.key);
}
Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
Future<Res<T>> _runWithRetry<T>(Future<T> Function() task,
{int retry = 3}) async {
for (var i = 0; i < retry; i++) {
try {
@@ -487,6 +506,7 @@ Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
if (i == retry - 1) {
return Res.error(e.toString());
}
await Future.delayed(Duration(seconds: i + 1));
}
}
throw UnimplementedError();

View File

@@ -139,12 +139,10 @@ class ImageDownloader {
var buffer = <int>[];
await for (var data in stream) {
buffer.addAll(data);
if (expectedBytes != null) {
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: expectedBytes,
);
}
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: expectedBytes,
);
}
if (configs['onResponse'] is JSInvokable) {
@@ -194,7 +192,7 @@ class ImageDownloader {
class ImageDownloadProgress {
final int currentBytes;
final int totalBytes;
final int? totalBytes;
final Uint8List? imageBytes;

View File

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

View File

@@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import 'package:shimmer_animation/shimmer_animation.dart';
import "package:venera/components/components.dart";
import "package:venera/foundation/app.dart";
import "package:venera/foundation/appdata.dart";
import "package:venera/foundation/comic_source/comic_source.dart";
import "package:venera/pages/search_result_page.dart";
import "package:venera/utils/translations.dart";
@@ -24,7 +25,18 @@ class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
@override
void initState() {
sources = ComicSource.all().where((e) => e.searchPageData != null).toList();
var all = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
var settings = appdata.settings['searchSources'] as List;
var sources = <String>[];
for (var source in settings) {
if (all.contains(source)) {
sources.add(source);
}
}
this.sources = sources.map((e) => ComicSource.find(e)!).toList();
_keyword = widget.keyword;
controller = SearchBarController(
currentText: widget.keyword,
@@ -46,7 +58,11 @@ class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
delegate: SliverChildBuilderDelegate(
(context, index) {
final source = sources[index];
return _SliverSearchResult(source: source, keyword: _keyword);
return _SliverSearchResult(
key: ValueKey(source.key),
source: source,
keyword: _keyword,
);
},
childCount: sources.length,
),
@@ -56,7 +72,11 @@ class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
}
class _SliverSearchResult extends StatefulWidget {
const _SliverSearchResult({required this.source, required this.keyword});
const _SliverSearchResult({
required this.source,
required this.keyword,
super.key,
});
final ComicSource source;
@@ -78,6 +98,8 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
List<Comic>? comics;
String? error;
void load() async {
final data = widget.source.searchPageData!;
var options =
@@ -89,6 +111,11 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
comics = res.data;
isLoading = false;
});
} else {
setState(() {
error = res.errorMessage ?? "Unknown error".tl;
isLoading = false;
});
}
} else if (data.loadNext != null) {
var res = await data.loadNext!(widget.keyword, null, options);
@@ -97,6 +124,11 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
comics = res.data;
isLoading = false;
});
} else {
setState(() {
error = res.errorMessage ?? "Unknown error".tl;
isLoading = false;
});
}
}
}
@@ -127,6 +159,9 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
@override
Widget build(BuildContext context) {
if (error != null && error!.startsWith("CloudflareException")) {
error = "Cloudflare verification required".tl;
}
super.build(context);
return InkWell(
onTap: () {
@@ -169,7 +204,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
}),
),
)
else if (comics == null || comics!.isEmpty)
else if (error != null || comics == null || comics!.isEmpty)
SizedBox(
height: _kComicHeight,
child: Column(
@@ -178,7 +213,13 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Text("No search results found".tl),
Expanded(
child: Text(
error ?? "No search results found".tl,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
],
),
const Spacer(),

View File

@@ -3,80 +3,128 @@ 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/state_controller.dart';
import 'package:venera/pages/ranking_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'category_comics_page.dart';
import 'comic_source_page.dart';
class CategoriesPage extends StatelessWidget {
class CategoriesPage extends StatefulWidget {
const CategoriesPage({super.key});
@override
State<CategoriesPage> createState() => _CategoriesPageState();
}
class _CategoriesPageState extends State<CategoriesPage> {
var categories = <String>[];
void onSettingsChanged() {
var categories =
List.from(appdata.settings["categories"]).whereType<String>().toList();
var allCategories = ComicSource.all()
.map((e) => e.categoryData?.key)
.where((element) => element != null)
.map((e) => e!)
.toList();
categories =
categories.where((element) => allCategories.contains(element)).toList();
if (!categories.isEqualTo(this.categories)) {
setState(() {
this.categories = categories;
});
}
}
@override
void initState() {
super.initState();
var categories =
List.from(appdata.settings["categories"]).whereType<String>().toList();
var allCategories = ComicSource.all()
.map((e) => e.categoryData?.key)
.where((element) => element != null)
.map((e) => e!)
.toList();
this.categories =
categories.where((element) => allCategories.contains(element)).toList();
appdata.settings.addListener(onSettingsChanged);
}
void addPage() {
showPopUpWidget(App.rootContext, setCategoryPagesWidget());
}
@override
void dispose() {
super.dispose();
appdata.settings.removeListener(onSettingsChanged);
}
Widget buildEmpty() {
var msg = "No Category Pages".tl;
msg += '\n';
VoidCallback onTap;
if (ComicSource.isEmpty) {
msg += "Please add some sources".tl;
onTap = () {
context.to(() => ComicSourcePage());
};
} else {
msg += "Please check your settings".tl;
onTap = addPage;
}
return NetworkError(
message: msg,
retry: onTap,
withAppbar: false,
buttonText: "Manage".tl,
);
}
@override
Widget build(BuildContext context) {
return StateBuilder<SimpleController>(
tag: "category",
init: SimpleController(),
builder: (controller) {
var categories = List.from(appdata.settings["categories"]);
var allCategories = ComicSource.all()
.map((e) => e.categoryData?.key)
.where((element) => element != null)
.map((e) => e!)
.toList();
categories = categories
.where((element) => allCategories.contains(element))
.toList();
if (categories.isEmpty) {
return buildEmpty();
}
if(categories.isEmpty) {
var msg = "No Category Pages".tl;
msg += '\n';
if(ComicSource.isEmpty) {
msg += "Add a comic source in home page".tl;
} else {
msg += "Please check your settings".tl;
}
return NetworkError(
message: msg,
retry: () {
controller.update();
},
withAppbar: false,
);
}
return Material(
child: DefaultTabController(
length: categories.length,
key: Key(categories.toString()),
child: Column(
children: [
FilledTabBar(
key: PageStorageKey(categories.toString()),
tabs: categories.map((e) {
String title = e;
try {
title = getCategoryDataWithKey(e).title;
} catch (e) {
//
}
return Tab(
text: title,
key: Key(e),
);
}).toList(),
).paddingTop(context.padding.top),
Expanded(
child: TabBarView(
children:
categories.map((e) => _CategoryPage(e)).toList()),
)
],
),
),
);
},
return Material(
child: DefaultTabController(
length: categories.length,
key: Key(categories.toString()),
child: Column(
children: [
AppTabBar(
key: PageStorageKey(categories.toString()),
tabs: categories.map((e) {
String title = e;
try {
title = getCategoryDataWithKey(e).title;
} catch (e) {
//
}
return Tab(
text: title,
key: Key(e),
);
}).toList(),
actionButton: TabActionButton(
icon: const Icon(Icons.add),
text: "Add".tl,
onPressed: addPage,
),
).paddingTop(context.padding.top),
Expanded(
child: TabBarView(
children: categories.map((e) => _CategoryPage(e)).toList(),
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,439 @@
part of 'comic_page.dart';
abstract mixin class _ComicPageActions {
void update();
ComicDetails get comic;
ComicSource get comicSource => ComicSource.find(comic.sourceKey)!;
History? get history;
bool isLiking = false;
bool isLiked = false;
void likeOrUnlike() async {
if (isLiking) return;
isLiking = true;
update();
var res = await comicSource.likeOrUnlikeComic!(comic.id, isLiked);
if (res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
} else {
isLiked = !isLiked;
}
isLiking = false;
update();
}
/// whether the comic is added to local favorite
bool isAddToLocalFav = false;
/// whether the comic is favorite on the server
bool isFavorite = false;
FavoriteItem _toFavoriteItem() {
var tags = <String>[];
for (var e in comic.tags.entries) {
tags.addAll(e.value.map((tag) => '${e.key}:$tag'));
}
return FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
);
}
void openFavPanel() {
showSideBar(
App.rootContext,
_FavoritePanel(
cid: comic.id,
type: comic.comicType,
isFavorite: isFavorite,
onFavorite: (local, network) {
isFavorite = network ?? isFavorite;
isAddToLocalFav = local ?? isAddToLocalFav;
update();
},
favoriteItem: _toFavoriteItem(),
updateTime: comic.findUpdateTime(),
),
);
}
void quickFavorite() {
var folder = appdata.settings['quickFavorite'];
if (folder is! String) {
return;
}
LocalFavoritesManager().addComic(
folder,
_toFavoriteItem(),
null,
comic.findUpdateTime(),
);
isAddToLocalFav = true;
update();
App.rootContext.showMessage(message: "Added".tl);
}
void share() {
var text = comic.title;
if (comic.url != null) {
text += '\n${comic.url}';
}
Share.shareText(text);
}
/// read the comic
///
/// [ep] the episode number, start from 1
///
/// [page] the page number, start from 1
///
/// [group] the chapter group number, start from 1
void read([int? ep, int? page, int? group]) {
App.rootContext
.to(
() => Reader(
type: comic.comicType,
cid: comic.id,
name: comic.title,
chapters: comic.chapters,
initialChapter: ep,
initialPage: page,
initialChapterGroup: group,
history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '',
tags: comic.plainTags,
),
)
.then((_) {
onReadEnd();
});
}
void continueRead() {
var ep = history?.ep ?? 1;
var page = history?.page ?? 1;
var group = history?.group ?? 1;
read(ep, page, group);
}
void onReadEnd();
void download() async {
if (LocalManager().isDownloading(comic.id, comic.comicType)) {
App.rootContext.showMessage(message: "The comic is downloading".tl);
return;
}
if (comic.chapters == null &&
LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
App.rootContext.showMessage(message: "The comic is downloaded".tl);
return;
}
if (comicSource.archiveDownloader != null) {
bool useNormalDownload = false;
List<ArchiveInfo>? archives;
int selected = -1;
bool isLoading = false;
bool isGettingLink = false;
await showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: "Download".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<int>(
value: -1,
groupValue: selected,
title: Text("Normal".tl),
onChanged: (v) {
setState(() {
selected = v!;
});
},
),
ExpansionTile(
title: Text("Archive".tl),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
collapsedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
onExpansionChanged: (b) {
if (!isLoading && b && archives == null) {
isLoading = true;
comicSource.archiveDownloader!
.getArchives(comic.id)
.then((value) {
if (value.success) {
archives = value.data;
} else {
App.rootContext
.showMessage(message: value.errorMessage!);
}
setState(() {
isLoading = false;
});
});
}
},
children: [
if (archives == null)
const ListLoadingIndicator().toCenter()
else
for (int i = 0; i < archives!.length; i++)
RadioListTile<int>(
value: i,
groupValue: selected,
onChanged: (v) {
setState(() {
selected = v!;
});
},
title: Text(archives![i].title),
subtitle: Text(archives![i].description),
)
],
)
],
),
actions: [
Button.filled(
isLoading: isGettingLink,
onPressed: () async {
if (selected == -1) {
useNormalDownload = true;
context.pop();
return;
}
setState(() {
isGettingLink = true;
});
var res =
await comicSource.archiveDownloader!.getDownloadUrl(
comic.id,
archives![selected].id,
);
if (res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
setState(() {
isGettingLink = false;
});
} else if (context.mounted) {
LocalManager()
.addTask(ArchiveDownloadTask(res.data, comic));
App.rootContext
.showMessage(message: "Download started".tl);
context.pop();
}
},
child: Text("Confirm".tl),
),
],
);
},
);
},
);
if (!useNormalDownload) {
return;
}
}
if (comic.chapters == null) {
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: comic.id,
comic: comic,
));
} else {
List<int>? selected;
var downloaded = <int>[];
var localComic = LocalManager().find(comic.id, comic.comicType);
if (localComic != null) {
for (int i = 0; i < comic.chapters!.length; i++) {
if (localComic.downloadedChapters
.contains(comic.chapters!.ids.elementAt(i))) {
downloaded.add(i);
}
}
}
await showSideBar(
App.rootContext,
_SelectDownloadChapter(
comic.chapters!.titles.toList(),
(v) => selected = v,
downloaded,
),
);
if (selected == null) return;
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: comic.id,
comic: comic,
chapters: selected!.map((i) {
return comic.chapters!.ids.elementAt(i);
}).toList(),
));
}
App.rootContext.showMessage(message: "Download started".tl);
update();
}
void onTapTag(String tag, String namespace) {
var config = comicSource.handleClickTagEvent?.call(namespace, tag) ??
{
'action': 'search',
'keyword': tag,
};
var context = App.mainNavigatorKey!.currentContext!;
if (config['action'] == 'search') {
context.to(() => SearchResultPage(
text: config['keyword'] ?? '',
sourceKey: comicSource.key,
options: const [],
));
} else if (config['action'] == 'category') {
context.to(
() => CategoryComicsPage(
category: config['keyword'] ?? '',
categoryKey: comicSource.categoryData!.key,
param: config['param'],
),
);
}
}
void showMoreActions() {
var context = App.rootContext;
showMenuX(
context,
Offset(
context.width - 16,
context.padding.top,
),
[
MenuEntry(
icon: Icons.copy,
text: "Copy Title".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.title));
context.showMessage(message: "Copied".tl);
},
),
MenuEntry(
icon: Icons.copy_rounded,
text: "Copy ID".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.id));
context.showMessage(message: "Copied".tl);
},
),
if (comic.url != null)
MenuEntry(
icon: Icons.link,
text: "Copy URL".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.url!));
context.showMessage(message: "Copied".tl);
},
),
if (comic.url != null)
MenuEntry(
icon: Icons.open_in_browser,
text: "Open in Browser".tl,
onClick: () {
launchUrlString(comic.url!);
},
),
]);
}
void showComments() {
showSideBar(
App.rootContext,
CommentsPage(
data: comic,
source: comicSource,
),
);
}
void starRating() {
if (!comicSource.isLogged) {
return;
}
var rating = 0.0;
var isLoading = false;
showDialog(
context: App.rootContext,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => SimpleDialog(
title: const Text("Rating"),
alignment: Alignment.center,
children: [
SizedBox(
height: 100,
child: Center(
child: SizedBox(
width: 210,
child: Column(
children: [
const SizedBox(
height: 10,
),
RatingWidget(
padding: 2,
onRatingUpdate: (value) => rating = value,
value: 1,
selectable: true,
size: 40,
),
const Spacer(),
Button.filled(
isLoading: isLoading,
onPressed: () {
setState(() {
isLoading = true;
});
comicSource.starRatingFunc!(comic.id, rating.round())
.then((value) {
if (value.success) {
App.rootContext
.showMessage(message: "Success".tl);
Navigator.of(dialogContext).pop();
} else {
App.rootContext
.showMessage(message: value.errorMessage!);
setState(() {
isLoading = false;
});
}
});
},
child: Text("Submit".tl),
)
],
),
),
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,353 @@
part of 'comic_page.dart';
class _ComicChapters extends StatelessWidget {
const _ComicChapters({this.history, required this.groupedMode});
final History? history;
final bool groupedMode;
@override
Widget build(BuildContext context) {
return groupedMode
? _GroupedComicChapters(history)
: _NormalComicChapters(history);
}
}
class _NormalComicChapters extends StatefulWidget {
const _NormalComicChapters(this.history);
final History? history;
@override
State<_NormalComicChapters> createState() => _NormalComicChaptersState();
}
class _NormalComicChaptersState extends State<_NormalComicChapters> {
late _ComicPageState state;
bool reverse = false;
bool showAll = false;
late History? history;
late ComicChapters chapters;
@override
void initState() {
super.initState();
history = widget.history;
}
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.chapters!;
super.didChangeDependencies();
}
@override
void didUpdateWidget(covariant _NormalComicChapters oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() {
history = widget.history;
});
}
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constrains) {
int length = chapters.length;
bool canShowAll = showAll;
if (!showAll) {
var width = constrains.crossAxisExtent - 16;
var crossItems = width ~/ 200;
if (width % 200 != 0) {
crossItems += 1;
}
length = math.min(length, crossItems * 8);
if (length == chapters.length) {
canShowAll = true;
}
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: length,
(context, i) {
if (reverse) {
i = chapters.length - i - 1;
}
var key = chapters.ids.elementAt(i);
var value = chapters[key]!;
bool visited = (history?.readEpisode ?? {}).contains(i + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
child: Material(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () => state.read(i + 1),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: visited
? context.colorScheme.outline
: null,
),
),
),
),
),
),
);
},
),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200,
itemHeight: 48,
),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
if (!canShowAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: TextButton.icon(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
showAll = true;
});
},
label: Text("${"Show all".tl} (${chapters.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
},
);
}
}
class _GroupedComicChapters extends StatefulWidget {
const _GroupedComicChapters(this.history);
final History? history;
@override
State<_GroupedComicChapters> createState() => _GroupedComicChaptersState();
}
class _GroupedComicChaptersState extends State<_GroupedComicChapters>
with SingleTickerProviderStateMixin {
late _ComicPageState state;
bool reverse = false;
bool showAll = false;
late History? history;
late ComicChapters chapters;
late TabController tabController;
int index = 0;
@override
void initState() {
super.initState();
history = widget.history;
}
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.chapters!;
tabController = TabController(
length: chapters.ids.length,
vsync: this,
);
tabController.addListener(onTabChange);
super.didChangeDependencies();
}
void onTabChange() {
if (index != tabController.index) {
setState(() {
index = tabController.index;
});
}
}
@override
void didUpdateWidget(covariant _GroupedComicChapters oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() {
history = widget.history;
});
}
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constrains) {
var group = chapters.getGroupByIndex(index);
int length = group.length;
bool canShowAll = showAll;
if (!showAll) {
var width = constrains.crossAxisExtent - 16;
var crossItems = width ~/ 200;
if (width % 200 != 0) {
crossItems += 1;
}
length = math.min(length, crossItems * 8);
if (length == group.length) {
canShowAll = true;
}
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverToBoxAdapter(
child: AppTabBar(
withUnderLine: false,
controller: tabController,
tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
),
),
SliverPadding(padding: const EdgeInsets.only(top: 8)),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: length,
(context, i) {
if (reverse) {
i = group.length - i - 1;
}
var key = group.keys.elementAt(i);
var value = group[key]!;
var chapterIndex = 0;
for (var j = 0; j < chapters.groupCount; j++) {
if (j == index) {
chapterIndex += i;
break;
}
chapterIndex += chapters.getGroupByIndex(j).length;
}
String rawIndex = (chapterIndex + 1).toString();
String groupedIndex = "${index + 1}-${i + 1}";
bool visited = false;
if (history != null) {
visited = history!.readEpisode.contains(groupedIndex) ||
history!.readEpisode.contains(rawIndex);
}
return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
child: Material(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () => state.read(chapterIndex + 1),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: visited
? context.colorScheme.outline
: null,
),
),
),
),
),
),
);
},
),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200,
itemHeight: 48,
),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
if (!canShowAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: TextButton.icon(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
showAll = true;
});
},
label: Text("${"Show all".tl} (${group.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,943 @@
import 'dart:collection';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shimmer_animation/shimmer_animation.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/category_comics_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'dart:math' as math;
part 'comments_page.dart';
part 'chapters.dart';
part 'thumbnails.dart';
part 'favorite.dart';
part 'comments_preview.dart';
part 'actions.dart';
class ComicPage extends StatefulWidget {
const ComicPage({
super.key,
required this.id,
required this.sourceKey,
this.cover,
this.title,
this.heroID,
});
final String id;
final String sourceKey;
final String? cover;
final String? title;
final int? heroID;
@override
State<ComicPage> createState() => _ComicPageState();
}
class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
with _ComicPageActions {
@override
History? history;
bool showAppbarTitle = false;
var scrollController = ScrollController();
bool isDownloaded = false;
@override
void onReadEnd() {
history ??=
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
update();
}
@override
Widget buildLoading() {
return _ComicPageLoadingPlaceHolder(
cover: widget.cover,
title: widget.title,
sourceKey: widget.sourceKey,
cid: widget.id,
heroID: widget.heroID,
);
}
@override
void initState() {
scrollController.addListener(onScroll);
super.initState();
}
@override
void dispose() {
scrollController.removeListener(onScroll);
super.dispose();
}
@override
void update() {
setState(() {});
}
@override
ComicDetails get comic => data!;
void onScroll() {
if (scrollController.offset > 100) {
if (!showAppbarTitle) {
setState(() {
showAppbarTitle = true;
});
}
} else {
if (showAppbarTitle) {
setState(() {
showAppbarTitle = false;
});
}
}
}
var isFirst = true;
@override
Widget buildContent(BuildContext context, ComicDetails data) {
return SmoothCustomScrollView(
controller: scrollController,
slivers: [
...buildTitle(),
buildActions(),
buildDescription(),
buildInfo(),
buildChapters(),
buildComments(),
buildThumbnails(),
buildRecommend(),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
],
);
}
@override
Future<Res<ComicDetails>> loadData() async {
if (widget.sourceKey == 'local') {
var localComic = LocalManager().find(widget.id, ComicType.local);
if (localComic == null) {
return const Res.error('Local comic not found');
}
var history = HistoryManager().find(widget.id, ComicType.local);
if (isFirst) {
Future.microtask(() {
App.rootContext.to(() {
return Reader(
type: ComicType.local,
cid: widget.id,
name: localComic.title,
chapters: localComic.chapters,
initialPage: history?.page,
initialChapter: history?.ep,
initialChapterGroup: history?.group,
history: history ??
History.fromModel(
model: localComic,
ep: 0,
page: 0,
),
author: localComic.subTitle ?? '',
tags: localComic.tags,
);
});
App.mainNavigatorKey!.currentContext!.pop();
});
isFirst = false;
}
await Future.delayed(const Duration(milliseconds: 200));
return const Res.error('Local comic');
}
var comicSource = ComicSource.find(widget.sourceKey);
if (comicSource == null) {
return const Res.error('Comic source not found');
}
isAddToLocalFav = LocalFavoritesManager().isExist(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
history =
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
return comicSource.loadComicInfo!(widget.id);
}
@override
Future<void> onDataLoaded() async {
isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false;
if (comic.chapters == null) {
isDownloaded = LocalManager().isDownloaded(
comic.id,
comic.comicType,
0,
);
}
}
Iterable<Widget> buildTitle() sync* {
yield SliverAppbar(
title: AnimatedOpacity(
opacity: showAppbarTitle ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Text(comic.title),
),
actions: [
IconButton(
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
],
);
yield const SliverPadding(padding: EdgeInsets.only(top: 8));
yield SliverLazyToBoxAdapter(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Hero(
tag: "cover${widget.heroID}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
),
width: double.infinity,
height: double.infinity,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14)
.paddingVertical(4),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
),
],
),
),
],
),
);
}
Widget buildActions() {
bool isMobile = context.width < changePoint;
bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1);
return SliverLazyToBoxAdapter(
child: Column(
children: [
ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
if (hasHistory && !isMobile)
_ActionButton(
icon: const Icon(Icons.menu_book),
text: 'Continue'.tl,
onPressed: continueRead,
iconColor: context.useTextColor(Colors.yellow),
),
if (!isMobile || hasHistory)
_ActionButton(
icon: const Icon(Icons.play_circle_outline),
text: 'Start'.tl,
onPressed: read,
iconColor: context.useTextColor(Colors.orange),
),
if (!isMobile && !isDownloaded)
_ActionButton(
icon: const Icon(Icons.download),
text: 'Download'.tl,
onPressed: download,
iconColor: context.useTextColor(Colors.cyan),
),
if (data!.isLiked != null)
_ActionButton(
icon: const Icon(Icons.favorite_border),
activeIcon: const Icon(Icons.favorite),
isActive: isLiked,
text: ((data!.likesCount != null)
? (data!.likesCount! + (isLiked ? 1 : 0))
: (isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(),
isLoading: isLiking,
onPressed: likeOrUnlike,
iconColor: context.useTextColor(Colors.red),
),
_ActionButton(
icon: const Icon(Icons.bookmark_outline_outlined),
activeIcon: const Icon(Icons.bookmark),
isActive: isFavorite || isAddToLocalFav,
text: 'Favorite'.tl,
onPressed: openFavPanel,
onLongPressed: quickFavorite,
iconColor: context.useTextColor(Colors.purple),
),
if (comicSource.commentsLoader != null)
_ActionButton(
icon: const Icon(Icons.comment),
text: (comic.commentCount ?? 'Comments'.tl).toString(),
onPressed: showComments,
iconColor: context.useTextColor(Colors.green),
),
_ActionButton(
icon: const Icon(Icons.share),
text: 'Share'.tl,
onPressed: share,
iconColor: context.useTextColor(Colors.blue),
),
],
).fixHeight(48),
if (isMobile)
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: download,
child: Text("Download".tl),
),
),
const SizedBox(width: 16),
Expanded(
child: hasHistory
? FilledButton(
onPressed: continueRead, child: Text("Continue".tl))
: FilledButton(onPressed: read, child: Text("Read".tl)),
)
],
).paddingHorizontal(16).paddingVertical(8),
if (history != null)
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.history, color: context.useTextColor(Colors.teal)),
const SizedBox(width: 8),
Builder(
builder: (context) {
bool haveChapter = comic.chapters != null;
var page = history!.page;
var ep = history!.ep;
var group = history!.group;
String text;
if (haveChapter) {
var epName = group == null
? comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
)
: comic.chapters!
.getGroupByIndex(group - 1)
.values
.elementAt(ep - 1);
text = "${"Last Reading".tl}: $epName P$page";
} else {
text = "${"Last Reading".tl}: P$page";
}
return Text(text);
},
),
const SizedBox(width: 4),
],
),
).toAlign(Alignment.centerLeft),
const Divider(),
],
).paddingTop(16),
);
}
Widget buildDescription() {
if (comic.description == null || comic.description!.trim().isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverLazyToBoxAdapter(
child: Column(
children: [
ListTile(
title: Text("Description".tl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!).fixWidth(double.infinity),
),
const SizedBox(height: 16),
const Divider(),
],
),
);
}
Widget buildInfo() {
if (comic.tags.isEmpty &&
comic.uploader == null &&
comic.uploadTime == null &&
comic.uploadTime == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
int i = 0;
Widget buildTag({
required String text,
VoidCallback? onTap,
bool isTitle = false,
}) {
Color color;
if (isTitle) {
const colors = [
Colors.blue,
Colors.cyan,
Colors.red,
Colors.pink,
Colors.purple,
Colors.indigo,
Colors.teal,
Colors.green,
Colors.lime,
Colors.yellow,
];
color = context.useBackgroundColor(colors[(i++) % (colors.length)]);
} else {
color = context.colorScheme.surfaceContainerLow;
}
final borderRadius = BorderRadius.circular(12);
const padding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
if (onTap != null) {
return Material(
color: color,
borderRadius: borderRadius,
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
onSecondaryTapDown: (details) {
showMenuX(context, details.globalPosition, [
MenuEntry(
icon: Icons.remove_red_eye,
text: "View".tl,
onClick: onTap,
),
MenuEntry(
icon: Icons.copy,
text: "Copy".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
),
]);
},
child: Text(text).padding(padding),
),
);
} else {
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: borderRadius,
),
child: Text(text).padding(padding),
);
}
}
String formatTime(String time) {
if (int.tryParse(time) != null) {
var t = int.tryParse(time);
if (t! > 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(t)
.toString()
.substring(0, 19);
} else {
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
.toString()
.substring(0, 19);
}
}
if (time.contains('T') || time.contains('Z')) {
var t = DateTime.parse(time);
return t.toString().substring(0, 19);
}
return time;
}
Widget buildWrap({required List<Widget> children}) {
return Wrap(
runSpacing: 8,
spacing: 8,
children: children,
).paddingHorizontal(16).paddingBottom(8);
}
bool enableTranslation =
App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate;
return SliverLazyToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text("Information".tl),
),
if (comic.stars != null)
Row(
children: [
StarRating(
value: comic.stars!,
size: 24,
onTap: starRating,
),
const SizedBox(width: 8),
Text(comic.stars!.toStringAsFixed(2)),
],
).paddingLeft(16).paddingVertical(8),
for (var e in comic.tags.entries)
buildWrap(
children: [
if (e.value.isNotEmpty)
buildTag(text: e.key.ts(comicSource.key), isTitle: true),
for (var tag in e.value)
buildTag(
text: enableTranslation
? TagsTranslation.translationTagWithNamespace(
tag,
e.key.toLowerCase(),
)
: tag,
onTap: () => onTapTag(tag, e.key),
),
],
),
if (comic.uploader != null)
buildWrap(
children: [
buildTag(text: 'Uploader'.tl, isTitle: true),
buildTag(text: comic.uploader!),
],
),
if (comic.uploadTime != null)
buildWrap(
children: [
buildTag(text: 'Upload Time'.tl, isTitle: true),
buildTag(text: formatTime(comic.uploadTime!)),
],
),
if (comic.updateTime != null)
buildWrap(
children: [
buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: formatTime(comic.updateTime!)),
],
),
const SizedBox(height: 12),
const Divider(),
],
),
);
}
Widget buildChapters() {
if (comic.chapters == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return _ComicChapters(
history: history,
groupedMode: comic.chapters!.isGrouped,
);
}
Widget buildThumbnails() {
if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return const _ComicThumbnails();
}
Widget buildRecommend() {
if (comic.recommend == null || comic.recommend!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Related".tl),
),
),
SliverGridComics(comics: comic.recommend!),
]);
}
Widget buildComments() {
if (comic.comments == null || comic.comments!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return _CommentsPart(
comments: comic.comments!,
showMore: showComments,
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.text,
required this.onPressed,
this.onLongPressed,
this.activeIcon,
this.isActive,
this.isLoading,
this.iconColor,
});
final Widget icon;
final Widget? activeIcon;
final bool? isActive;
final String text;
final void Function() onPressed;
final bool? isLoading;
final Color? iconColor;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
child: InkWell(
onTap: () {
if (!(isLoading ?? false)) {
onPressed();
}
},
onLongPress: onLongPressed,
borderRadius: BorderRadius.circular(18),
child: IconTheme.merge(
data: IconThemeData(size: 20, color: iconColor),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading ?? false)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 1.8),
)
else
(isActive ?? false) ? (activeIcon ?? icon) : icon,
const SizedBox(width: 8),
Text(text),
],
).paddingHorizontal(16),
),
),
);
}
}
class _SelectDownloadChapter extends StatefulWidget {
const _SelectDownloadChapter(this.eps, this.finishSelect, this.downloadedEps);
final List<String> eps;
final void Function(List<int>) finishSelect;
final List<int> downloadedEps;
@override
State<_SelectDownloadChapter> createState() => _SelectDownloadChapterState();
}
class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
List<int> selected = [];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text("Download".tl),
backgroundColor: context.colorScheme.surfaceContainerLow,
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: widget.eps.length,
itemBuilder: (context, i) {
return CheckboxListTile(
title: Text(widget.eps[i]),
value: selected.contains(i) ||
widget.downloadedEps.contains(i),
onChanged: widget.downloadedEps.contains(i)
? null
: (v) {
setState(() {
if (selected.contains(i)) {
selected.remove(i);
} else {
selected.add(i);
}
});
});
},
),
),
Container(
height: 50,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
),
),
),
child: Row(
children: [
const SizedBox(width: 16),
Expanded(
child: TextButton(
onPressed: () {
var res = <int>[];
for (int i = 0; i < widget.eps.length; i++) {
if (!widget.downloadedEps.contains(i)) {
res.add(i);
}
}
widget.finishSelect(res);
context.pop();
},
child: Text("Download All".tl),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: selected.isEmpty
? null
: () {
widget.finishSelect(selected);
context.pop();
},
child: Text("Download Selected".tl),
),
),
const SizedBox(width: 16),
],
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom),
],
),
);
}
}
class _ComicPageLoadingPlaceHolder extends StatelessWidget {
const _ComicPageLoadingPlaceHolder({
this.cover,
this.title,
required this.sourceKey,
required this.cid,
this.heroID,
});
final String? cover;
final String? title;
final String sourceKey;
final String cid;
final int? heroID;
@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(
color: context.isDarkMode ? Colors.grey.shade700 : Colors.white,
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$heroID",
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

@@ -1,25 +1,18 @@
import 'dart:collection';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
part of 'comic_page.dart';
class CommentsPage extends StatefulWidget {
const CommentsPage(
{super.key, required this.data, required this.source, this.replyId});
const CommentsPage({
super.key,
required this.data,
required this.source,
this.replyComment,
});
final ComicDetails data;
final ComicSource source;
final String? replyId;
final Comment? replyComment;
@override
State<CommentsPage> createState() => _CommentsPageState();
@@ -36,13 +29,13 @@ class _CommentsPageState extends State<CommentsPage> {
void firstLoad() async {
var res = await widget.source.commentsLoader!(
widget.data.comicId, widget.data.subId, 1, widget.replyId);
widget.data.comicId, widget.data.subId, 1, widget.replyComment?.id);
if (res.error) {
setState(() {
_error = res.errorMessage;
_loading = false;
});
} else {
} else if (mounted) {
setState(() {
_comments = res.data;
_loading = false;
@@ -53,7 +46,11 @@ class _CommentsPageState extends State<CommentsPage> {
void loadMore() async {
var res = await widget.source.commentsLoader!(
widget.data.comicId, widget.data.subId, _page + 1, widget.replyId);
widget.data.comicId,
widget.data.subId,
_page + 1,
widget.replyComment?.id,
);
if (res.error) {
context.showMessage(message: res.errorMessage ?? "Unknown Error");
} else {
@@ -73,6 +70,7 @@ class _CommentsPageState extends State<CommentsPage> {
resizeToAvoidBottomInset: false,
appBar: Appbar(
title: Text("Comments".tl),
style: AppbarStyle.shadow,
),
body: buildBody(context),
);
@@ -104,8 +102,44 @@ class _CommentsPageState extends State<CommentsPage> {
child: ListView.builder(
primary: false,
padding: EdgeInsets.zero,
itemCount: _comments!.length + 1,
itemCount: _comments!.length + 2,
itemBuilder: (context, index) {
if (index == 0) {
if (widget.replyComment != null) {
return Column(
children: [
_CommentTile(
comment: widget.replyComment!,
source: widget.source,
comic: widget.data,
showAvatar: showAvatar,
showActions: false,
),
const SizedBox(height: 8),
Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Text(
"Replies".tl,
style: ts.s18,
),
),
],
);
} else {
return const SizedBox();
}
}
index--;
if (index == _comments!.length) {
if (_page < (maxPage ?? _page + 1)) {
loadMore();
@@ -140,6 +174,12 @@ class _CommentsPageState extends State<CommentsPage> {
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Material(
color: context.colorScheme.surfaceContainer,
@@ -159,7 +199,7 @@ class _CommentsPageState extends State<CommentsPage> {
),
if (sending)
const Padding(
padding: EdgeInsets.all(8.5),
padding: EdgeInsets.all(8),
child: SizedBox(
width: 24,
height: 24,
@@ -181,7 +221,7 @@ class _CommentsPageState extends State<CommentsPage> {
widget.data.comicId,
widget.data.subId,
controller.text,
widget.replyId);
widget.replyComment?.id);
if (!b.error) {
controller.text = "";
setState(() {
@@ -204,7 +244,7 @@ class _CommentsPageState extends State<CommentsPage> {
),
)
],
).paddingVertical(2).paddingLeft(16).paddingRight(4),
).paddingLeft(16).paddingRight(4),
),
);
}
@@ -216,6 +256,7 @@ class _CommentTile extends StatefulWidget {
required this.source,
required this.comic,
required this.showAvatar,
this.showActions = true,
});
final Comment comment;
@@ -226,6 +267,8 @@ class _CommentTile extends StatefulWidget {
final bool showAvatar;
final bool showActions;
@override
State<_CommentTile> createState() => _CommentTileState();
}
@@ -242,24 +285,17 @@ class _CommentTileState extends State<_CommentTile> {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showAvatar)
Container(
width: 40,
height: 40,
width: 36,
height: 36,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.secondaryContainer),
child: widget.comment.avatar == null
? null
@@ -269,7 +305,7 @@ class _CommentTileState extends State<_CommentTile> {
sourceKey: widget.source.key,
),
),
).paddingRight(12),
).paddingRight(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -287,11 +323,14 @@ class _CommentTileState extends State<_CommentTile> {
),
)
],
).paddingAll(16),
),
);
}
Widget buildActions() {
if (!widget.showActions) {
return const SizedBox();
}
if (widget.comment.score == null && widget.comment.replyCount == null) {
return const SizedBox();
}
@@ -330,7 +369,7 @@ class _CommentTileState extends State<_CommentTile> {
CommentsPage(
data: widget.comic,
source: widget.source,
replyId: widget.comment.id,
replyComment: widget.comment,
),
showBarrier: false,
);
@@ -529,6 +568,7 @@ class _Tag {
'u' => style.underline,
's' => style.lineThrough,
'a' => style.withColor(context.colorScheme.primary),
'strong' => style.bold,
'span' => () {
if (attributes.containsKey('style')) {
var s = attributes['style']!;
@@ -622,10 +662,14 @@ class RichCommentContent extends StatefulWidget {
class _RichCommentContentState extends State<RichCommentContent> {
var textSpan = <InlineSpan>[];
var images = <_CommentImage>[];
bool isRendered = false;
@override
void didChangeDependencies() {
render();
if (!isRendered) {
render();
isRendered = true;
}
super.didChangeDependencies();
}
@@ -670,7 +714,17 @@ class _RichCommentContentState extends State<RichCommentContent> {
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', '');
}
}
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span'];
const acceptedTags = [
'img',
'a',
'b',
'i',
'u',
's',
'br',
'span',
'strong'
];
if (acceptedTags.contains(tagName)) {
writeBuffer();
if (tagName == 'img') {

View File

@@ -0,0 +1,150 @@
part of 'comic_page.dart';
class _CommentsPart extends StatefulWidget {
const _CommentsPart({
required this.comments,
required this.showMore,
});
final List<Comment> comments;
final void Function() showMore;
@override
State<_CommentsPart> createState() => _CommentsPartState();
}
class _CommentsPartState extends State<_CommentsPart> {
final scrollController = ScrollController();
late List<Comment> comments;
@override
void initState() {
comments = widget.comments;
super.initState();
}
@override
Widget build(BuildContext context) {
return MultiSliver(
children: [
SliverLazyToBoxAdapter(
child: ListTile(
title: Text("Comments".tl),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
scrollController.animateTo(
scrollController.position.pixels - 340,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
scrollController.animateTo(
scrollController.position.pixels + 340,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
),
],
),
),
),
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 184,
child: MediaQuery.removePadding(
removeTop: true,
context: context,
child: ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
itemCount: comments.length,
itemBuilder: (context, index) {
return _CommentWidget(comment: comments[index]);
},
),
),
),
const SizedBox(height: 8),
_ActionButton(
icon: const Icon(Icons.comment),
text: "View more".tl,
onPressed: widget.showMore,
iconColor: context.useTextColor(Colors.green),
).fixHeight(48).paddingRight(8).toAlign(Alignment.centerRight),
const SizedBox(height: 8),
],
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}
class _CommentWidget extends StatelessWidget {
const _CommentWidget({required this.comment});
final Comment comment;
@override
Widget build(BuildContext context) {
return Container(
height: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 0, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
width: 324,
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
children: [
if (comment.avatar != null)
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: context.colorScheme.surfaceContainer,
),
clipBehavior: Clip.antiAlias,
child: Image(
image: CachedImageProvider(comment.avatar!),
width: 36,
height: 36,
fit: BoxFit.cover,
),
).paddingRight(8),
Text(comment.userName, style: ts.bold),
],
),
const SizedBox(height: 4),
Expanded(
child: RichCommentContent(text: comment.content).fixWidth(324),
),
const SizedBox(height: 4),
if (comment.time != null)
Text(comment.time!, style: ts.s12).toAlign(Alignment.centerLeft),
],
),
);
}
}

View File

@@ -0,0 +1,432 @@
part of 'comic_page.dart';
class _FavoritePanel extends StatefulWidget {
const _FavoritePanel({
required this.cid,
required this.type,
required this.isFavorite,
required this.onFavorite,
required this.favoriteItem,
this.updateTime,
});
final String cid;
final ComicType type;
/// whether the comic is in the network favorite list
///
/// if null, the comic source does not support favorite or support multiple favorite lists
final bool? isFavorite;
final void Function(bool?, bool?) onFavorite;
final FavoriteItem favoriteItem;
final String? updateTime;
@override
State<_FavoritePanel> createState() => _FavoritePanelState();
}
class _FavoritePanelState extends State<_FavoritePanel>
with SingleTickerProviderStateMixin {
late ComicSource comicSource;
late TabController tabController;
late bool hasNetwork;
@override
void initState() {
comicSource = widget.type.comicSource!;
localFolders = LocalFavoritesManager().folderNames;
added = LocalFavoritesManager().find(widget.cid, widget.type);
hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
var initIndex = 0;
if (appdata.implicitData['favoritePanelIndex'] is int) {
initIndex = appdata.implicitData['favoritePanelIndex'];
}
initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0);
tabController = TabController(
initialIndex: initIndex,
length: hasNetwork ? 2 : 1,
vsync: this,
);
super.initState();
}
@override
void dispose() {
var currentIndex = tabController.index;
appdata.implicitData['favoritePanelIndex'] = currentIndex;
appdata.writeImplicitData();
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text("Favorite".tl),
),
body: Column(
children: [
TabBar(
controller: tabController,
tabs: [
Tab(text: "Local".tl),
if (hasNetwork) Tab(text: "Network".tl),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
buildLocal(),
if (hasNetwork) buildNetwork(),
],
),
),
],
),
);
}
late List<String> localFolders;
late List<String> added;
var selectedLocalFolders = <String>{};
Widget buildLocal() {
var isRemove = selectedLocalFolders.isNotEmpty &&
added.contains(selectedLocalFolders.first);
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: localFolders.length + 1,
itemBuilder: (context, index) {
if (index == localFolders.length) {
return SizedBox(
height: 36,
child: Center(
child: TextButton(
onPressed: () {
newFolder().then((v) {
setState(() {
localFolders = LocalFavoritesManager().folderNames;
});
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 4),
Text("New Folder".tl)
],
),
),
),
);
}
var folder = localFolders[index];
var disabled = false;
if (selectedLocalFolders.isNotEmpty) {
if (added.contains(folder) &&
!added.contains(selectedLocalFolders.first)) {
disabled = true;
} else if (!added.contains(folder) &&
added.contains(selectedLocalFolders.first)) {
disabled = true;
}
}
return CheckboxListTile(
title: Row(
children: [
Text(folder),
const SizedBox(width: 8),
if (added.contains(folder))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
value: selectedLocalFolders.contains(folder),
onChanged: disabled
? null
: (v) {
setState(() {
if (v!) {
selectedLocalFolders.add(folder);
} else {
selectedLocalFolders.remove(folder);
}
});
},
);
},
),
),
Center(
child: FilledButton(
onPressed: () {
if (selectedLocalFolders.isEmpty) {
return;
}
if (isRemove) {
for (var folder in selectedLocalFolders) {
LocalFavoritesManager()
.deleteComicWithId(folder, widget.cid, widget.type);
}
widget.onFavorite(false, null);
} else {
for (var folder in selectedLocalFolders) {
LocalFavoritesManager().addComic(
folder,
widget.favoriteItem,
null,
widget.updateTime,
);
}
widget.onFavorite(true, null);
}
context.pop();
},
child: isRemove ? Text("Remove".tl) : Text("Add".tl),
).paddingVertical(8),
),
],
);
}
Widget buildNetwork() {
return _NetworkFavorites(
cid: widget.cid,
comicSource: comicSource,
isFavorite: widget.isFavorite,
onFavorite: (network) {
widget.onFavorite(null, network);
},
);
}
}
class _NetworkFavorites extends StatefulWidget {
const _NetworkFavorites({
required this.cid,
required this.comicSource,
required this.isFavorite,
required this.onFavorite,
});
final String cid;
final ComicSource comicSource;
final bool? isFavorite;
final void Function(bool) onFavorite;
@override
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
}
class _NetworkFavoritesState extends State<_NetworkFavorites> {
@override
Widget build(BuildContext context) {
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
}
bool isLoading = false;
Widget buildSingleFolder() {
var isFavorite = widget.isFavorite ?? false;
return Column(
children: [
Expanded(
child: Center(
child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl),
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
setState(() {
isLoading = true;
});
var res = await widget.comicSource.favoriteData!
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
if (res.success) {
widget.onFavorite(!isFavorite);
context.pop();
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl);
} else {
setState(() {
isLoading = false;
});
context.showMessage(message: res.errorMessage!);
}
},
child: isFavorite ? Text("Remove".tl) : Text("Add".tl),
).paddingVertical(8),
),
],
);
}
Map<String, String>? folders;
var addedFolders = <String>{};
var isLoadingFolders = true;
// for network favorites, only one selection is allowed
String? selected;
void loadFolders() async {
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
if (res.error) {
context.showMessage(message: res.errorMessage!);
} else {
folders = res.data;
if (res.subData is List) {
addedFolders = List<String>.from(res.subData).toSet();
}
setState(() {
isLoadingFolders = false;
});
}
}
Widget buildMultiFolder() {
if (widget.isFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
return Column(
children: [
Expanded(
child: Center(
child: Text("Added to favorites".tl),
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
setState(() {
isLoading = true;
});
var res = await widget.comicSource.favoriteData!
.addOrDelFavorite!(widget.cid, '', false, null);
if (res.success) {
widget.onFavorite(false);
context.pop();
App.rootContext.showMessage(message: "Removed".tl);
} else {
setState(() {
isLoading = false;
});
context.showMessage(message: res.errorMessage!);
}
},
child: Text("Remove".tl),
).paddingVertical(8),
),
],
);
}
if (isLoadingFolders) {
loadFolders();
return const Center(child: CircularProgressIndicator());
} else {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: folders!.length,
itemBuilder: (context, index) {
var name = folders!.values.elementAt(index);
var id = folders!.keys.elementAt(index);
return CheckboxListTile(
title: Row(
children: [
Text(name),
const SizedBox(width: 8),
if (addedFolders.contains(id))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
value: selected == id,
onChanged: (v) {
setState(() {
selected = id;
});
},
);
},
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
if (selected == null) {
return;
}
setState(() {
isLoading = true;
});
var res =
await widget.comicSource.favoriteData!.addOrDelFavorite!(
widget.cid,
selected!,
!addedFolders.contains(selected!),
null,
);
if (res.success) {
context.showMessage(message: "Success".tl);
context.pop();
} else {
context.showMessage(message: res.errorMessage!);
setState(() {
isLoading = false;
});
}
},
child: selected != null && addedFolders.contains(selected!)
? Text("Remove".tl)
: Text("Add".tl),
).paddingVertical(8),
),
],
);
}
}
}

View File

@@ -0,0 +1,169 @@
part of 'comic_page.dart';
class _ComicThumbnails extends StatefulWidget {
const _ComicThumbnails();
@override
State<_ComicThumbnails> createState() => _ComicThumbnailsState();
}
class _ComicThumbnailsState extends State<_ComicThumbnails> {
late _ComicPageState state;
late List<String> thumbnails;
bool isInitialLoading = true;
String? next;
String? error;
bool isLoading = false;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
loadNext();
thumbnails = List.from(state.comic.thumbnails ?? []);
super.didChangeDependencies();
}
void loadNext() async {
if (state.comicSource.loadComicThumbnail == null) return;
if (!isInitialLoading && next == null) {
return;
}
if (isLoading) return;
Future.microtask(() {
setState(() {
isLoading = true;
});
});
var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next);
if (res.success) {
thumbnails.addAll(res.data);
next = res.subData;
isInitialLoading = false;
} else {
error = res.errorMessage;
}
if (mounted) {
setState(() {
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return MultiSliver(
children: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Preview".tl),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: thumbnails.length,
(context, index) {
if (index == thumbnails.length - 1 && error == null) {
loadNext();
}
var url = thumbnails[index];
ImagePart? part;
if (url.contains('@')) {
var params = url.split('@')[1].split('&');
url = url.split('@')[0];
double? x1, y1, x2, y2;
try {
for (var p in params) {
if (p.startsWith('x')) {
var r = p.split('=')[1];
x1 = double.parse(r.split('-')[0]);
x2 = double.parse(r.split('-')[1]);
}
if (p.startsWith('y')) {
var r = p.split('=')[1];
y1 = double.parse(r.split('-')[0]);
y2 = double.parse(r.split('-')[1]);
}
}
} catch (_) {
// ignore
}
part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2);
}
return Padding(
padding: context.width < changePoint
? const EdgeInsets.all(4)
: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InkWell(
onTap: () => state.read(null, index + 1),
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
url,
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
part: part,
),
),
),
),
const SizedBox(
height: 4,
),
Text((index + 1).toString()),
],
),
);
},
),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.68,
),
),
if (error != null)
SliverToBoxAdapter(
child: Column(
children: [
Text(error!),
Button.outlined(
onPressed: loadNext,
child: Text("Retry".tl),
)
],
),
)
else if (isLoading)
const SliverListLoadingIndicator(),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,15 @@ class DownloadingPage extends StatefulWidget {
}
class _DownloadingPageState extends State<DownloadingPage> {
DownloadTask? firstTask;
@override
void didChangeDependencies() {
super.didChangeDependencies();
firstTask = LocalManager().downloadingTasks.firstOrNull;
firstTask?.addListener(update);
}
@override
void initState() {
LocalManager().addListener(update);
@@ -24,10 +33,17 @@ class _DownloadingPageState extends State<DownloadingPage> {
@override
void dispose() {
LocalManager().removeListener(update);
firstTask?.removeListener(update);
super.dispose();
}
void update() {
var currentFirstTask = LocalManager().downloadingTasks.firstOrNull;
if (currentFirstTask != firstTask) {
firstTask?.removeListener(update);
firstTask = currentFirstTask;
firstTask?.addListener(update);
}
if(mounted) {
setState(() {});
}
@@ -46,6 +62,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
i--;
return _DownloadTaskTile(
key: ValueKey(LocalManager().downloadingTasks[i]),
task: LocalManager().downloadingTasks[i],
);
},
@@ -120,7 +137,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
}
class _DownloadTaskTile extends StatefulWidget {
const _DownloadTaskTile({required this.task});
const _DownloadTaskTile({required this.task, super.key});
final DownloadTask task;
@@ -129,20 +146,33 @@ class _DownloadTaskTile extends StatefulWidget {
}
class _DownloadTaskTileState extends State<_DownloadTaskTile> {
late DownloadTask task;
@override
void initState() {
widget.task.addListener(update);
task = widget.task;
task.addListener(update);
super.initState();
}
@override
void dispose() {
widget.task.removeListener(update);
task.removeListener(update);
super.dispose();
}
@override
void didUpdateWidget(covariant _DownloadTaskTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.task != widget.task) {
task.removeListener(update);
task = widget.task;
task.addListener(update);
}
}
void update() {
context.findAncestorStateOfType<_DownloadingPageState>()?.update();
setState(() {});
}
@override

View File

@@ -3,9 +3,11 @@ 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/global_state.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
@@ -35,7 +37,7 @@ class _ExplorePageState extends State<ExplorePage>
.expand((e) => e.map((e) => e.title))
.toList();
explorePages = explorePages.where((e) => all.contains(e)).toList();
if (!pages.isEqualsTo(explorePages)) {
if (!pages.isEqualTo(explorePages)) {
setState(() {
pages = explorePages;
controller = TabController(
@@ -50,12 +52,14 @@ class _ExplorePageState extends State<ExplorePage>
if (index == 2) {
int page = controller.index;
String currentPageId = pages[page];
StateController.find<SimpleController>(tag: currentPageId)
.control!()['toTop']
?.call();
GlobalState.find<_SingleExplorePageState>(currentPageId).toTop();
}
}
void addPage() {
showPopUpWidget(App.rootContext, setExplorePagesWidget());
}
NaviPaneState? naviPane;
@override
@@ -92,7 +96,7 @@ class _ExplorePageState extends State<ExplorePage>
void refresh() {
int page = controller.index;
String currentPageId = pages[page];
StateController.find<SimpleController>(tag: currentPageId).refresh();
GlobalState.find<_SingleExplorePageState>(currentPageId).refresh();
}
Widget buildFAB() => Material(
@@ -117,26 +121,21 @@ class _ExplorePageState extends State<ExplorePage>
Widget buildEmpty() {
var msg = "No Explore Pages".tl;
msg += '\n';
VoidCallback onTap;
if (ComicSource.isEmpty) {
msg += "Add a comic source in home page".tl;
msg += "Please add some sources".tl;
onTap = () {
context.to(() => ComicSourcePage());
};
} else {
msg += "Please check your settings".tl;
onTap = addPage;
}
return NetworkError(
message: msg,
retry: () {
setState(() {
pages = ComicSource.all()
.map((e) => e.explorePages)
.expand((e) => e.map((e) => e.title))
.toList();
controller = TabController(
length: pages.length,
vsync: this,
);
});
},
retry: onTap,
withAppbar: false,
buttonText: "Manage".tl,
);
}
@@ -148,10 +147,15 @@ class _ExplorePageState extends State<ExplorePage>
}
Widget tabBar = Material(
child: FilledTabBar(
child: AppTabBar(
key: PageStorageKey(pages.toString()),
tabs: pages.map((e) => buildTab(e)).toList(),
controller: controller,
actionButton: TabActionButton(
icon: const Icon(Icons.add),
text: "Add".tl,
onPressed: addPage,
),
),
).paddingTop(context.padding.top);
@@ -238,7 +242,7 @@ class _SingleExplorePage extends StatefulWidget {
State<_SingleExplorePage> createState() => _SingleExplorePageState();
}
class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
class _SingleExplorePageState extends AutomaticGlobalState<_SingleExplorePage>
with AutomaticKeepAliveClientMixin<_SingleExplorePage> {
late final ExplorePageData data;
@@ -322,7 +326,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
}
@override
Object? get tag => widget.title;
Object? get key => widget.title;
@override
void refresh() {
@@ -341,9 +345,6 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
);
}
}
@override
Map<String, dynamic> get control => {"toTop": toTop};
}
class _MixedExplorePage extends StatefulWidget {

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
@@ -14,8 +15,9 @@ import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';

View File

@@ -351,6 +351,21 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
text: "Download".tl,
onClick: downloadSelected,
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.copy,
text: "Copy Title".tl,
onClick: () {
Clipboard.setData(
ClipboardData(
text: selectedComics.keys.first.title,
),
);
context.showMessage(
message: "Copied".tl,
);
},
),
]),
],
)

View File

@@ -476,55 +476,47 @@ class _CreateFolderDialogState extends State<_CreateFolderDialog> {
@override
Widget build(BuildContext context) {
return SimpleDialog(
title: Text("Create a folder".tl),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
child: TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "name".tl,
),
),
),
const SizedBox(
width: 200,
height: 10,
),
if (loading)
Center(
child: const CircularProgressIndicator(
strokeWidth: 2,
).fixWidth(24).fixHeight(24),
)
else
SizedBox(
height: 35,
child: Center(
child: TextButton(
onPressed: () {
setState(() {
loading = true;
});
widget.data.addFolder!(controller.text).then((b) {
if (b.error) {
context.showMessage(message: b.errorMessage!);
setState(() {
loading = false;
});
} else {
context.pop();
context.showMessage(message: "Created successfully".tl);
widget.updateState();
}
});
},
child: Text("Submit".tl),
return ContentDialog(
title: "Create a folder".tl,
content: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "name".tl,
),
),
)
),
const SizedBox(
height: 16
),
],
),
actions: [
Button.filled(
isLoading: loading,
onPressed: () {
setState(() {
loading = true;
});
widget.data.addFolder!(controller.text).then((b) {
if (b.error) {
context.showMessage(message: b.errorMessage!);
setState(() {
loading = false;
});
} else {
context.pop();
context.showMessage(message: "Created successfully".tl);
widget.updateState();
}
});
},
child: Text("Submit".tl),
)
],
);
}

View File

@@ -20,22 +20,35 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
var networkFolders = <String>[];
void findNetworkFolders() {
networkFolders.clear();
var all = ComicSource.all()
.where((e) => e.favoriteData != null)
.map((e) => e.favoriteData!.key)
.toList();
var settings = appdata.settings['favorites'] as List;
for (var p in settings) {
if (all.contains(p) && !networkFolders.contains(p)) {
networkFolders.add(p);
}
}
}
@override
void initState() {
favPage = widget.favPage ??
context.findAncestorStateOfType<_FavoritesPageState>()!;
favPage.folderList = this;
folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all()
.where((e) => e.favoriteData != null && e.isLogged)
.map((e) => e.favoriteData!.key)
.toList();
findNetworkFolders();
appdata.settings.addListener(updateFolders);
super.initState();
}
@override
void dispose() {
super.dispose();
appdata.settings.removeListener(updateFolders);
}
@override
@@ -102,7 +115,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
onClick: () {
newFolder().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
folders =
LocalFavoritesManager().folderNames;
});
});
},
@@ -113,7 +127,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
onClick: () {
sortFolders().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
folders =
LocalFavoritesManager().folderNames;
});
});
},
@@ -143,15 +158,24 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
),
child: Row(
children: [
const SizedBox(width: 16),
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
),
).paddingHorizontal(16),
);
}
index--;
@@ -241,10 +265,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
if (!mounted) return;
setState(() {
folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all()
.where((e) => e.favoriteData != null)
.map((e) => e.favoriteData!.key)
.toList();
findNetworkFolders();
});
}
}

View File

@@ -0,0 +1,717 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/translations.dart';
import '../foundation/global_state.dart';
class FollowUpdatesWidget extends StatefulWidget {
const FollowUpdatesWidget({super.key});
@override
State<FollowUpdatesWidget> createState() => _FollowUpdatesWidgetState();
}
class _FollowUpdatesWidgetState
extends AutomaticGlobalState<FollowUpdatesWidget> {
int _count = 0;
String? get folder => appdata.settings["followUpdatesFolder"];
void getCount() {
if (folder == null) {
_count = 0;
return;
}
if (!LocalFavoritesManager().folderNames.contains(folder)) {
_count = 0;
appdata.settings["followUpdatesFolder"] = null;
Future.microtask(() {
appdata.saveData();
});
} else {
_count = LocalFavoritesManager().countUpdates(folder!);
}
}
void updateCount() {
setState(() {
getCount();
});
}
@override
void initState() {
super.initState();
getCount();
}
@override
Widget build(BuildContext context) {
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(() => FollowUpdatesPage());
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 56,
child: Row(
children: [
Center(
child: Text('Follow Updates'.tl, style: ts.s18),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
),
).paddingHorizontal(16),
if (_count > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
margin: const EdgeInsets.only(bottom: 16, left: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.primaryContainer,
),
child: Text(
'@c updates'.tlParams({
'c': _count,
}),
style: ts.s16,
),
),
],
),
),
),
);
}
@override
Object? get key => 'FollowUpdatesWidget';
}
class FollowUpdatesPage extends StatefulWidget {
const FollowUpdatesPage({super.key});
@override
State<FollowUpdatesPage> createState() => _FollowUpdatesPageState();
}
class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
String? get folder => appdata.settings["followUpdatesFolder"];
var updatedComics = <FavoriteItemWithUpdateInfo>[];
var allComics = <FavoriteItemWithUpdateInfo>[];
/// Sort comics by update time in descending order with nulls at the end.
void sortComics() {
allComics.sort((a, b) {
if (a.updateTime == null && b.updateTime == null) {
return 0;
} else if (a.updateTime == null) {
return -1;
} else if (b.updateTime == null) {
return 1;
}
try {
var aNums = a.updateTime!.split('-').map(int.parse).toList();
var bNums = b.updateTime!.split('-').map(int.parse).toList();
for (int i = 0; i < aNums.length; i++) {
if (aNums[i] != bNums[i]) {
return bNums[i] - aNums[i];
}
}
return 0;
} catch (_) {
return 0;
}
});
}
@override
void initState() {
super.initState();
if (folder != null) {
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder!);
sortComics();
updatedComics = allComics.where((c) => c.hasNewUpdate).toList();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text('Follow Updates'.tl)),
if (folder == null)
buildNotConfigured(context)
else
buildConfigured(context),
SliverPadding(padding: const EdgeInsets.only(top: 8)),
buildUpdatedComics(),
buildAllComics(),
],
),
);
}
Widget buildNotConfigured(BuildContext context) {
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.info_outline),
title: Text("Not Configured".tl),
),
Text(
"Choose a folder to follow updates.".tl,
style: ts.s16,
).paddingHorizontal(16),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: showSelector,
child: Text("Choose Folder".tl),
).paddingHorizontal(16).toAlign(Alignment.centerRight),
const SizedBox(height: 16),
],
),
),
);
}
Widget buildConfigured(BuildContext context) {
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.stars_outlined),
title: Text(folder!),
),
Text(
"Automatic update checking enabled.".tl,
style: ts.s14,
).paddingHorizontal(16),
Text(
"The app will check for updates at most once a day.".tl,
style: ts.s14,
).paddingHorizontal(16),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: showSelector,
child: Text("Change Folder".tl),
),
FilledButton.tonal(
onPressed: checkNow,
child: Text("Check Now".tl),
),
const SizedBox(width: 16),
],
),
const SizedBox(height: 16),
],
),
),
);
}
Widget buildUpdatedComics() {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(Icons.update),
const SizedBox(width: 8),
Text(
"Updates".tl,
style: ts.s18,
),
const Spacer(),
if (updatedComics.isNotEmpty)
IconButton(
icon: Icon(Icons.clear_all),
onPressed: () {
showConfirmDialog(
context: App.rootContext,
title: "Mark all as read".tl,
content: "Do you want to mark all as read?".tl,
onConfirm: () {
for (var comic in updatedComics) {
LocalFavoritesManager().markAsRead(
comic.id,
comic.type,
);
}
updateFollowUpdatesUI();
},
);
},
),
],
),
),
),
if (updatedComics.isNotEmpty)
SliverToBoxAdapter(
child: Text(
"The comic will be marked as no updates as soon as you read it."
.tl)
.paddingHorizontal(16)
.paddingVertical(4),
),
if (updatedComics.isNotEmpty)
SliverGridComics(comics: updatedComics)
else
SliverToBoxAdapter(
child: Row(
children: [
Container(
margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"No updates found".tl,
style: ts.s16,
),
],
),
)
],
),
),
],
);
}
Widget buildAllComics() {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(Icons.list),
const SizedBox(width: 8),
Text(
"All Comics".tl,
style: ts.s18,
),
],
),
),
),
SliverGridComics(comics: allComics),
],
);
}
void showSelector() {
var folders = LocalFavoritesManager().folderNames;
if (folders.isEmpty) {
context.showMessage(message: "No folders available".tl);
return;
}
String? selectedFolder;
showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Choose Folder".tl,
content: Column(
children: [
ListTile(
title: Text("Folder".tl),
trailing: Select(
minWidth: 120,
current: selectedFolder,
values: folders,
onTap: (i) {
setState(() {
selectedFolder = folders[i];
});
},
),
),
],
),
actions: [
if (appdata.settings["followUpdatesFolder"] != null)
TextButton(
onPressed: () {
disable();
context.pop();
},
child: Text("Disable".tl),
),
FilledButton(
onPressed: selectedFolder == null
? null
: () {
context.pop();
setFolder(selectedFolder!);
},
child: Text("Confirm".tl),
),
],
);
});
},
);
}
void disable() {
appdata.settings["followUpdatesFolder"] = null;
appdata.saveData();
updateFollowUpdatesUI();
}
void setFolder(String folder) async {
FollowUpdatesService._cancelChecking?.call();
LocalFavoritesManager().prepareTableForFollowUpdates(folder);
var count = LocalFavoritesManager().count(folder);
if (count > 0) {
bool isCanceled = false;
void onCancel() {
isCanceled = true;
}
var loadingController = showLoadingDialog(
App.rootContext,
withProgress: true,
cancelButtonText: "Cancel".tl,
onCancel: onCancel,
message: "Updating comics...".tl,
);
await for (var progress in _updateFolder(folder, true)) {
if (isCanceled) {
return;
}
loadingController.setProgress(progress.current / progress.total);
}
loadingController.close();
}
setState(() {
appdata.settings["followUpdatesFolder"] = folder;
updatedComics = [];
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
sortComics();
});
appdata.saveData();
}
void checkNow() async {
FollowUpdatesService._cancelChecking?.call();
bool isCanceled = false;
void onCancel() {
isCanceled = true;
}
var loadingController = showLoadingDialog(
App.rootContext,
withProgress: true,
cancelButtonText: "Cancel".tl,
onCancel: onCancel,
message: "Updating comics...".tl,
);
int updated = 0;
await for (var progress in _updateFolder(folder!, true)) {
if (isCanceled) {
return;
}
loadingController.setProgress(progress.current / progress.total);
updated = progress.updated;
}
loadingController.close();
if (updated > 0) {
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
updateComics();
}
}
void updateComics() {
if (folder == null) {
setState(() {
allComics = [];
updatedComics = [];
});
return;
}
setState(() {
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder!);
sortComics();
updatedComics = allComics.where((c) => c.hasNewUpdate).toList();
});
}
@override
Object? get key => 'FollowUpdatesPage';
}
class _UpdateProgress {
final int total;
final int current;
final int errors;
final int updated;
_UpdateProgress(this.total, this.current, this.errors, this.updated);
}
void _updateFolderBase(
String folder,
StreamController<_UpdateProgress> stream,
bool ignoreCheckTime,
) async {
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
int current = 0;
int errors = 0;
int updated = 0;
var futures = <Future>[];
const maxConcurrent = 5;
for (int i = 0; i < comics.length; i++) {
if (stream.isClosed) {
return;
}
if (!ignoreCheckTime) {
var lastCheckTime = comics[i].lastCheckTime;
if (lastCheckTime != null &&
DateTime.now().difference(lastCheckTime).inDays < 1) {
current++;
stream.add(_UpdateProgress(comics.length, current, errors, updated));
continue;
}
}
if (futures.length >= maxConcurrent) {
await Future.any(futures);
}
var future = () async {
int retries = 3;
while (true) {
try {
var c = comics[i];
var comicSource = c.type.comicSource;
if (comicSource == null) return;
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
var item = FavoriteItem(
id: c.id,
name: newInfo.title,
coverPath: newInfo.cover,
author: newInfo.subTitle ??
newInfo.tags['author']?.firstOrNull ??
c.author,
type: c.type,
tags: newTags,
);
LocalFavoritesManager().updateInfo(folder, item, false);
var updateTime = newInfo.findUpdateTime();
if (updateTime != null && updateTime != c.updateTime) {
LocalFavoritesManager().updateUpdateTime(
folder,
c.id,
c.type,
updateTime,
);
} else {
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
}
updated++;
return;
} catch (e, s) {
Log.error("Check Updates", e, s);
retries--;
if (retries == 0) {
errors++;
return;
}
} finally {
current++;
stream.add(_UpdateProgress(comics.length, current, errors, updated));
}
}
}();
future.then((_) {
futures.remove(future);
});
futures.add(future);
}
await Future.wait(futures);
if (updated > 0) {
LocalFavoritesManager().notifyChanges();
}
stream.close();
}
Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) {
var stream = StreamController<_UpdateProgress>();
_updateFolderBase(folder, stream, ignoreCheckTime);
return stream.stream;
}
/// Background service for checking updates
abstract class FollowUpdatesService {
static bool _isChecking = false;
static void Function()? _cancelChecking;
static bool _isInitialized = false;
static void _check() async {
if (_isChecking) {
return;
}
var folder = appdata.settings["followUpdatesFolder"];
if (folder == null) {
return;
}
bool isCanceled = false;
_cancelChecking = () {
isCanceled = true;
};
_isChecking = true;
while (DataSync().isDownloading) {
await Future.delayed(const Duration(milliseconds: 100));
}
int updated = 0;
try {
await for (var progress in _updateFolder(folder, false)) {
if (isCanceled) {
return;
}
updated = progress.updated;
}
} finally {
_cancelChecking = null;
_isChecking = false;
if (updated > 0) {
updateFollowUpdatesUI();
}
}
}
/// Initialize the checker.
static void initChecker() {
if (_isInitialized) return;
_isInitialized = true;
_check();
// A short interval will not affect the performance since every comic has a check time.
Timer.periodic(const Duration(minutes: 5), (timer) {
_check();
});
}
}
/// Update the UI of follow updates.
void updateFollowUpdatesUI() {
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics();
}

View File

@@ -29,86 +29,211 @@ class _HistoryPageState extends State<HistoryPage> {
void onUpdate() {
setState(() {
comics = HistoryManager().getAll();
if (multiSelectMode) {
selectedComics.removeWhere((comic, _) => !comics.contains(comic));
if (selectedComics.isEmpty) {
multiSelectMode = false;
}
}
});
}
var comics = HistoryManager().getAll();
var controller = FlyoutController();
bool multiSelectMode = false;
Map<History, bool> selectedComics = {};
void selectAll() {
setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
});
}
void deSelect() {
setState(() {
selectedComics.clear();
});
}
void invertSelection() {
setState(() {
comics.asMap().forEach((k, v) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
});
selectedComics.removeWhere((k, v) => !v);
});
}
void _removeHistory(History comic) {
if (comic.sourceKey.startsWith("Unknown")) {
HistoryManager().remove(
comic.id,
ComicType(int.parse(comic.sourceKey.split(':')[1])),
);
} else if (comic.sourceKey == 'local') {
HistoryManager().remove(
comic.id,
ComicType.local,
);
} else {
HistoryManager().remove(
comic.id,
ComicType(comic.sourceKey.hashCode),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text('History'.tl),
actions: [
Tooltip(
message: 'Clear History'.tl,
child: Flyout(
controller: controller,
flyoutBuilder: (context) {
return FlyoutContent(
title: 'Clear History'.tl,
content: Text(
'Are you sure you want to clear your history?'.tl),
actions: [
Button.filled(
color: context.colorScheme.error,
onPressed: () {
HistoryManager().clearHistory();
context.pop();
},
child: Text('Clear'.tl),
),
],
);
},
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
controller.show();
},
),
),
)
],
),
SliverGridComics(
comics: comics,
badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name;
},
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.remove,
text: 'Remove'.tl,
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
),
IconButton(
icon: const Icon(Icons.flip),
tooltip: "Invert Selection".tl,
onPressed: invertSelection
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Delete".tl,
onPressed: selectedComics.isEmpty
? null
: () {
final comicsToDelete = List<History>.from(selectedComics.keys);
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
for (final comic in comicsToDelete) {
_removeHistory(comic);
}
},
),
];
List<Widget> normalActions = [
IconButton(
icon: const Icon(Icons.checklist),
tooltip: multiSelectMode ? "Exit Multi-Select".tl : "Multi-Select".tl,
onPressed: () {
setState(() {
multiSelectMode = !multiSelectMode;
});
},
),
Tooltip(
message: 'Clear History'.tl,
child: Flyout(
controller: controller,
flyoutBuilder: (context) {
return FlyoutContent(
title: 'Clear History'.tl,
content: Text('Are you sure you want to clear your history?'.tl),
actions: [
Button.filled(
color: context.colorScheme.error,
onClick: () {
if (c.sourceKey.startsWith("Unknown")) {
HistoryManager().remove(
c.id,
ComicType(int.parse(c.sourceKey.split(':')[1])),
);
} else if (c.sourceKey == 'local') {
HistoryManager().remove(
c.id,
ComicType.local,
);
onPressed: () {
HistoryManager().clearHistory();
context.pop();
},
child: Text('Clear'.tl),
),
],
);
},
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
controller.show();
},
),
),
)
];
return PopScope(
canPop: !multiSelectMode,
onPopInvokedWithResult: (didPop, result) {
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
}
},
child: Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
leading: Tooltip(
message: multiSelectMode ? "Cancel".tl : "Back".tl,
child: IconButton(
onPressed: () {
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
} else {
HistoryManager().remove(
c.id,
ComicType(c.sourceKey.hashCode),
);
context.pop();
}
},
icon: multiSelectMode
? const Icon(Icons.close)
: const Icon(Icons.arrow_back),
),
];
},
),
],
),
title: multiSelectMode
? Text(selectedComics.length.toString())
: Text('History'.tl),
actions: multiSelectMode ? selectActions : normalActions,
),
SliverGridComics(
comics: comics,
selections: selectedComics,
onLongPressed: null,
onTap: multiSelectMode
? (c) {
setState(() {
if (selectedComics.containsKey(c as History)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
if (selectedComics.isEmpty) {
multiSelectMode = false;
}
});
}
: null,
badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name;
},
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.remove,
text: 'Remove'.tl,
color: context.colorScheme.error,
onClick: () {
_removeHistory(c as History);
},
),
];
},
),
],
),
),
);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -8,10 +9,10 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/follow_updates_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';
@@ -34,8 +35,8 @@ class HomePage extends StatelessWidget {
const _SyncDataWidget(),
const _History(),
const _Local(),
const FollowUpdatesWidget(),
const _ComicSourceWidget(),
const _AccountsWidget(),
const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
],
@@ -196,10 +197,12 @@ class _HistoryState extends State<_History> {
late int count;
void onHistoryChange() {
setState(() {
history = HistoryManager().getRecent();
count = HistoryManager().count();
});
if (mounted) {
setState(() {
history = HistoryManager().getRecent();
count = HistoryManager().count();
});
}
}
@override
@@ -535,38 +538,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
],
),
onPressed: () {
showDialog(
context: context,
barrierColor: Colors.black.toOpacity(0.2),
builder: (context) {
var help = '';
help +=
'${"A directory is considered as a comic only if it matches one of the following conditions:".tl}\n';
help += '${'1. The directory only contains image files.'.tl}\n';
help +=
'${'2. The directory contains directories which contain image files. Each directory is considered as a chapter.'.tl}\n\n';
help +=
'${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n';
help +=
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n"
.tl;
help +=
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database."
.tl;
return ContentDialog(
title: "Help".tl,
content: Text(help).paddingHorizontal(16),
actions: [
Button.filled(
child: Text("OK".tl),
onPressed: () {
context.pop();
},
),
],
);
},
);
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
},
).fixWidth(90).paddingRight(8),
Button.filled(
@@ -633,6 +605,19 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
super.dispose();
}
int get _availableUpdates {
int c = 0;
ComicSource.availableUpdates.forEach((key, version) {
var source = ComicSource.find(key);
if (source != null) {
if (compareSemVer(version, source.version)) {
c++;
}
}
});
return c;
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
@@ -696,7 +681,7 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}).toList(),
).paddingHorizontal(16).paddingBottom(16),
),
if (ComicSource.availableUpdates.isNotEmpty)
if (_availableUpdates > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
@@ -715,7 +700,7 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
Icon(Icons.update, color: context.colorScheme.primary, size: 20,),
const SizedBox(width: 8),
Text("@c updates".tlParams({
'c': ComicSource.availableUpdates.length,
'c': _availableUpdates,
}), style: ts.withColor(context.colorScheme.primary),),
],
),
@@ -728,115 +713,6 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}
}
class _AccountsWidget extends StatefulWidget {
const _AccountsWidget();
@override
State<_AccountsWidget> createState() => _AccountsWidgetState();
}
class _AccountsWidgetState extends State<_AccountsWidget> {
late List<String> accounts;
void onComicSourceChange() {
setState(() {
accounts.clear();
for (var c in ComicSource.all()) {
if (c.isLogged) {
accounts.add(c.name);
}
}
});
}
@override
void initState() {
accounts = [];
for (var c in ComicSource.all()) {
if (c.isLogged) {
accounts.add(c.name);
}
}
ComicSource.addListener(onComicSourceChange);
super.initState();
}
@override
void dispose() {
ComicSource.removeListener(onComicSourceChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
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 AccountsPage());
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 56,
child: Row(
children: [
Center(
child: Text('Accounts'.tl, style: ts.s18),
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(accounts.length.toString(), style: ts.s12),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
),
).paddingHorizontal(16),
SizedBox(
width: double.infinity,
child: Wrap(
runSpacing: 8,
spacing: 8,
children: accounts.map((e) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(e),
);
}).toList(),
).paddingHorizontal(16).paddingBottom(16),
),
],
),
),
),
);
}
}
class _AnimatedDownloadingIcon extends StatefulWidget {
const _AnimatedDownloadingIcon();
@@ -962,6 +838,20 @@ class _ImageFavoritesState extends State<ImageFavorites> {
Center(
child: Text('Image Favorites'.tl, style: ts.s18),
),
if (hasData)
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
imageFavoritesCompute!.count.toString(),
style: ts.s12,
),
),
const Spacer(),
const Icon(Icons.arrow_right),
],

View File

@@ -11,7 +11,7 @@ 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/comic_details_page/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';
@@ -391,7 +391,7 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
Widget build(BuildContext context) {
Widget tabBar = Material(
borderRadius: BorderRadius.circular(8),
child: FilledTabBar(
child: AppTabBar(
key: PageStorageKey(optionTypes),
tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(),
),

View File

@@ -4,7 +4,7 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/cbz.dart';
@@ -12,6 +12,7 @@ import 'package:venera/utils/epub.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart';
import 'package:zip_flutter/zip_flutter.dart';
class LocalComicsPage extends StatefulWidget {
const LocalComicsPage({super.key});
@@ -147,13 +148,13 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
text: "View Detail".tl,
onClick: () {
context.to(() => ComicPage(
id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey,
));
id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey,
));
},
),
if (selectedComics.length == 1)
...exportActions(selectedComics.keys.first),
if (selectedComics.isNotEmpty)
...exportActions(selectedComics.keys.toList()),
]);
}
@@ -322,7 +323,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
});
},
),
...exportActions(c as LocalComic),
...exportActions([c as LocalComic]),
];
},
),
@@ -390,79 +391,102 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return isDeleted;
}
List<MenuEntry> exportActions(LocalComic c) {
List<MenuEntry> exportActions(List<LocalComic> comics) {
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();
}),
icon: Icons.outbox_outlined,
text: "Export as cbz".tl,
onClick: () {
exportComics(comics, CBZ.export, ".cbz");
},
),
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();
}
exportComics(comics, createPdfFromComicIsolate, ".pdf");
},
),
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();
}
exportComics(comics, createEpubWithLocalComic, ".epub");
},
)
];
}
/// Export given comics to a file
void exportComics(
List<LocalComic> comics, ExportComicFunc export, String ext) async {
var current = 0;
var cacheDir = FilePath.join(App.cachePath, 'comics_export');
var outFile = FilePath.join(App.cachePath, 'comics_export.zip');
bool canceled = false;
if (Directory(cacheDir).existsSync()) {
Directory(cacheDir).deleteSync(recursive: true);
}
Directory(cacheDir).createSync();
var loadingController = showLoadingDialog(
context,
allowCancel: true,
message: "${"Exporting".tl} $current/${comics.length}",
withProgress: comics.length > 1,
onCancel: () {
canceled = true;
},
);
try {
var fileName = "";
// For each comic, export it to a file
for (var comic in comics) {
fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext);
await export(comic, fileName);
current++;
if (comics.length > 1) {
loadingController
.setMessage("${"Exporting".tl} $current/${comics.length}");
loadingController.setProgress(current / comics.length);
}
if (canceled) {
return;
}
}
// For single comic, just save the file
if (comics.length == 1) {
await saveFile(
file: File(fileName),
filename: File(fileName).name,
);
Directory(cacheDir).deleteSync(recursive: true);
loadingController.close();
return;
}
// For multiple comics, compress the folder
loadingController.setProgress(null);
loadingController.setMessage("Compressing".tl);
await ZipFile.compressFolderAsync(cacheDir, outFile);
if (canceled) {
File(outFile).deleteIgnoreError();
return;
}
} catch (e, s) {
Log.error("Export Comics", e, s);
context.showMessage(message: e.toString());
loadingController.close();
return;
} finally {
Directory(cacheDir).deleteIgnoreError(recursive: true);
}
await saveFile(
file: File(outFile),
filename: "comics_export.zip",
);
loadingController.close();
File(outFile).deleteIgnoreError();
}
}
typedef ExportComicFunc = Future<File> Function(
LocalComic comic, String outFilePath);

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/pages/categories_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
@@ -7,7 +6,6 @@ import 'package:venera/utils/translations.dart';
import '../components/components.dart';
import '../foundation/app.dart';
import 'comic_source_page.dart';
import 'explore_page.dart';
import 'favorites/favorites_page.dart';
import 'home_page.dart';
@@ -36,24 +34,8 @@ class _MainPageState extends State<MainPage> {
_navigatorKey!.currentContext!.pop();
}
void checkUpdates() async {
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) {
return;
}
appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
}
}
@override
void initState() {
checkUpdates();
_observer = NaviObserver();
_navigatorKey = GlobalKey();
App.mainNavigatorKey = _navigatorKey;

View File

@@ -0,0 +1,242 @@
part of 'reader.dart';
class _ChaptersView extends StatefulWidget {
const _ChaptersView(this.reader);
final _ReaderState reader;
@override
State<_ChaptersView> createState() => _ChaptersViewState();
}
class _ChaptersViewState extends State<_ChaptersView> {
bool desc = false;
late final ScrollController _scrollController;
var downloaded = <String>[];
@override
void initState() {
super.initState();
int epIndex = widget.reader.chapter - 2;
_scrollController = ScrollController(
initialScrollOffset: (epIndex * 48.0 + 52).clamp(0, double.infinity),
);
var local = LocalManager().find(widget.reader.cid, widget.reader.type);
if (local != null) {
downloaded = local.downloadedChapters;
}
}
@override
Widget build(BuildContext context) {
var chapters = widget.reader.widget.chapters!;
var current = widget.reader.chapter - 1;
return Scaffold(
body: SmoothCustomScrollView(
controller: _scrollController,
slivers: [
SliverAppbar(
style: AppbarStyle.shadow,
title: Text("Chapters".tl),
actions: [
Tooltip(
message: "Click to change the order".tl,
child: TextButton.icon(
icon: Icon(
!desc ? Icons.arrow_upward : Icons.arrow_downward,
size: 18,
),
label: Text(!desc ? "Ascending".tl : "Descending".tl),
onPressed: () {
setState(() {
desc = !desc;
});
},
),
),
],
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (desc) {
index = chapters.length - 1 - index;
}
var chapter = chapters.titles.elementAt(index);
return _ChapterListTile(
onTap: () {
widget.reader.toChapter(index + 1);
Navigator.of(context).pop();
},
title: chapter,
isActive: current == index,
isDownloaded:
downloaded.contains(chapters.ids.elementAt(index)),
);
},
childCount: chapters.length,
),
),
],
),
);
}
}
class _GroupedChaptersView extends StatefulWidget {
const _GroupedChaptersView(this.reader);
final _ReaderState reader;
@override
State<_GroupedChaptersView> createState() => _GroupedChaptersViewState();
}
class _GroupedChaptersViewState extends State<_GroupedChaptersView>
with SingleTickerProviderStateMixin {
ComicChapters get chapters => widget.reader.widget.chapters!;
late final TabController tabController;
late final ScrollController _scrollController;
late final String initialGroupName;
var downloaded = <String>[];
@override
void initState() {
super.initState();
int index = 0;
int epIndex = widget.reader.chapter - 1;
while (epIndex >= 0) {
epIndex -= chapters.getGroupByIndex(index).length;
index++;
}
tabController = TabController(
length: chapters.groups.length,
vsync: this,
initialIndex: index - 1,
);
initialGroupName = chapters.groups.elementAt(index - 1);
var epIndexAtGroup = widget.reader.chapter - 1;
for (var i = 0; i < index - 1; i++) {
epIndexAtGroup -= chapters.getGroupByIndex(i).length;
}
_scrollController = ScrollController(
initialScrollOffset: (epIndexAtGroup * 48.0).clamp(0, double.infinity),
);
var local = LocalManager().find(widget.reader.cid, widget.reader.type);
if (local != null) {
downloaded = local.downloadedChapters;
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Appbar(title: Text("Chapters".tl)),
AppTabBar(
controller: tabController,
tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
),
Expanded(
child: TabViewBody(
controller: tabController,
children: chapters.groups.map(buildGroup).toList(),
),
),
],
);
}
Widget buildGroup(String groupName) {
var group = chapters.getGroup(groupName);
return SmoothCustomScrollView(
controller: initialGroupName == groupName ? _scrollController : null,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
var name = group.values.elementAt(index);
var i = 0;
for (var g in chapters.groups) {
if (g == groupName) {
break;
}
i += chapters.getGroup(g).length;
}
i += index + 1;
return _ChapterListTile(
onTap: () {
widget.reader.toChapter(i);
context.pop();
},
title: name,
isActive: widget.reader.chapter == i,
isDownloaded: downloaded.contains(group.keys.elementAt(index)),
);
},
childCount: group.length,
),
),
],
);
}
}
class _ChapterListTile extends StatelessWidget {
const _ChapterListTile({
required this.title,
required this.isActive,
required this.isDownloaded,
required this.onTap,
});
final String title;
final bool isActive;
final bool isDownloaded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color:
isActive ? context.colorScheme.primary : Colors.transparent,
width: 4,
),
),
),
child: Row(
children: [
Text(
title,
style: isActive
? ts.withColor(context.colorScheme.primary).bold.s16
: ts.s16,
),
const Spacer(),
if (isDownloaded)
Icon(
Icons.download_done_rounded,
color: context.colorScheme.secondary,
),
],
),
),
);
}
}

View File

@@ -3,31 +3,30 @@ part of 'reader.dart';
class ComicImage extends StatefulWidget {
/// Modified from flutter Image
ComicImage({
required ImageProvider image,
super.key,
double scale = 1.0,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
}
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
required ImageProvider image,
super.key,
double scale = 1.0,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
}) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
final ImageProvider image;
@@ -138,8 +137,8 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
}
void _updateInvertColors() {
_invertColors = MediaQuery.maybeInvertColorsOf(context)
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
_invertColors = MediaQuery.maybeInvertColorsOf(context) ??
SemanticsBinding.instance.accessibilityFeatures.invertColors;
}
void _resolveImage() {
@@ -148,16 +147,19 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
imageProvider: widget.image,
);
final ImageStream newStream =
provider.resolve(createLocalImageConfiguration(
provider.resolve(createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
size: widget.width != null && widget.height != null
? Size(widget.width!, widget.height!)
: null,
));
_updateSourceStream(newStream);
}
ImageStreamListener? _imageStreamListener;
ImageStreamListener _getListener({bool recreateListener = false}) {
if(_imageStreamListener == null || recreateListener) {
if (_imageStreamListener == null || recreateListener) {
_lastException = null;
_imageStreamListener = ImageStreamListener(
_handleImageFrame,
@@ -191,7 +193,8 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
void _replaceImage({required ImageInfo? info}) {
final ImageInfo? oldImageInfo = _imageInfo;
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
SchedulerBinding.instance
.addPostFrameCallback((_) => oldImageInfo?.dispose());
_imageInfo = info;
}
@@ -208,7 +211,9 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
}
if (!widget.gaplessPlayback) {
setState(() { _replaceImage(info: null); });
setState(() {
_replaceImage(info: null);
});
}
setState(() {
@@ -247,7 +252,9 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
return;
}
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
if (keepStreamAlive &&
_completerHandle == null &&
_imageStream?.completer != null) {
_completerHandle = _imageStream!.completer!.keepAlive();
}
@@ -269,26 +276,41 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
children: [
Expanded(
child: Center(
child: Text(_lastException.toString(), maxLines: 3,),
child: Text(
_lastException.toString(),
maxLines: 3,
),
),
),
const SizedBox(height: 4,),
const SizedBox(
height: 4,
),
MouseRegion(
cursor: SystemMouseCursors.click,
child: Listener(
onPointerDown: (details){
onPointerDown: (details) {
GlobalState.find<_ReaderGestureDetectorState>().ignoreNextTap();
setState(() {
_loadingProgress = null;
_lastException = null;
});
_resolveImage();
},
child: const SizedBox(
child: SizedBox(
width: 84,
height: 36,
child: Center(
child: Text("Retry", style: TextStyle(color: Colors.blue),),
child: Text(
"Retry".tl,
style: TextStyle(color: Colors.blue),
),
),
),
),
),
const SizedBox(height: 16,),
const SizedBox(
height: 16,
),
],
),
),
@@ -300,34 +322,32 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
var width = widget.width;
var height = widget.height;
if(_imageInfo != null) {
if (_imageInfo != null) {
// Record the height and the width of the image
_cache[widget.image.hashCode] = Size(
_imageInfo!.image.width.toDouble(),
_imageInfo!.image.height.toDouble()
);
_cache[widget.image.hashCode] = Size(_imageInfo!.image.width.toDouble(),
_imageInfo!.image.height.toDouble());
}
Size? cacheSize = _cache[widget.image.hashCode];
if(cacheSize != null){
if(width == double.infinity) {
if (cacheSize != null) {
if (width == double.infinity) {
width = constrains.maxWidth;
height = width * cacheSize.height / cacheSize.width;
} else if(height == double.infinity) {
} else if (height == double.infinity) {
height = constrains.maxHeight;
width = height * cacheSize.width / cacheSize.height;
}
} else {
if(width == double.infinity) {
if (width == double.infinity) {
width = constrains.maxWidth;
height = 300;
} else if(height == double.infinity) {
} else if (height == double.infinity) {
height = constrains.maxHeight;
width = 300;
}
}
if(_imageInfo != null){
if (_imageInfo != null) {
// build image
Widget result = RawImage(
// Do not clone the image, because RawImage is a stateless wrapper.
@@ -379,12 +399,13 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
height: 24,
child: CircularProgressIndicator(
strokeWidth: 3,
backgroundColor: context.colorScheme.surfaceContainerLow,
backgroundColor: context.colorScheme.surfaceContainer,
value: (_loadingProgress != null &&
_loadingProgress!.expectedTotalBytes!=null &&
_loadingProgress!.expectedTotalBytes! != 0)
?_loadingProgress!.cumulativeBytesLoaded / _loadingProgress!.expectedTotalBytes!
:0,
_loadingProgress!.expectedTotalBytes != null &&
_loadingProgress!.expectedTotalBytes! != 0)
? _loadingProgress!.cumulativeBytesLoaded /
_loadingProgress!.expectedTotalBytes!
: 0,
),
),
),
@@ -398,8 +419,10 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.debugFillProperties(description);
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<ImageChunkEvent>(
'loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
description.add(DiagnosticsProperty<bool>(
'wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
}
}

View File

@@ -9,7 +9,7 @@ class _ReaderGestureDetector extends StatefulWidget {
State<_ReaderGestureDetector> createState() => _ReaderGestureDetectorState();
}
class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDetector> {
late TapGestureRecognizer _tapGestureRecognizer;
static const _kDoubleTapMaxTime = Duration(milliseconds: 200);
@@ -24,6 +24,14 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
int fingers = 0;
late _ReaderState reader;
bool ignoreNextTag = false;
void ignoreNextTap() {
ignoreNextTag = true;
}
@override
void initState() {
_tapGestureRecognizer = TapGestureRecognizer()
@@ -33,6 +41,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
};
super.initState();
context.readerScaffold._gestureDetectorState = this;
reader = context.reader;
}
@override
@@ -41,6 +50,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
fingers++;
if (ignoreNextTag) {
ignoreNextTag = false;
return;
}
_lastTapPointer = event.pointer;
_lastTapMoveDistance = Offset.zero;
_tapGestureRecognizer.addPointer(event);
@@ -118,11 +131,11 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
}
if (context.reader.mode.key.startsWith('gallery')) {
if (forward) {
if (!context.reader.toNextPage()) {
if (!context.reader.toNextPage() && !context.reader.isLastChapterOfGroup) {
context.reader.toNextChapter();
}
} else {
if (!context.reader.toPrevPage()) {
if (!context.reader.toPrevPage() && !context.reader.isFirstChapterOfGroup) {
context.reader.toPrevChapter();
}
}
@@ -166,7 +179,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
}
void onTap(Offset location) {
if (context.readerScaffold.isOpen) {
if (reader._imageViewController!.handleOnTap(location)) {
return;
} else if (context.readerScaffold.isOpen) {
context.readerScaffold.openOrClose();
} else {
if (appdata.settings['enableTapToTurnPages']) {
@@ -186,31 +201,37 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
isBottom = true;
}
bool isCenter = false;
var prev = context.reader.toPrevPage;
var next = context.reader.toNextPage;
if (appdata.settings['reverseTapToTurnPages']) {
prev = context.reader.toNextPage;
next = context.reader.toPrevPage;
}
switch (context.reader.mode) {
case ReaderMode.galleryLeftToRight:
case ReaderMode.continuousLeftToRight:
if (isLeft) {
context.reader.toPrevPage();
prev();
} else if (isRight) {
context.reader.toNextPage();
next();
} else {
isCenter = true;
}
case ReaderMode.galleryRightToLeft:
case ReaderMode.continuousRightToLeft:
if (isLeft) {
context.reader.toNextPage();
next();
} else if (isRight) {
context.reader.toPrevPage();
prev();
} else {
isCenter = true;
}
case ReaderMode.galleryTopToBottom:
case ReaderMode.continuousTopToBottom:
if (isTop) {
context.reader.toPrevPage();
prev();
} else if (isBottom) {
context.reader.toNextPage();
next();
} else {
isCenter = true;
}
@@ -279,6 +300,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
void removeDragListener(_DragListener listener) {
_dragListeners.remove(listener);
}
@override
Object? get key => "reader_gesture";
}
class _DragListener {

View File

@@ -45,7 +45,7 @@ class _ReaderImagesState extends State<_ReaderImages> {
} else {
var res = await reader.type.comicSource!.loadComicPages!(
reader.widget.cid,
reader.widget.chapters?.keys.elementAt(reader.chapter - 1),
reader.widget.chapters?.ids.elementAt(reader.chapter - 1),
);
if (res.error) {
setState(() {
@@ -105,15 +105,13 @@ class _GalleryModeState extends State<_GalleryMode>
late List<bool> cached;
int get preCacheCount => 4;
int get preCacheCount => appdata.settings["preloadImageCount"];
var photoViewControllers = <int, PhotoViewController>{};
late _ReaderState reader;
int get totalPages => ((reader.images!.length + reader.imagesPerPage - 1) /
reader.imagesPerPage)
.ceil();
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
@override
void initState() {
@@ -156,7 +154,6 @@ class _GalleryModeState extends State<_GalleryMode>
builder: (BuildContext context, int index) {
if (index == 0 || index == totalPages + 1) {
return PhotoViewGalleryPageOptions.customChild(
scaleStateController: PhotoViewScaleStateController(),
child: const SizedBox(),
);
} else {
@@ -170,7 +167,7 @@ class _GalleryModeState extends State<_GalleryMode>
cached[index] = true;
cache(index);
photoViewControllers[index] = PhotoViewController();
photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage == 1) {
return PhotoViewGalleryPageOptions(
@@ -208,11 +205,11 @@ class _GalleryModeState extends State<_GalleryMode>
),
onPageChanged: (i) {
if (i == 0) {
if (!reader.toPrevChapter()) {
if (reader.isFirstChapterOfGroup || !reader.toPrevChapter()) {
reader.toPage(1);
}
} else if (i == totalPages + 1) {
if (!reader.toNextChapter()) {
if (reader.isLastChapterOfGroup || !reader.toNextChapter()) {
reader.toPage(totalPages);
}
} else {
@@ -228,17 +225,23 @@ class _GalleryModeState extends State<_GalleryMode>
? Axis.vertical
: Axis.horizontal;
bool reverse = reader.mode == ReaderMode.galleryRightToLeft;
List<Widget> imageWidgets = images.map((imageKey) {
ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context);
return Expanded(
child: Image(
child: ComicImage(
image: imageProvider,
fit: BoxFit.contain,
),
);
}).toList();
if (reverse) {
imageWidgets = imageWidgets.reversed.toList();
}
return axis == Axis.vertical
? Column(children: imageWidgets)
: Row(children: imageWidgets);
@@ -331,6 +334,11 @@ class _GalleryModeState extends State<_GalleryMode>
}
}
}
@override
bool handleOnTap(Offset location) {
return false;
}
}
const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
@@ -341,6 +349,8 @@ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
PointerDeviceKind.unknown
};
const double _kChangeChapterOffset = 160;
class _ContinuousMode extends StatefulWidget {
const _ContinuousMode({super.key});
@@ -355,28 +365,66 @@ class _ContinuousModeState extends State<_ContinuousMode>
var itemScrollController = ItemScrollController();
var itemPositionsListener = ItemPositionsListener.create();
var photoViewController = PhotoViewController();
late ScrollController scrollController;
ScrollController? _scrollController;
ScrollController get scrollController => _scrollController!;
var isCTRLPressed = false;
static var _isMouseScrolling = false;
var fingers = 0;
bool disableScroll = false;
late List<bool> cached;
int get preCacheCount => appdata.settings["preloadImageCount"];
/// Whether the user was scrolling the page.
/// The gesture detector has a delay to detect tap event.
/// To handle the tap event, we need to know if the user was scrolling before the delay.
bool delayedIsScrolling = false;
void delayedSetIsScrolling(bool value) {
Future.delayed(
const Duration(milliseconds: 300),
() => delayedIsScrolling = value,
);
}
bool prepareToPrevChapter = false;
bool prepareToNextChapter = false;
bool jumpToNextChapter = false;
bool jumpToPrevChapter = false;
@override
void initState() {
reader = context.reader;
reader._imageViewController = this;
itemPositionsListener.itemPositions.addListener(onPositionChanged);
cached = List.filled(reader.maxPage + 2, false);
Future.delayed(
const Duration(milliseconds: 100),
() => cacheImages(reader.page),
);
super.initState();
}
@override
void dispose() {
itemPositionsListener.itemPositions.removeListener(onPositionChanged);
super.dispose();
}
void onPositionChanged() {
if (itemPositionsListener.itemPositions.value.isEmpty) {
return;
}
var page = itemPositionsListener.itemPositions.value.first.index;
page = page.clamp(1, reader.maxPage);
if (page != reader.page) {
reader.setPage(page);
context.readerScaffold.update();
}
cacheImages(page);
}
double? futurePosition;
@@ -416,6 +464,27 @@ class _ContinuousModeState extends State<_ContinuousMode>
}
}
void cacheImages(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= reader.maxPage && !cached[i]) {
_precacheImage(i, context);
cached[i] = true;
}
}
}
void onScroll() {
if (prepareToPrevChapter) {
jumpToNextChapter = false;
jumpToPrevChapter = scrollController.offset <
scrollController.position.minScrollExtent - _kChangeChapterOffset;
} else if (prepareToNextChapter) {
jumpToNextChapter = scrollController.offset >
scrollController.position.maxScrollExtent + _kChangeChapterOffset;
jumpToPrevChapter = false;
}
}
@override
Widget build(BuildContext context) {
Widget widget = ScrollablePositionedList.builder(
@@ -423,7 +492,11 @@ class _ContinuousModeState extends State<_ContinuousMode>
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollControllerCallback: (scrollController) {
this.scrollController = scrollController;
if (_scrollController != null) {
_scrollController!.removeListener(onScroll);
}
_scrollController = scrollController;
_scrollController!.addListener(onScroll);
},
itemCount: reader.maxPage + 2,
addSemanticIndexes: false,
@@ -433,7 +506,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
reverse: reader.mode == ReaderMode.continuousRightToLeft,
physics: isCTRLPressed || _isMouseScrolling || disableScroll
? const NeverScrollableScrollPhysics()
: const ClampingScrollPhysics(),
: const BouncingScrollPhysics(),
itemBuilder: (context, index) {
if (index == 0 || index == reader.maxPage + 1) {
return const SizedBox();
@@ -446,22 +519,30 @@ class _ContinuousModeState extends State<_ContinuousMode>
width = double.infinity;
}
_precacheImage(index, context);
ImageProvider image = _createImageProvider(index, context);
return ComicImage(
filterQuality: FilterQuality.medium,
image: image,
width: width,
height: height,
fit: BoxFit.contain,
return ColoredBox(
color: context.colorScheme.surface,
child: ComicImage(
filterQuality: FilterQuality.medium,
image: image,
width: width,
height: height,
fit: BoxFit.contain,
),
);
},
scrollBehavior: const MaterialScrollBehavior()
.copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes),
);
widget = Stack(
children: [
Positioned.fill(child: buildBackground(context)),
Positioned.fill(child: widget),
],
);
widget = Listener(
onPointerDown: (event) {
fingers++;
@@ -484,6 +565,23 @@ class _ContinuousModeState extends State<_ContinuousMode>
disableScroll = false;
});
}
if (fingers == 0) {
if (jumpToPrevChapter) {
context.readerScaffold.setFloatingButton(0);
reader.toPrevChapter();
} else if (jumpToNextChapter) {
context.readerScaffold.setFloatingButton(0);
reader.toNextChapter();
}
}
},
onPointerCancel: (event) {
fingers--;
if (fingers <= 1 && disableScroll) {
setState(() {
disableScroll = false;
});
}
},
onPointerPanZoomUpdate: (event) {
if (event.scale == 1.0) {
@@ -512,20 +610,49 @@ class _ContinuousModeState extends State<_ContinuousMode>
child: widget,
);
widget = NotificationListener<ScrollUpdateNotification>(
widget = NotificationListener<ScrollNotification>(
onNotification: (notification) {
var length = reader.maxChapter;
if (!scrollController.hasClients) return false;
if (scrollController.position.pixels <=
scrollController.position.minScrollExtent &&
reader.chapter != 1) {
context.readerScaffold.setFloatingButton(-1);
} else if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent &&
reader.chapter < length) {
context.readerScaffold.setFloatingButton(1);
} else {
context.readerScaffold.setFloatingButton(0);
if (notification is ScrollStartNotification) {
delayedSetIsScrolling(true);
} else if (notification is ScrollEndNotification) {
delayedSetIsScrolling(false);
}
if (notification is ScrollUpdateNotification) {
if (!scrollController.hasClients) return false;
if (scrollController.position.pixels <=
scrollController.position.minScrollExtent &&
!reader.isFirstChapterOfGroup) {
if (!prepareToPrevChapter) {
jumpToPrevChapter = false;
jumpToNextChapter = false;
context.readerScaffold.setFloatingButton(-1);
setState(() {
prepareToPrevChapter = true;
});
}
} else if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent &&
!reader.isLastChapterOfGroup) {
if (!prepareToNextChapter) {
jumpToPrevChapter = false;
jumpToNextChapter = false;
context.readerScaffold.setFloatingButton(1);
setState(() {
prepareToNextChapter = true;
});
}
} else {
context.readerScaffold.setFloatingButton(0);
if (prepareToPrevChapter || prepareToNextChapter) {
jumpToPrevChapter = false;
jumpToNextChapter = false;
setState(() {
prepareToPrevChapter = false;
prepareToNextChapter = false;
});
}
}
}
return true;
@@ -557,6 +684,26 @@ class _ContinuousModeState extends State<_ContinuousMode>
);
}
Widget buildBackground(BuildContext context) {
return Column(
children: [
SizedBox(height: context.padding.top + 16),
if (prepareToPrevChapter)
_SwipeChangeChapterProgress(
controller: scrollController,
isPrev: true,
),
const Spacer(),
if (prepareToNextChapter)
_SwipeChangeChapterProgress(
controller: scrollController,
isPrev: false,
),
SizedBox(height: 36),
],
);
}
@override
Future<void> animateToPage(int page) {
return itemScrollController.scrollTo(
@@ -588,7 +735,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override
void handleLongPressDown(Offset location) {
if (!appdata.settings['enableLongPressToZoom']) {
if (!appdata.settings['enableLongPressToZoom'] || delayedIsScrolling) {
return;
}
double target = photoViewController.getInitialScale!.call()! * 1.75;
@@ -663,6 +810,14 @@ class _ContinuousModeState extends State<_ContinuousMode>
);
}
}
@override
bool handleOnTap(Offset location) {
if (delayedIsScrolling) {
return true;
}
return false;
}
}
ImageProvider _createImageProviderFromKey(
@@ -689,3 +844,138 @@ void _precacheImage(int page, BuildContext context) {
context,
);
}
class _SwipeChangeChapterProgress extends StatefulWidget {
const _SwipeChangeChapterProgress({
this.controller,
required this.isPrev,
});
final ScrollController? controller;
final bool isPrev;
@override
State<_SwipeChangeChapterProgress> createState() =>
_SwipeChangeChapterProgressState();
}
class _SwipeChangeChapterProgressState
extends State<_SwipeChangeChapterProgress> {
double value = 0;
late final isPrev = widget.isPrev;
ScrollController? controller;
@override
void initState() {
super.initState();
if (widget.controller != null) {
controller = widget.controller;
controller!.addListener(onScroll);
}
}
@override
void didUpdateWidget(covariant _SwipeChangeChapterProgress oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
controller?.removeListener(onScroll);
controller = widget.controller;
controller?.addListener(onScroll);
if (value != 0) {
setState(() {
value = 0;
});
}
}
}
@override
void dispose() {
super.dispose();
controller?.removeListener(onScroll);
}
void onScroll() {
var position = controller!.position.pixels;
var offset = isPrev
? controller!.position.minScrollExtent - position
: position - controller!.position.maxScrollExtent;
var newValue = offset / _kChangeChapterOffset;
newValue = newValue.clamp(0.0, 1.0);
if (newValue != value) {
setState(() {
value = newValue;
});
}
}
@override
Widget build(BuildContext context) {
final msg = widget.isPrev
? "Swipe down for previous chapter".tl
: "Swipe up for next chapter".tl;
return CustomPaint(
painter: _ProgressPainter(
value: value,
backgroundColor: context.colorScheme.surfaceContainerLow,
color: context.colorScheme.surfaceContainerHighest,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.isPrev ? Icons.arrow_downward : Icons.arrow_upward,
color: context.colorScheme.onSurface,
size: 16,
),
const SizedBox(width: 4),
Text(msg),
],
).paddingVertical(6).paddingHorizontal(16),
);
}
}
class _ProgressPainter extends CustomPainter {
final double value;
final Color backgroundColor;
final Color color;
const _ProgressPainter({
required this.value,
required this.backgroundColor,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromLTRBR(0, 0, size.width, size.height, Radius.circular(16)),
paint,
);
paint.color = color;
canvas.drawRRect(
RRect.fromLTRBR(
0, 0, size.width * value, size.height, Radius.circular(16)),
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! _ProgressPainter ||
oldDelegate.value != value ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.color != color;
}
}

View File

@@ -33,6 +33,7 @@ class _ReaderWithLoadingState
history: data.history,
initialChapter: widget.initialEp ?? data.history.ep,
initialPage: widget.initialPage ?? data.history.page,
initialChapterGroup: data.history.group,
author: data.author,
tags: data.tags,
);
@@ -41,7 +42,7 @@ class _ReaderWithLoadingState
@override
Future<Res<ReaderProps>> loadData() async {
var comicSource = ComicSource.find(widget.sourceKey);
var history = HistoryManager().findSync(
var history = HistoryManager().find(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
@@ -101,7 +102,7 @@ class ReaderProps {
final String name;
final Map<String, String>? chapters;
final ComicChapters? chapters;
final History history;

View File

@@ -22,6 +22,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart';
@@ -39,11 +40,17 @@ import 'package:window_manager/window_manager.dart';
import 'package:battery_plus/battery_plus.dart';
part 'scaffold.dart';
part 'images.dart';
part 'gesture.dart';
part 'comic_image.dart';
part 'loading.dart';
part 'chapters.dart';
extension _ReaderContext on BuildContext {
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
@@ -61,6 +68,7 @@ class Reader extends StatefulWidget {
required this.history,
this.initialPage,
this.initialChapter,
this.initialChapterGroup,
required this.author,
required this.tags,
});
@@ -75,9 +83,7 @@ class Reader extends StatefulWidget {
final String name;
/// key: Chapter ID, value: Chapter Name
/// null if the comic is a gallery
final Map<String, String>? chapters;
final ComicChapters? chapters;
/// Starts from 1, invalid values equal to 1
final int? initialPage;
@@ -85,57 +91,38 @@ class Reader extends StatefulWidget {
/// Starts from 1, invalid values equal to 1
final int? initialChapter;
/// Starts from 1, invalid values equal to 1
final int? initialChapterGroup;
final History history;
@override
State<Reader> createState() => _ReaderState();
}
class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
class _ReaderState extends State<Reader>
with _ReaderLocation, _ReaderWindow, _VolumeListener, _ImagePerPageHandler {
@override
void update() {
setState(() {});
}
@override
int get maxPage =>
((images?.length ?? 1) + imagesPerPage - 1) ~/ imagesPerPage;
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
ComicType get type => widget.type;
String get cid => widget.cid;
String get eid => widget.chapters?.keys.elementAt(chapter - 1) ?? '0';
String get eid => widget.chapters?.ids.elementAt(chapter - 1) ?? '0';
List<String>? images;
@override
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;
}
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait;
History? history;
@@ -144,18 +131,24 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
var focusNode = FocusNode();
VolumeListener? volumeListener;
@override
void initState() {
page = widget.initialPage ?? 1;
chapter = widget.initialChapter ?? 1;
if (page < 1) {
page = 1;
}
chapter = widget.initialChapter ?? 1;
if (chapter < 1) {
chapter = 1;
}
if (widget.initialChapterGroup != null) {
for (int i = 0; i < (widget.initialChapterGroup! - 1); i++) {
chapter += widget.chapters!.getGroupByIndex(i).length;
}
}
if (widget.initialPage != null) {
page = widget.initialPage!;
}
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
history = widget.history;
Future.microtask(() {
@@ -172,6 +165,12 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
initImagesPerPage(widget.initialPage ?? 1);
}
void setImageCacheSize() async {
var availableRAM = await MemoryInfo.getFreePhysicalMemorySize();
if (availableRAM == null) return;
@@ -230,19 +229,132 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
updateHistory();
}
/// Prevent multiple history updates in a short time.
/// `HistoryManager().addHistoryAsync` is a high-cost operation because it creates a new isolate.
Timer? _updateHistoryTimer;
void updateHistory() {
if (history != null) {
history!.page = page;
history!.ep = chapter;
if (maxPage > 1) {
history!.maxPage = maxPage;
if (page == maxPage) {
/// Record the last image of chapter
history!.page = images?.length ?? 1;
} else {
/// Record the first image of the page
history!.page = (page - 1) * imagesPerPage + 1;
}
history!.maxPage = images?.length ?? 1;
if (widget.chapters?.isGrouped ?? false) {
int g = 0;
int c = chapter;
while (c > widget.chapters!.getGroupByIndex(g).length) {
c -= widget.chapters!.getGroupByIndex(g).length;
g++;
}
history!.readEpisode.add('${g + 1}-$c');
history!.ep = c;
history!.group = g + 1;
} else {
history!.readEpisode.add(chapter.toString());
history!.ep = chapter;
}
history!.readEpisode.add(chapter);
history!.time = DateTime.now();
HistoryManager().addHistory(history!);
_updateHistoryTimer?.cancel();
_updateHistoryTimer = Timer(const Duration(seconds: 1), () {
HistoryManager().addHistoryAsync(history!);
_updateHistoryTimer = null;
});
}
}
bool get isFirstChapterOfGroup {
if (widget.chapters?.isGrouped ?? false) {
int c = chapter - 1;
int g = 1;
while (c > 0) {
c -= widget.chapters!.getGroupByIndex(g - 1).length;
g++;
}
if (c == 0) {
return true;
} else {
return false;
}
}
return chapter == 1;
}
bool get isLastChapterOfGroup {
if (widget.chapters?.isGrouped ?? false) {
int c = chapter;
int g = 1;
while (c > 0) {
c -= widget.chapters!.getGroupByIndex(g - 1).length;
g++;
}
if (c == 0) {
return true;
} else {
return false;
}
}
return chapter == maxChapter;
}
}
abstract mixin class _ImagePerPageHandler {
late int _lastImagesPerPage;
bool get isPortrait;
int get page;
set page(int value);
ReaderMode get mode;
void initImagesPerPage(int initialPage) {
_lastImagesPerPage = imagesPerPage;
if (imagesPerPage != 1) {
page = (initialPage / imagesPerPage).ceil();
}
}
/// The number of images displayed on one screen
int get imagesPerPage {
if (mode.isContinuous) return 1;
if (isPortrait) {
return appdata.settings['readerScreenPicNumberForPortrait'] ?? 1;
} else {
return appdata.settings['readerScreenPicNumberForLandscape'] ?? 1;
}
}
/// Check if the number of images per page has changed
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
}
}
/// Adjust the page number when the number of images per page changes
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
}
}
abstract mixin class _VolumeListener {
bool toNextPage();
bool toPrevPage();
VolumeListener? volumeListener;
void handleVolumeEvent() {
if (!App.isAndroid) {
// Currently only support Android
@@ -252,12 +364,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
volumeListener?.cancel();
}
volumeListener = VolumeListener(
onDown: () {
toNextPage();
},
onUp: () {
toPrevPage();
},
onDown: toNextPage,
onUp: toPrevPage,
)..listen();
}
@@ -431,4 +539,7 @@ abstract interface class _ImageViewController {
void handleLongPressUp(Offset location);
void handleKeyEvent(KeyEvent event);
/// Returns true if the event is handled.
bool handleOnTap(Offset location);
}

View File

@@ -26,73 +26,21 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
var lastValue = 0;
var fABValue = ValueNotifier<double>(0);
_ReaderGestureDetectorState? _gestureDetectorState;
_DragListener? _floatingButtonDragListener;
void setFloatingButton(int value) {
lastValue = showFloatingButtonValue;
if (value == 0) {
if (showFloatingButtonValue != 0) {
showFloatingButtonValue = 0;
fABValue.value = 0;
update();
}
if (_floatingButtonDragListener != null) {
_gestureDetectorState!.removeDragListener(_floatingButtonDragListener!);
_floatingButtonDragListener = null;
}
}
var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1;
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy;
} else if (readerMode == ReaderMode.continuousLeftToRight) {
fABValue.value -= offset.dx;
} else if (readerMode == ReaderMode.continuousRightToLeft) {
fABValue.value += offset.dx;
}
},
onEnd: () {
if (fABValue.value.abs() > 58 * 3) {
setState(() {
showFloatingButtonValue = 0;
});
context.reader.toNextChapter();
}
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update();
} else if (value == -1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = -1;
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value += offset.dy;
} else if (readerMode == ReaderMode.continuousLeftToRight) {
fABValue.value += offset.dx;
} else if (readerMode == ReaderMode.continuousRightToLeft) {
fABValue.value -= offset.dx;
}
},
onEnd: () {
if (fABValue.value.abs() > 58 * 3) {
setState(() {
showFloatingButtonValue = 0;
});
context.reader.toPrevChapter();
}
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update();
}
}
@@ -279,7 +227,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
List<String> tags = context.reader.widget.tags;
String author = context.reader.widget.author;
var epName = context.reader.widget.chapters?.values
var epName = context.reader.widget.chapters?.titles
.elementAtOrNull(context.reader.chapter - 1) ??
"E${context.reader.chapter}";
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
@@ -561,7 +509,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
}
Widget buildPageInfoText() {
var epName = context.reader.widget.chapters?.values
var epName = context.reader.widget.chapters?.titles
.elementAtOrNull(context.reader.chapter - 1) ??
"E${context.reader.chapter}";
if (epName.length > 8) {
@@ -614,7 +562,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
void openChapterDrawer() {
showSideBar(
context,
_ChaptersView(context.reader),
context.reader.widget.chapters!.isGrouped
? _GroupedChaptersView(context.reader)
: _ChaptersView(context.reader),
width: 400,
);
}
@@ -660,12 +610,16 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
App.rootContext.pop();
},
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
width: double.infinity,
height: double.infinity,
child: Image(
@@ -772,62 +726,35 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
case -1:
case 1:
return Container(
return SizedBox(
width: 58,
height: 58,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
child: Material(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: ValueListenableBuilder(
valueListenable: fABValue,
builder: (context, value, child) {
return Stack(
children: [
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
if (showFloatingButtonValue == 1) {
context.reader.toNextChapter();
} else if (showFloatingButtonValue == -1) {
context.reader.toPrevChapter();
}
setFloatingButton(0);
},
borderRadius: BorderRadius.circular(16),
child: Center(
child: Icon(
showFloatingButtonValue == 1
? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined,
size: 24,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
height: value.clamp(0, 58 * 3) / 3,
child: ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceTint
.toOpacity(0.2),
child: const SizedBox.expand(),
),
),
],
);
},
elevation: 2,
child: InkWell(
onTap: () {
if (showFloatingButtonValue == 1) {
context.reader.toNextChapter();
} else if (showFloatingButtonValue == -1) {
context.reader.toPrevChapter();
}
setFloatingButton(0);
},
borderRadius: BorderRadius.circular(16),
child: Center(
child: Icon(
showFloatingButtonValue == 1
? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined,
size: 24,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
),
);
}
@@ -845,6 +772,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
late int _batteryLevel = 100;
Timer? _timer;
bool _hasBattery = false;
BatteryState state = BatteryState.unknown;
@override
void initState() {
@@ -856,29 +784,23 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
void _checkBatteryAvailability() async {
try {
_batteryLevel = await _battery.batteryLevel;
if (_batteryLevel != -1) {
state = await _battery.batteryState;
if (_batteryLevel > 0 && state != BatteryState.unknown) {
setState(() {
_hasBattery = true;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_battery.batteryLevel.then((level) => {
if (_batteryLevel != level)
{
setState(() {
_batteryLevel = level;
})
}
});
});
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_battery.batteryLevel.then((level) {
if (_batteryLevel != level) {
setState(() {
_batteryLevel = level;
});
}
});
});
} else {
setState(() {
_hasBattery = false;
});
}
} catch (e) {
setState(() {
_hasBattery = false;
});
} catch (_) {
// ignore
}
}
@@ -900,7 +822,9 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
IconData batteryIcon;
Color batteryColor = context.colorScheme.onSurface;
if (batteryLevel >= 96) {
if (state == BatteryState.charging) {
batteryIcon = Icons.battery_charging_full;
} else if (batteryLevel >= 96) {
batteryIcon = Icons.battery_full_sharp;
} else if (batteryLevel >= 84) {
batteryIcon = Icons.battery_6_bar_sharp;
@@ -1016,79 +940,3 @@ class _ClockWidgetState extends State<_ClockWidget> {
);
}
}
class _ChaptersView extends StatefulWidget {
const _ChaptersView(this.reader);
final _ReaderState reader;
@override
State<_ChaptersView> createState() => _ChaptersViewState();
}
class _ChaptersViewState extends State<_ChaptersView> {
bool desc = false;
@override
Widget build(BuildContext context) {
var chapters = widget.reader.widget.chapters!;
var current = widget.reader.chapter - 1;
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text("Chapters".tl),
actions: [
Tooltip(
message: "Click to change the order".tl,
child: TextButton.icon(
icon: Icon(
!desc ? Icons.arrow_upward : Icons.arrow_downward,
size: 18,
),
label: Text(!desc ? "Ascending".tl : "Descending".tl),
onPressed: () {
setState(() {
desc = !desc;
});
},
),
),
],
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (desc) {
index = chapters.length - 1 - index;
}
var chapter = chapters.values.elementAt(index);
return ListTile(
shape: Border(
left: BorderSide(
color: current == index
? context.colorScheme.primary
: Colors.transparent,
width: 4,
),
),
title: Text(
chapter,
style: current == index
? ts.withColor(context.colorScheme.primary).bold
: null,
),
onTap: () {
widget.reader.toChapter(index + 1);
Navigator.of(context).pop();
},
);
},
childCount: chapters.length,
),
),
],
),
);
}
}

View File

@@ -7,15 +7,17 @@ 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/state_controller.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/pages/aggregated_search_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'comic_page.dart';
import 'comic_details_page/comic_page.dart';
import 'comic_source_page.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@@ -27,8 +29,13 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> {
late final SearchBarController controller;
late List<String> searchSources;
String searchTarget = "";
SearchPageData get currentSearchPageData =>
ComicSource.find(searchTarget)!.searchPageData!;
bool aggregatedSearch = false;
var focusNode = FocusNode();
@@ -139,29 +146,85 @@ class _SearchPageState extends State<SearchPage> {
@override
void initState() {
findSearchSources();
var defaultSearchTarget = appdata.settings['defaultSearchTarget'];
if (defaultSearchTarget == "_aggregated_") {
aggregatedSearch = true;
} else if (defaultSearchTarget != null &&
ComicSource.find(defaultSearchTarget) != null) {
searchSources.contains(defaultSearchTarget)) {
searchTarget = defaultSearchTarget;
} else {
searchTarget = ComicSource.all().first.key;
}
controller = SearchBarController(
onSearch: search,
);
appdata.settings.addListener(updateSearchSourcesIfNeeded);
super.initState();
}
@override
void dispose() {
focusNode.dispose();
appdata.settings.removeListener(updateSearchSourcesIfNeeded);
super.dispose();
}
void findSearchSources() {
var all = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
var settings = appdata.settings['searchSources'] as List;
var sources = <String>[];
for (var source in settings) {
if (all.contains(source)) {
sources.add(source);
}
}
searchSources = sources;
if (!searchSources.contains(searchTarget)) {
searchTarget = searchSources.firstOrNull ?? "";
}
}
void updateSearchSourcesIfNeeded() {
var old = searchSources;
findSearchSources();
if (old.isEqualTo(searchSources)) {
return;
}
setState(() {});
}
void manageSearchSources() {
showPopUpWidget(App.rootContext, setSearchSourcesWidget());
}
Widget buildEmpty() {
var msg = "No Search Sources".tl;
msg += '\n';
VoidCallback onTap;
if (ComicSource.isEmpty) {
msg += "Please add some sources".tl;
onTap = () {
context.to(() => ComicSourcePage());
};
} else {
msg += "Please check your settings".tl;
onTap = manageSearchSources;
}
return NetworkError(
message: msg,
retry: onTap,
withAppbar: true,
buttonText: "Manage".tl,
);
}
@override
Widget build(BuildContext context) {
if (searchSources.isEmpty) {
return buildEmpty();
}
return Scaffold(
body: SmoothCustomScrollView(
slivers: buildSlivers().toList(),
@@ -190,8 +253,7 @@ class _SearchPageState extends State<SearchPage> {
}
Widget buildSearchTarget() {
var sources =
ComicSource.all().where((e) => e.searchPageData != null).toList();
var sources = searchSources.map((e) => ComicSource.find(e)!).toList();
return SliverToBoxAdapter(
child: Container(
width: double.infinity,
@@ -203,6 +265,10 @@ class _SearchPageState extends State<SearchPage> {
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.search),
title: Text("Search in".tl),
trailing: IconButton(
icon: const Icon(Icons.settings),
onPressed: manageSearchSources,
),
),
Wrap(
spacing: 8,
@@ -229,11 +295,6 @@ class _SearchPageState extends State<SearchPage> {
onChanged: (value) {
setState(() {
aggregatedSearch = value ?? false;
if (!aggregatedSearch &&
appdata.settings['defaultSearchTarget'] ==
"_aggregated_") {
searchTarget = sources.first.key;
}
});
},
),
@@ -245,9 +306,7 @@ class _SearchPageState extends State<SearchPage> {
}
void useDefaultOptions() {
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
final searchOptions = currentSearchPageData.searchOptions ?? [];
options = searchOptions.map((e) => e.defaultValue).toList();
}
@@ -258,9 +317,7 @@ class _SearchPageState extends State<SearchPage> {
var children = <Widget>[];
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
final searchOptions = currentSearchPageData.searchOptions ?? [];
if (searchOptions.length != options.length) {
useDefaultOptions();
}
@@ -394,7 +451,9 @@ class _SearchPageState extends State<SearchPage> {
Text(
subTitle,
style: TextStyle(
fontSize: 14, color: Theme.of(context).colorScheme.outline),
fontSize: 14,
color: Theme.of(context).colorScheme.outline,
),
)
],
),

View File

@@ -3,7 +3,7 @@ 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/state_controller.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';
@@ -116,13 +116,13 @@ class _SearchResultPageState extends State<SearchResultPage> {
@override
void initState() {
sourceKey = widget.sourceKey;
text = checkAutoLanguage(widget.text);
controller = SearchBarController(
currentText: checkAutoLanguage(widget.text),
currentText: text,
onSearch: search,
);
options = widget.options ?? const [];
validateOptions();
text = widget.text;
appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller);
super.initState();
@@ -187,7 +187,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
suggestionsController.remove();
}
var previousOptions = options;
var previousOptions = List<String>.from(options);
var previousSourceKey = sourceKey;
await showDialog(
context: context,
@@ -196,7 +196,8 @@ class _SearchResultPageState extends State<SearchResultPage> {
return _SearchSettingsDialog(state: this);
},
);
if (previousOptions != options || previousSourceKey != sourceKey) {
if (!previousOptions.isEqualTo(options) ||
previousSourceKey != sourceKey) {
text = checkAutoLanguage(controller.text);
controller.currentText = text;
setState(() {});

View File

@@ -85,8 +85,8 @@ class _AboutSettingsState extends State<AboutSettings> {
}
Future<bool> checkUpdate() async {
var res = await AppDio().get(
"https://raw.githubusercontent.com/venera-app/venera/refs/heads/master/pubspec.yaml");
var res = await AppDio()
.get("https://cdn.jsdelivr.net/gh/venera-app/venera@master/pubspec.yaml");
if (res.statusCode == 200) {
var data = loadYaml(res.data);
if (data["version"] != null) {
@@ -108,7 +108,7 @@ Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
content: Text(
"A new version is available. Do you want to update now?"
.tl)
.paddingHorizontal(8),
.paddingHorizontal(16),
actions: [
Button.text(
onPressed: () {
@@ -137,6 +137,9 @@ bool _compareVersion(String version1, String version2) {
if (int.parse(v1[i]) > int.parse(v2[i])) {
return true;
}
if (int.parse(v1[i]) < int.parse(v2[i])) {
return false;
}
}
return false;
}

View File

@@ -107,7 +107,7 @@ class _AppSettingsState extends State<AppSettings> {
actionTitle: 'Export'.tl,
).toSliver(),
_CallbackSetting(
title: "Import App Data (Please restart after success)".tl,
title: "Import App Data".tl,
callback: () async {
var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera', 'picadata']);
@@ -126,6 +126,7 @@ class _AppSettingsState extends State<AppSettings> {
context.showMessage(message: "Failed to import data".tl);
} finally {
cacheFile.deleteIgnoreError();
App.forceRebuild();
}
}
controller.close();
@@ -329,10 +330,11 @@ class _WebdavSettingState extends State<_WebdavSetting> {
String url = "";
String user = "";
String pass = "";
bool autoSync = false;
bool isTesting = false;
bool upload = true;
bool isEnabled = false;
@override
void initState() {
@@ -347,6 +349,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
url = configs[0];
user = configs[1];
pass = configs[2];
isEnabled = true;
autoSync = appdata.implicitData['webdavAutoSync'] ?? false;
}
void onAutoSyncChanged(bool value) {
setState(() {
autoSync = value;
appdata.implicitData['webdavAutoSync'] = value;
appdata.writeImplicitData();
});
}
@override
@@ -356,6 +368,12 @@ class _WebdavSettingState extends State<_WebdavSetting> {
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 12),
SwitchListTile(
title: Text("WebDAV Auto Sync".tl),
value: autoSync,
onChanged: onAutoSyncChanged,
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
@@ -410,12 +428,53 @@ class _WebdavSettingState extends State<_WebdavSetting> {
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text("Once the operation is successful, app will automatically sync data with the server.".tl),
),
],
),
),
const SizedBox(height: 16),
Center(
child: Button.filled(
isLoading: isTesting,
onPressed: () async {
var oldConfig = appdata.settings['webdav'];
var oldAutoSync = appdata.implicitData['webdavAutoSync'];
if (url.trim().isEmpty &&
user.trim().isEmpty &&
pass.trim().isEmpty) {
appdata.settings['webdav'] = [];
appdata.implicitData['webdavAutoSync'] = false;
appdata.writeImplicitData();
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
return;
}
appdata.settings['webdav'] = [url, user, pass];
appdata.implicitData['webdavAutoSync'] = autoSync;
appdata.writeImplicitData();
if (!autoSync) {
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
return;
}
setState(() {
isTesting = true;
});
@@ -427,12 +486,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
isTesting = false;
});
appdata.settings['webdav'] = oldConfig;
appdata.implicitData['webdavAutoSync'] = oldAutoSync;
appdata.writeImplicitData();
appdata.saveData();
context.showMessage(message: testResult.errorMessage!);
return;
context.showMessage(message: "Saved Failed".tl);
} else {
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
}
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
},
child: Text("Continue".tl),
),

View File

@@ -30,51 +30,19 @@ class _ExploreSettingsState extends State<ExploreSettings> {
).toSliver(),
_PopupWindowSetting(
title: "Explore Pages".tl,
builder: () {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
for (var page in c.explorePages) {
pages[page.title] = page.title;
}
}
return _MultiPagesFilter(
title: "Explore Pages".tl,
settingsIndex: "explore_pages",
pages: pages,
);
},
builder: setExplorePagesWidget,
).toSliver(),
_PopupWindowSetting(
title: "Category Pages".tl,
builder: () {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.categoryData != null) {
pages[c.categoryData!.key] = c.categoryData!.title;
}
}
return _MultiPagesFilter(
title: "Category Pages".tl,
settingsIndex: "categories",
pages: pages,
);
},
builder: setCategoryPagesWidget,
).toSliver(),
_PopupWindowSetting(
title: "Network Favorite Pages".tl,
builder: () {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.favoriteData != null) {
pages[c.favoriteData!.key] = c.favoriteData!.title;
}
}
return _MultiPagesFilter(
title: "Network Favorite Pages".tl,
settingsIndex: "favorites",
pages: pages,
);
},
builder: setFavoritesPagesWidget,
).toSliver(),
_PopupWindowSetting(
title: "Search Sources".tl,
builder: setSearchSourcesWidget,
).toSliver(),
_SwitchSetting(
title: "Show favorite status on comic tile".tl,
@@ -132,8 +100,9 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
return PopUpWidgetScaffold(
title: "Keyword blocking".tl,
tailing: [
IconButton(
TextButton.icon(
icon: const Icon(Icons.add),
label: Text("Add".tl),
onPressed: add,
),
],
@@ -159,7 +128,6 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
void add() {
showDialog(
context: App.rootContext,
barrierColor: Colors.black.toOpacity(0.1),
builder: (context) {
var controller = TextEditingController();
String? error;
@@ -205,3 +173,59 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
);
}
}
Widget setExplorePagesWidget() {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
for (var page in c.explorePages) {
pages[page.title] = page.title.ts(c.key);
}
}
return _MultiPagesFilter(
title: "Explore Pages".tl,
settingsIndex: "explore_pages",
pages: pages,
);
}
Widget setCategoryPagesWidget() {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.categoryData != null) {
pages[c.categoryData!.key] = c.categoryData!.title;
}
}
return _MultiPagesFilter(
title: "Category Pages".tl,
settingsIndex: "categories",
pages: pages,
);
}
Widget setFavoritesPagesWidget() {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.favoriteData != null) {
pages[c.favoriteData!.key] = c.favoriteData!.title;
}
}
return _MultiPagesFilter(
title: "Network Favorite Pages".tl,
settingsIndex: "favorites",
pages: pages,
);
}
Widget setSearchSourcesWidget() {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.searchPageData != null) {
pages[c.key] = c.name;
}
}
return _MultiPagesFilter(
title: "Search Sources".tl,
settingsIndex: "searchSources",
pages: pages,
);
}

View File

@@ -22,6 +22,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call("enableTapToTurnPages");
},
).toSliver(),
_SwitchSetting(
title: "Reverse tap to turn Pages".tl,
settingKey: "reverseTapToTurnPages",
onChanged: () {
widget.onChanged?.call("reverseTapToTurnPages");
},
).toSliver(),
_SwitchSetting(
title: "Page animation".tl,
settingKey: "enablePageAnimation",
@@ -43,8 +50,10 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
var readerMode = appdata.settings['readerMode'];
if (readerMode?.toLowerCase().startsWith('continuous') ?? false) {
appdata.settings['readerScreenPicNumber'] = 1;
widget.onChanged?.call('readerScreenPicNumber');
appdata.settings['readerScreenPicNumberForLandscape'] = 1;
widget.onChanged?.call('readerScreenPicNumberForLandscape');
appdata.settings['readerScreenPicNumberForPortrait'] = 1;
widget.onChanged?.call('readerScreenPicNumberForPortrait');
}
widget.onChanged?.call("readerMode");
},
@@ -74,13 +83,40 @@ class _ReaderSettingsState extends State<ReaderSettings> {
: 1.0,
duration: Duration(milliseconds: 300),
child: _SliderSetting(
title: "The number of pic in screen (Only Gallery Mode)".tl,
settingsIndex: "readerScreenPicNumber",
title: "The number of pic in screen for landscape (Only Gallery Mode)".tl,
settingsIndex: "readerScreenPicNumberForLandscape",
interval: 1,
min: 1,
max: 5,
onChanged: () {
widget.onChanged?.call("readerScreenPicNumber");
widget.onChanged?.call("readerScreenPicNumberForLandscape");
},
),
),
),
),
SliverToBoxAdapter(
child: AbsorbPointer(
absorbing: (appdata.settings['readerMode']
?.toLowerCase()
.startsWith('continuous') ??
false),
child: AnimatedOpacity(
opacity: (appdata.settings['readerMode']
?.toLowerCase()
.startsWith('continuous') ??
false)
? 0.5
: 1.0,
duration: Duration(milliseconds: 300),
child: _SliderSetting(
title: "The number of pic in screen for portrait (Only Gallery Mode)".tl,
settingsIndex: "readerScreenPicNumberForPortrait",
interval: 1,
min: 1,
max: 5,
onChanged: () {
widget.onChanged?.call("readerScreenPicNumberForPortrait");
},
),
),
@@ -136,6 +172,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
callback: () => context.to(() => _CustomImageProcessing()),
actionTitle: "Edit".tl,
).toSliver(),
_SliderSetting(
title: "Number of images preloaded".tl,
settingsIndex: "preloadImageCount",
interval: 1,
min: 1,
max: 16,
).toSliver(),
],
);
}

View File

@@ -376,6 +376,14 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
super.initState();
}
@override
void dispose() {
super.dispose();
Future.microtask(() {
updateSetting();
});
}
var reorderWidgetKey = UniqueKey();
var scrollController = ScrollController();
final _key = GlobalKey();
@@ -404,7 +412,6 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
setState(() {
keys = List.from(reorderFunc(keys));
});
updateSetting();
},
children: tiles,
builder: (children) {
@@ -424,7 +431,11 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
title: widget.title,
tailing: [
if (keys.length < widget.pages.length)
IconButton(onPressed: showAddDialog, icon: const Icon(Icons.add))
TextButton.icon(
label: Text("Add".tl),
icon: const Icon(Icons.add),
onPressed: showAddDialog,
)
],
body: view,
);
@@ -438,9 +449,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
setState(() {
keys.remove(key);
});
updateSetting();
},
icon: const Icon(Icons.delete)),
icon: const Icon(Icons.delete_outline)),
);
return ListTile(
@@ -463,30 +473,68 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
canAdd[key] = value;
}
});
var selected = <String>[];
showDialog(
context: context,
builder: (context) {
return ContentDialog(
title: "Add".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: canAdd.entries
.map(
(e) => ListTile(
title: Text(e.value),
key: Key(e.key),
onTap: () {
context.pop();
setState(() {
keys.add(e.key);
});
updateSetting();
},
),
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Add".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: canAdd.entries
.map(
(e) => CheckboxListTile(
value: selected.contains(e.key),
title: Text(e.value),
key: Key(e.key),
onChanged: (value) {
setState(() {
if (value!) {
selected.add(e.key);
} else {
selected.remove(e.key);
}
});
},
),
)
.toList(),
),
actions: [
if (selected.length < canAdd.length)
TextButton(
child: Text("Select All".tl),
onPressed: () {
setState(() {
selected = canAdd.keys.toList();
});
},
)
.toList(),
),
);
else
TextButton(
child: Text("Deselect All".tl),
onPressed: () {
setState(() {
selected.clear();
});
},
),
const SizedBox(width: 8),
FilledButton(
onPressed: selected.isNotEmpty
? () {
this.setState(() {
keys.addAll(selected);
});
Navigator.pop(context);
}
: null,
child: Text("Add".tl),
),
],
);
});
},
);
}

View File

@@ -170,44 +170,78 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
),
),
),
Expanded(child: buildRight())
],
);
} else {
return Stack(
children: [
Positioned.fill(child: buildLeft()),
Positioned(
left: offset,
right: 0,
top: 0,
bottom: 0,
child: Listener(
onPointerDown: handlePointerDown,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
reverseDuration: const Duration(milliseconds: 300),
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
transitionBuilder: (child, animation) {
var tween = Tween<Offset>(
begin: const Offset(1, 0), end: const Offset(0, 0));
return SlideTransition(
position: tween.animate(animation),
child: child,
);
},
child: currentPage == -1
? const SizedBox(
key: Key("1"),
)
: buildRight(),
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return LayoutBuilder(
builder: (context, constrains) {
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
var width = constrains.maxWidth;
var value = animation.isForwardOrCompleted
? 1 - animation.value
: 1;
var left = width * value;
return Stack(
children: [
Positioned(
top: 0,
bottom: 0,
left: left,
width: width,
child: child,
),
],
);
},
);
},
);
},
child: buildRight(),
),
)
],
);
} else {
return LayoutBuilder(
builder: (context, constrains) {
return Stack(
children: [
Positioned.fill(child: buildLeft()),
Positioned(
left: offset,
width: constrains.maxWidth,
top: 0,
bottom: 0,
child: Listener(
onPointerDown: handlePointerDown,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
transitionBuilder: (child, animation) {
var tween = Tween<Offset>(
begin: const Offset(1, 0), end: const Offset(0, 0));
return SlideTransition(
position: tween.animate(animation),
child: child,
);
},
child: Material(
key: ValueKey(currentPage),
child: buildRight(),
),
),
),
)
],
);
},
);
}
}
@@ -307,7 +341,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
}
Widget buildRight() {
final Widget body = switch (currentPage) {
return switch (currentPage) {
-1 => const SizedBox(),
0 => const ExploreSettings(),
1 => const ReaderSettings(),
@@ -318,10 +352,6 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
6 => const AboutSettings(),
_ => throw UnimplementedError()
};
return Material(
child: body,
);
}
var canPop = ValueNotifier(true);

View File

@@ -25,8 +25,13 @@ extension WebviewExtension on InAppWebViewController {
if (url[url.length - 1] == '/') {
url = url.substring(0, url.length - 1);
}
CookieManager cookieManager = CookieManager.instance();
final cookies = await cookieManager.getCookies(url: WebUri(url));
CookieManager cookieManager = CookieManager.instance(
webViewEnvironment: AppWebview.webViewEnvironment,
);
final cookies = await cookieManager.getCookies(
url: WebUri(url),
webViewController: this,
);
var res = <io.Cookie>[];
for (var cookie in cookies) {
var c = io.Cookie(cookie.name, cookie.value);
@@ -86,11 +91,12 @@ class _AppWebviewState extends State<AppWebview> {
late var future = _createWebviewEnvironment();
Future<WebViewEnvironment> _createWebviewEnvironment() async {
Future<bool> _createWebviewEnvironment() async {
var proxy = appdata.settings['proxy'].toString();
if (proxy != "system" && proxy != "direct") {
var proxyAvailable = await WebViewFeature.isFeatureSupported(
WebViewFeature.PROXY_OVERRIDE);
WebViewFeature.PROXY_OVERRIDE,
);
if (proxyAvailable) {
ProxyController proxyController = ProxyController.instance();
await proxyController.clearProxyOverride();
@@ -104,11 +110,15 @@ class _AppWebviewState extends State<AppWebview> {
);
}
}
return WebViewEnvironment.create(
if (!App.isWindows) {
return true;
}
AppWebview.webViewEnvironment = await WebViewEnvironment.create(
settings: WebViewEnvironmentSettings(
userDataFolder: "${App.dataPath}\\webview",
),
);
return true;
}
@override
@@ -147,22 +157,20 @@ class _AppWebviewState extends State<AppWebview> {
)
];
Widget body = (App.isWindows && AppWebview.webViewEnvironment == null)
? FutureBuilder(
future: future,
builder: (context, e) {
if (e.error != null) {
return Center(child: Text("Error: ${e.error}"));
}
if (e.data == null) {
return const Center(child: CircularProgressIndicator());
}
AppWebview.webViewEnvironment = e.data;
return createWebviewWithEnvironment(
AppWebview.webViewEnvironment);
},
)
: createWebviewWithEnvironment(AppWebview.webViewEnvironment);
Widget body = FutureBuilder(
future: future,
builder: (context, e) {
if (e.error != null) {
return Center(child: Text("Error: ${e.error}"));
}
if (!e.hasData) {
return const SizedBox();
}
return createWebviewWithEnvironment(
AppWebview.webViewEnvironment,
);
},
);
body = Stack(
children: [
@@ -303,7 +311,10 @@ class DesktopWebview {
proxy: AppDio.proxy,
));
_webview!.addOnWebMessageReceivedCallback(onMessage);
_webview!.setOnNavigation((s) => onNavigation?.call(s, this));
_webview!.setOnNavigation((s) {
s = s.substring(1, s.length - 1);
return onNavigation?.call(s, this);
});
_webview!.launch(initialUrl, triggerOnUrlRequestEvent: false);
_runTimer();
_webview!.onClose.then((value) {

View File

@@ -1,7 +1,7 @@
import 'package:app_links/app_links.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
void handleLinks() {
final appLinks = AppLinks();

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter_7zip/flutter_7zip.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/utils/ext.dart';
@@ -85,6 +86,10 @@ abstract class CBZ {
if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync();
await extractArchive(file, cache);
var f = cache.listSync();
if (f.length == 1 && f.first is Directory) {
cache = f.first as Directory;
}
var metaDataFile = File(FilePath.join(cache.path, 'metadata.json'));
ComicMetaData? metaData;
if (metaDataFile.existsSync()) {
@@ -111,7 +116,17 @@ abstract class CBZ {
cache.deleteSync(recursive: true);
throw Exception('No images found in the archive');
}
files.sort((a, b) => a.path.compareTo(b.path));
files.sort((a, b) {
var aName = a.basenameWithoutExt;
var bName = b.basenameWithoutExt;
var aIndex = int.tryParse(aName);
var bIndex = int.tryParse(bName);
if (aIndex != null && bIndex != null) {
return aIndex.compareTo(bIndex);
} else {
return a.path.compareTo(b.path);
}
});
var coverFile = files.firstWhereOrNull(
(element) =>
element.path.endsWith('cover.${element.path.split('.').last}'),
@@ -162,7 +177,7 @@ abstract class CBZ {
tags: metaData.tags,
comicType: ComicType.local,
directory: dest.name,
chapters: cpMap,
chapters: ComicChapters.fromJson(cpMap),
downloadedChapters: cpMap?.keys.toList() ?? [],
cover: 'cover.${coverFile.extension}',
createdAt: DateTime.now(),
@@ -171,7 +186,7 @@ abstract class CBZ {
return comic;
}
static Future<File> export(LocalComic comic) async {
static Future<File> export(LocalComic comic, String outFilePath) async {
var cache = Directory(FilePath.join(App.cachePath, 'cbz_export'));
if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync();
@@ -230,7 +245,7 @@ abstract class CBZ {
).toJson(),
),
);
var cbz = File(FilePath.join(App.cachePath, sanitizeFileName('${comic.title}.cbz')));
var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync();
await _compress(cache.path, cbz.path);
cache.deleteSync(recursive: true);

View File

@@ -32,37 +32,45 @@ class DataSync with ChangeNotifier {
factory DataSync() => instance ?? (instance = DataSync._());
bool isDownloading = false;
bool _isDownloading = false;
bool isUploading = false;
bool get isDownloading => _isDownloading;
bool _isUploading = false;
bool get isUploading => _isUploading;
bool haveWaitingTask = false;
bool get isEnabled {
var config = appdata.settings['webdav'];
return config is List && config.isNotEmpty;
var autoSync = appdata.implicitData['webdavAutoSync'] ?? false;
return autoSync && config is List && config.isNotEmpty;
}
List<String>? _validateConfig() {
var config = appdata.settings['webdav'];
if (config is! List || (config.isNotEmpty && config.length != 3)) {
if (config is! List) {
return null;
}
if (config.whereType<String>().length != 3) {
if (config.isEmpty) {
return [];
}
if (config.length != 3 || config.whereType<String>().length != 3) {
return null;
}
return List.from(config);
}
Future<Res<bool>> uploadData() async {
if(isDownloading) return const Res(true);
if (isDownloading) return const Res(true);
if (haveWaitingTask) return const Res(true);
while (isUploading) {
haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
isUploading = true;
_isUploading = true;
notifyListeners();
try {
var config = _validateConfig();
@@ -109,7 +117,7 @@ class DataSync with ChangeNotifier {
filename += '.venera';
var files = await client.readDir('/');
files = files.where((e) => e.name!.endsWith('.venera')).toList();
var old = files.firstWhereOrNull( (e) => e.name!.startsWith("$time-"));
var old = files.firstWhereOrNull((e) => e.name!.startsWith("$time-"));
if (old != null) {
await client.remove(old.name!);
}
@@ -118,6 +126,7 @@ class DataSync with ChangeNotifier {
await client.remove(files.first.name!);
}
await client.write(filename, await data.readAsBytes());
data.deleteIgnoreError();
Log.info("Upload Data", "Data uploaded successfully");
return const Res(true);
} catch (e, s) {
@@ -125,7 +134,7 @@ class DataSync with ChangeNotifier {
return Res.error(e.toString());
}
} finally {
isUploading = false;
_isUploading = false;
notifyListeners();
}
}
@@ -137,7 +146,7 @@ class DataSync with ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
isDownloading = true;
_isDownloading = true;
notifyListeners();
try {
var config = _validateConfig();
@@ -176,8 +185,11 @@ class DataSync with ChangeNotifier {
var files = await client.readDir('/');
files.sort((a, b) => b.name!.compareTo(a.name!));
var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera'));
if (file == null) {
throw 'No data file found';
}
var version =
file!.name!.split('-').elementAtOrNull(1)?.split('.').first;
file.name!.split('-').elementAtOrNull(1)?.split('.').first;
if (version != null && int.tryParse(version) != null) {
var currentVersion = appdata.settings['dataVersion'];
if (currentVersion != null && int.parse(version) <= currentVersion) {
@@ -197,7 +209,7 @@ class DataSync with ChangeNotifier {
return Res.error(e.toString());
}
} finally {
isDownloading = false;
_isDownloading = false;
notifyListeners();
}
}

View File

@@ -24,7 +24,8 @@ class EpubData {
});
}
Future<File> createEpubComic(EpubData data, String cacheDir) async {
Future<File> createEpubComic(
EpubData data, String cacheDir, String outFilePath) async {
final workingDir = Directory(FilePath.join(cacheDir, 'epub'));
if (workingDir.existsSync()) {
workingDir.deleteSync(recursive: true);
@@ -109,8 +110,7 @@ ${images.map((e) => ' <img src="$e" alt="$e"/>').join('\n')}
}
// content.opf
final contentOpf =
File(FilePath.join(workingDir.path, 'content.opf'));
final contentOpf = File(FilePath.join(workingDir.path, 'content.opf'));
final uuid = const Uuid().v4();
var spineStrBuilder = StringBuffer();
for (var i = 0; i < chapterIndex; i++) {
@@ -171,16 +171,15 @@ ${navMapStrBuilder.toString()}
</ncx>
''');
// zip
final zipPath = FilePath.join(cacheDir, '${data.title}.epub');
ZipFile.compressFolder(workingDir.path, zipPath);
ZipFile.compressFolder(workingDir.path, outFilePath);
workingDir.deleteSync(recursive: true);
return File(zipPath);
return File(outFilePath);
}
Future<File> createEpubWithLocalComic(LocalComic comic) async {
Future<File> createEpubWithLocalComic(
LocalComic comic, String outFilePath) async {
var chapters = <String, List<File>>{};
if (comic.chapters == null) {
chapters[comic.title] =
@@ -188,11 +187,11 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
.map((e) => File(e))
.toList();
} else {
for (var chapter in comic.chapters!.keys) {
chapters[comic.chapters![chapter]!] = (await LocalManager()
.getImages(comic.id, comic.comicType, chapter))
.map((e) => File(e))
.toList();
for (var chapter in comic.downloadedChapters) {
chapters[comic.chapters![chapter]!] =
(await LocalManager().getImages(comic.id, comic.comicType, chapter))
.map((e) => File(e))
.toList();
}
}
var data = EpubData(
@@ -205,6 +204,6 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
final cacheDir = App.cachePath;
return Isolate.run(() => overrideIO(() async {
return createEpubComic(data, cacheDir);
return createEpubComic(data, cacheDir, outFilePath);
}));
}

View File

@@ -25,7 +25,9 @@ extension ListExt<T> on List<T>{
}
}
bool isEqualsTo(List<T> list){
/// Compare every element of this list with another list.
/// Return true if all elements are equal.
bool isEqualTo(List<T> list){
if(length != list.length){
return false;
}
@@ -81,10 +83,6 @@ extension StringExt on String{
return '$before$to$after';
}
static bool hasMatch(String? value, String pattern) {
return (value == null) ? false : RegExp(pattern).hasMatch(value);
}
bool _isURL(){
final regex = RegExp(
r'^((http|https|ftp)://)[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-|]*[\w@?^=%&/~+#-])?$',

View File

@@ -26,7 +26,7 @@ class Image {
var codec = await ui.instantiateImageCodec(data);
var frame = await codec.getNextFrame();
codec.dispose();
var info = await frame.image.toByteData();
var info = await frame.image.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
if (info == null) {
throw Exception('Failed to decode image');
}
@@ -39,6 +39,14 @@ class Image {
return image;
}
Color getPixelAtIndex(int index) {
if (index < 0 || index >= _data.length) {
throw ArgumentError(
'Invalid argument: index must be in the range of [0, ${_data.length}).');
}
return Color.fromValue(_data[index]);
}
Image copyRange(int x, int y, int width, int height) {
if (width + x > this.width) {
throw ArgumentError('''
@@ -176,11 +184,11 @@ class Color {
Color.fromValue(this.value);
int get r => (value >> 16) & 0xFF;
int get r => value & 0xFF;
int get g => (value >> 8) & 0xFF;
int get b => value & 0xFF;
int get b => (value >> 16) & 0xFF;
int get a => (value >> 24) & 0xFF;
}

View File

@@ -3,6 +3,7 @@ import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart';
@@ -262,7 +263,9 @@ class ImportComic {
subtitle: subtitle ?? '',
tags: tags ?? [],
directory: directory.path,
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
chapters: hasChapters
? ComicChapters(Map.fromIterables(chapters, chapters))
: null,
cover: coverPath,
comicType: ComicType.local,
downloadedChapters: chapters,

View File

@@ -35,19 +35,9 @@ class FilePath {
}
extension FileSystemEntityExt on FileSystemEntity {
/// Get the base name of the file or directory.
String get name {
var path = this.path;
if (path.endsWith('/') || path.endsWith('\\')) {
path = path.substring(0, path.length - 1);
}
int i = path.length - 1;
while (i >= 0 && path[i] != '\\' && path[i] != '/') {
i--;
}
return path.substring(i + 1);
return p.basename(path);
}
Future<void> deleteIgnoreError({bool recursive = false}) async {
@@ -83,6 +73,10 @@ extension FileExtension on File {
// Stream is not usable since [AndroidFile] does not support [openRead].
await newFile.writeAsBytes(await readAsBytes());
}
String get basenameWithoutExt {
return p.basenameWithoutExtension(path);
}
}
extension DirectoryExtension on Directory {

View File

@@ -1,33 +1,28 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'package:pdf/widgets.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/image.dart';
import 'package:venera/utils/io.dart';
import 'package:zip_flutter/zip_flutter.dart';
typedef DecodeImage = Future<Image> Function(Uint8List data);
Future<void> _createPdfFromComic({
required LocalComic comic,
required String savePath,
required String localPath,
required DecodeImage decodeImage,
}) async {
final pdf = Document(
title: comic.title,
author: comic.subTitle ?? "",
producer: "Venera",
);
pdf.document.outline;
var images = <String>[];
var baseDir = comic.directory.contains('/') || comic.directory.contains('\\')
? comic.directory
: FilePath.join(localPath, comic.directory);
// add cover
var imageData = File(FilePath.join(baseDir, comic.cover)).readAsBytesSync();
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
images.add(FilePath.join(baseDir, comic.cover));
bool multiChapters = comic.chapters != null;
@@ -35,14 +30,14 @@ Future<void> _createPdfFromComic({
files.removeWhere(
(element) => element is! File || element.path.startsWith('cover'));
files.sort((a, b) {
var aName = (a as File).name;
var bName = (b as File).name;
var aName = (a as File).basenameWithoutExt;
var bName = (b as File).basenameWithoutExt;
var aNumber = int.tryParse(aName);
var bNumber = int.tryParse(bName);
if (aNumber != null && bNumber != null) {
return aNumber.compareTo(bNumber);
}
return aName.compareTo(bName);
return a.name.compareTo(b.name);
});
}
@@ -51,42 +46,358 @@ Future<void> _createPdfFromComic({
reorderFiles(files);
for (var file in files) {
var imageData = (file as File).readAsBytesSync();
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
images.add(file.path);
}
} else {
for (var chapter in comic.chapters!.keys) {
for (var chapter in comic.downloadedChapters) {
var files = Directory(FilePath.join(baseDir, chapter)).listSync();
reorderFiles(files);
for (var file in files) {
var imageData = (file as File).readAsBytesSync();
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
images.add(file.path);
}
}
}
final file = File(savePath);
file.writeAsBytesSync(await pdf.save());
var generator = PdfGenerator(
title: comic.title,
author: comic.subtitle,
imagePaths: images,
outputPath: savePath,
decodeImage: decodeImage,
);
await generator.generate();
}
Future<void> createPdfFromComicIsolate({
required LocalComic comic,
required String savePath,
}) async {
Future<Isolate> _runIsolate(
LocalComic comic, String savePath, SendPort sendPort) {
var localPath = LocalManager().path;
return Isolate.run(() => overrideIO(() async {
return await _createPdfFromComic(
return Isolate.spawn<SendPort>(
(sendPort) => overrideIO(
() async {
var receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
Completer<Image>? completer;
Future<Image> decodeImage(Uint8List data) async {
if (completer != null) {
throw Exception('Another image is being decoded');
}
sendPort.send(data);
completer = Completer();
return completer!.future;
}
receivePort.listen((message) {
if (message is Image) {
if (completer == null) {
throw Exception('No image is being decoded');
}
completer!.complete(message);
completer = null;
}
});
await _createPdfFromComic(
comic: comic,
savePath: savePath,
localPath: localPath,
decodeImage: decodeImage,
);
}));
sendPort.send(null);
},
),
sendPort,
);
}
Future<File> createPdfFromComicIsolate(LocalComic comic, String savePath) async {
var receivePort = ReceivePort();
SendPort? sendPort;
Isolate? isolate;
var completer = Completer<void>();
receivePort.listen((message) {
if (message is SendPort) {
sendPort = message;
} else if (message is Uint8List) {
Image.decodeImage(message).then((image) {
sendPort!.send(image);
});
} else if (message == null) {
receivePort.close();
completer.complete();
isolate!.kill();
}
});
isolate = await _runIsolate(comic, savePath, receivePort.sendPort);
await completer.future;
return File(savePath);
}
class PdfGenerator {
final String title;
final String author;
final List<String> imagePaths;
final String outputPath;
final DecodeImage decodeImage;
// PDF文件的对象ID计数器
int _objectId = 1;
// 存储每个对象在PDF中的字节位置
final Map<int, int> _objectOffsets = {};
static const double a4Width = 595.0; // points
static const double a4Height = 842.0; // points
PdfGenerator({
required this.title,
required this.author,
required this.imagePaths,
required this.outputPath,
required this.decodeImage,
});
Future<void> generate() async {
var file = File(outputPath);
final output = file.openWrite();
int length = 0;
void write(String str) {
var data = utf8.encode(str);
output.add(data);
length += data.length;
}
void writeData(Uint8List data) {
output.add(data);
length += data.length;
}
int getCurrentLength() {
return length;
}
// 1. 写入PDF头部
write('%PDF-1.7\n%\xFF\xFF\xFF\xFF\n\n');
// 2. 写入Catalog对象
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Type /Catalog\n');
write('/Pages ${_objectId + 1} 0 R\n');
write('>>\nendobj\n\n');
final catalogId = _objectId++;
// 3. 写入Pages对象
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Type /Pages\n');
write('/Kids [');
final pageIds = <int>[];
for (var i = 0; i < imagePaths.length; i++) {
pageIds.add(_objectId + 1 + i * 3);
write('${_objectId + 1 + i * 3} 0 R ');
}
write(']\n');
write('/Count ${imagePaths.length}\n');
write('>>\nendobj\n\n');
final pagesId = _objectId++;
// 4. 为每个图片创建Page和Image对象
for (var i = 0; i < imagePaths.length; i++) {
final imagePath = imagePaths[i];
final image = await _getImage(imagePath);
// 写入Page对象
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Type /Page\n');
write('/Parent $pagesId 0 R\n');
write('/Resources <<\n');
write('/XObject << /Im${i + 1} ${_objectId + 1} 0 R >>\n');
write('>>\n');
write('/MediaBox [0 0 $a4Width $a4Height]\n');
write('/Contents ${_objectId + 2} 0 R\n');
write('>>\nendobj\n\n');
_objectId++;
// 写入Image对象
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Type /XObject\n');
write('/Subtype /Image\n');
write('/Width ${image.width}\n');
write('/Height ${image.height}\n');
write('/ColorSpace /DeviceRGB\n');
write('/BitsPerComponent 8\n');
write('/Filter /FlateDecode\n');
write('/Length ${image.data.length}\n');
write('>>\nstream\n');
writeData(image.data);
write('\nendstream\nendobj\n\n');
_objectId++;
// 写入Contents对象绘制图片的指令
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
var stream = '';
stream += 'q\n';
// Calculate scaling factors
var scaleX = a4Width / image.width;
var scaleY = a4Height / image.height;
var scale = scaleX < scaleY ? scaleX : scaleY;
// Calculate centering offsets
var offsetX = (a4Width - (image.width * scale)) / 2;
var offsetY = (a4Height - (image.height * scale)) / 2;
// Apply transformation matrix
stream += '1 0 0 1 $offsetX $offsetY cm\n'; // Translate
stream += '${scale * image.width} 0 0 ${scale * image.height} 0 0 cm\n';
stream += '/Im${i + 1} Do\n';
stream += 'Q\n';
var streamData = utf8.encode(stream);
write('/Length ${streamData.length}\n');
write('>>\nstream\n');
writeData(streamData);
write('endstream\nendobj\n\n');
_objectId++;
}
// 5. 写入Info对象元数据
final infoId = _objectId;
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Title <');
writeData(_toPdfString(title));
write('>\n');
write('/Author <');
writeData(_toPdfString(author));
write('>\n');
write('/Producer (venera v${App.version})\n');
write('/CreationDate (D:${_formatDateTime(DateTime.now())})\n');
write('>>\nendobj\n\n');
_objectId++;
// 6. 写入交叉引用表
final xrefOffset = getCurrentLength();
write('xref\n');
write('0 $_objectId\n');
write('0000000000 65535 f\r\n');
for (var i = 1; i < _objectId; i++) {
final offset = _objectOffsets[i]!;
write('${offset.toString().padLeft(10, '0')} 00000 n\r\n'); // 使用\r\n
}
// 7. 写入文件尾部
write('trailer\n');
write('<<\n');
write('/Size $_objectId\n');
write('/Root $catalogId 0 R\n');
write('/Info $infoId 0 R\n');
write('>>\n');
write('startxref\n');
write('$xrefOffset\n');
write('%%EOF\n');
await output.close();
}
int _codeUnitForDigit(int digit) =>
digit < 10 ? digit + 0x30 : digit + 0x61 - 10;
Uint8List _toPdfString(String str) {
Uint8List data;
try {
data = latin1.encode(str);
} catch (e) {
data = Uint8List.fromList(<int>[0xfe, 0xff] + _encodeUtf16be(str));
}
var result = <int>[];
for (final byte in data) {
result.add(_codeUnitForDigit((byte & 0xF0) >> 4));
result.add(_codeUnitForDigit(byte & 0x0F));
}
return Uint8List.fromList(result);
}
List<int> _encodeUtf16be(String str) {
const unicodeReplacementCharacterCodePoint = 0xfffd;
const unicodeByteZeroMask = 0xff;
const unicodeByteOneMask = 0xff00;
const unicodeValidRangeMax = 0x10ffff;
const unicodePlaneOneMax = 0xffff;
const unicodeUtf16ReservedLo = 0xd800;
const unicodeUtf16ReservedHi = 0xdfff;
const unicodeUtf16Offset = 0x10000;
const unicodeUtf16SurrogateUnit0Base = 0xd800;
const unicodeUtf16SurrogateUnit1Base = 0xdc00;
const unicodeUtf16HiMask = 0xffc00;
const unicodeUtf16LoMask = 0x3ff;
final encoding = <int>[];
void add(int unit) {
encoding.add((unit & unicodeByteOneMask) >> 8);
encoding.add(unit & unicodeByteZeroMask);
}
for (final unit in str.codeUnits) {
if ((unit >= 0 && unit < unicodeUtf16ReservedLo) ||
(unit > unicodeUtf16ReservedHi && unit <= unicodePlaneOneMax)) {
add(unit);
} else if (unit > unicodePlaneOneMax && unit <= unicodeValidRangeMax) {
final base = unit - unicodeUtf16Offset;
add(unicodeUtf16SurrogateUnit0Base +
((base & unicodeUtf16HiMask) >> 10));
add(unicodeUtf16SurrogateUnit1Base + (base & unicodeUtf16LoMask));
} else {
add(unicodeReplacementCharacterCodePoint);
}
}
return encoding;
}
// 格式化日期时间
String _formatDateTime(DateTime dt) {
return dt
.toUtc()
.toString()
.replaceAll('-', '')
.replaceAll(':', '')
.replaceAll(' ', '')
.replaceAll('.', '')
.substring(0, 14);
}
Future<({int width, int height, Uint8List data})> _getImage(
String imagePath) async {
var data = await File(imagePath).readAsBytes();
var image = await decodeImage(data);
var width = image.width;
var height = image.height;
data = Uint8List(width * height * 3);
for (var i = 0; i < width * height; i++) {
var pixel = image.getPixelAtIndex(i);
data[i * 3] = pixel.r;
data[i * 3 + 1] = pixel.g;
data[i * 3 + 2] = pixel.b;
}
data = tdeflCompressData(data, true, true, 9);
return (width: width, height: height, data: data);
}
}

View File

@@ -15,9 +15,6 @@ extension TagsTranslation on String{
static final Map<String, Map<String, String>> _data = {};
static Future<void> readData() async{
if(App.locale.languageCode != "zh"){
return;
}
var fileName = App.locale.countryCode == 'TW'
? "assets/tags_tw.json"
: "assets/tags.json";
@@ -55,6 +52,15 @@ extension TagsTranslation on String{
/// translate tag's text to chinese
String get translateTagsToCN => _translateTags(this);
String get translateTagIfNeed {
var locale = App.locale;
if (locale.languageCode == "zh") {
return translateTagsToCN;
} else {
return this;
}
}
static String translateTag(String tag) {
if(tag.contains(':') && tag.indexOf(':') == tag.lastIndexOf(':')) {
var [namespace, text] = tag.split(':');

View File

@@ -5,10 +5,10 @@ packages:
dependency: "direct main"
description:
name: app_links
sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950"
sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba"
url: "https://pub.dev"
source: hosted
version: "6.3.3"
version: "6.4.0"
app_links_linux:
dependency: transitive
description:
@@ -33,14 +33,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@@ -53,18 +45,10 @@ packages:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev"
source: hosted
version: "2.11.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003
url: "https://pub.dev"
source: hosted
version: "2.2.8"
version: "2.12.0"
battery_plus:
dependency: "direct main"
description:
@@ -81,22 +65,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
bidi:
dependency: transitive
description:
name: bidi
sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d"
url: "https://pub.dev"
source: hosted
version: "2.0.12"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
build_cli_annotations:
dependency: transitive
description:
@@ -109,26 +85,26 @@ packages:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.19.1"
convert:
dependency: transitive
description:
@@ -165,10 +141,10 @@ packages:
dependency: transitive
description:
name: dbus
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
desktop_webview_window:
dependency: "direct main"
description:
@@ -182,18 +158,18 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
url: "https://pub.dev"
source: hosted
version: "5.7.0"
version: "5.8.0+1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
sha256: e485c7a39ff2b384fa1d7e09b4e25f755804de8384358049124830b04fc4f93a
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "2.1.0"
dynamic_color:
dependency: "direct main"
description:
@@ -206,18 +182,18 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.2"
ffi:
dependency: transitive
description:
name: ffi
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
file:
dependency: transitive
description:
@@ -286,10 +262,10 @@ packages:
dependency: transitive
description:
name: file_selector_windows
sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4"
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
url: "https://pub.dev"
source: hosted
version: "0.9.3+3"
version: "0.9.3+4"
fixnum:
dependency: transitive
description:
@@ -298,6 +274,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flex_seed_scheme:
dependency: "direct main"
description:
name: flex_seed_scheme
sha256: d3ba3c5c92d2d79d45e94b4c6c71d01fac3c15017da1545880c53864da5dfeb0
url: "https://pub.dev"
source: hosted
version: "3.5.0"
flutter:
dependency: "direct main"
description: flutter
@@ -323,19 +307,21 @@ packages:
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
path: flutter_inappwebview
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
source: git
version: "6.2.0-beta.3"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
path: flutter_inappwebview_android
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
source: git
version: "1.2.0-beta.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
@@ -347,43 +333,48 @@ packages:
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
path: flutter_inappwebview_ios
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
source: git
version: "1.2.0-beta.3"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://pub.dev"
source: hosted
version: "1.1.2"
path: flutter_inappwebview_macos
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
source: git
version: "1.2.0-beta.3"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://pub.dev"
source: hosted
version: "1.3.0+1"
path: flutter_inappwebview_platform_interface
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
source: git
version: "1.4.0-beta.3"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
path: flutter_inappwebview_web
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
source: git
version: "1.2.0-beta.3"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
path: flutter_inappwebview_windows
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
source: git
version: "0.7.0-beta.3"
flutter_lints:
dependency: "direct dev"
description:
@@ -409,16 +400,16 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398"
sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e"
url: "https://pub.dev"
source: hosted
version: "2.0.23"
version: "2.0.24"
flutter_qjs:
dependency: "direct main"
description:
path: "."
ref: "9c99ac258a11f8e91761a5466a190efba3ca64af"
resolved-ref: "9c99ac258a11f8e91761a5466a190efba3ca64af"
ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
resolved-ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
url: "https://github.com/wgh136/flutter_qjs"
source: git
version: "0.3.7"
@@ -426,18 +417,18 @@ packages:
dependency: "direct main"
description:
name: flutter_reorderable_grid_view
sha256: "732bcb1b29d5130c11a70e6acec512941fafe241f0e80bffd93ca6e415819915"
sha256: a7e0f9d5ba12fd232eb07fbb7f570ae35491045a6bba1858f6eb50c675526dfe
url: "https://pub.dev"
source: hosted
version: "5.4.0"
version: "5.4.1"
flutter_rust_bridge:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: "35c257fc7f98e34c1314d6c145e5ed54e7c94e8a9f469947e31c9298177d546f"
sha256: "3292ad6085552987b8b3b9a7e5805567f4013372d302736b702801acb001ee00"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
version: "2.7.1"
flutter_saf:
dependency: "direct main"
description:
@@ -501,18 +492,18 @@ packages:
dependency: transitive
description:
name: http
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.3.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360"
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
version: "4.1.2"
http_profile:
dependency: transitive
description:
@@ -521,14 +512,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.0"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
intl:
dependency: "direct main"
description:
@@ -545,14 +528,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
url: "https://pub.dev"
source: hosted
version: "0.7.1"
json_annotation:
dependency: transitive
description:
@@ -565,18 +540,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
version: "10.0.7"
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.8"
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
@@ -589,10 +564,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: "4a16b3f03741e1252fda5de3ce712666d010ba2122f8e912c94f9f7b90e1a4c3"
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.0"
version: "5.1.1"
local_auth:
dependency: "direct main"
description:
@@ -613,10 +588,10 @@ packages:
dependency: transitive
description:
name: local_auth_darwin
sha256: "6d2950da311d26d492a89aeb247c72b4653ddc93601ea36a84924a396806d49c"
sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.3"
local_auth_platform_interface:
dependency: transitive
description:
@@ -646,10 +621,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@@ -662,10 +637,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.16.0"
mime:
dependency: "direct main"
description:
@@ -686,18 +661,10 @@ packages:
dependency: "direct main"
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
@@ -746,28 +713,20 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07"
url: "https://pub.dev"
source: hosted
version: "3.11.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://pub.dev"
source: hosted
version: "6.0.2"
version: "6.1.0"
photo_view:
dependency: "direct main"
description:
path: "."
ref: "94724a0b"
resolved-ref: "94724a0b7f94167fd1ae061f84e14ae04cae5c39"
ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
resolved-ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
url: "https://github.com/wgh136/photo_view"
source: git
version: "0.14.0"
@@ -791,26 +750,18 @@ packages:
dependency: "direct main"
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "4.0.0"
rhttp:
dependency: "direct main"
description:
name: rhttp
sha256: "8212cbc816cc3e761eecb8d4dbbaa1eca95de715428320a198a4e7a89acdcd2e"
sha256: "3deabc6c3384b4efa252dfb4a5059acc6530117fdc1b10f5f67ff9768c9af75a"
url: "https://pub.dev"
source: hosted
version: "0.9.8"
version: "0.10.0"
screen_retriever:
dependency: transitive
description:
@@ -864,10 +815,10 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400"
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.3"
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
@@ -901,10 +852,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
version: "1.10.1"
sprintf:
dependency: transitive
description:
@@ -917,42 +868,42 @@ packages:
dependency: "direct main"
description:
name: sqlite3
sha256: cb7f4e9dc1b52b1fa350f7b3d41c662e75fc3d399555fa4e5efcf267e9a4fbb5
sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
version: "2.7.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "73016db8419f019e807b7a5e5fbf2a7bd45c165fed403b8e7681230f3a102785"
sha256: "57fafacd815c981735406215966ff7caaa8eab984b094f52e692accefcbd9233"
url: "https://pub.dev"
source: hosted
version: "0.5.28"
version: "0.5.30"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
syntax_highlight:
dependency: "direct main"
description:
@@ -965,26 +916,18 @@ packages:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.3"
text_scroll:
dependency: "direct main"
description:
name: text_scroll
sha256: "7869d86a6fdd725dee56bdd150216a99f0372b82fbfcac319214dbd5f36e1908"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.7.4"
typed_data:
dependency: transitive
description:
@@ -1053,18 +996,18 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e"
sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9"
url: "https://pub.dev"
source: hosted
version: "2.3.3"
version: "2.4.0"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4"
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
version: "3.1.4"
uuid:
dependency: "direct main"
description:
@@ -1085,10 +1028,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev"
source: hosted
version: "14.3.0"
version: "14.3.1"
web:
dependency: transitive
description:
@@ -1110,10 +1053,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69"
sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef
url: "https://pub.dev"
source: hosted
version: "5.9.0"
version: "5.11.0"
window_manager:
dependency: "direct main"
description:
@@ -1142,18 +1085,18 @@ packages:
dependency: "direct main"
description:
name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.2"
version: "3.1.3"
zip_flutter:
dependency: "direct main"
description:
name: zip_flutter
sha256: be21152c35fcb6d0ef4ce89fc3aed681f7adc0db5490ca3eb5893f23fd20e646
sha256: bbf3160062610a43901b7ebbc6f6dd46519540f03a84027dc7b1fff399dda1ac
url: "https://pub.dev"
source: hosted
version: "0.0.6"
version: "0.0.10"
sdks:
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.1"
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.0"

View File

@@ -2,61 +2,62 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.2.0+120
version: 1.3.1+131
environment:
sdk: '>=3.6.0 <4.0.0'
flutter: 3.27.1
flutter: 3.29.0
dependencies:
flutter:
sdk: flutter
path_provider: any
flutter_localizations:
sdk: flutter
intl: ^0.19.0
intl: any
window_manager: ^0.4.3
sqlite3: ^2.4.7
sqlite3_flutter_libs: ^0.5.28
sqlite3: ^2.7.4
sqlite3_flutter_libs: ^0.5.30
flutter_qjs:
git:
url: https://github.com/wgh136/flutter_qjs
ref: 9c99ac258a11f8e91761a5466a190efba3ca64af
ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3
crypto: ^3.0.6
dio: ^5.7.0
dio: ^5.8.0+1
html: ^0.15.5
pointycastle: any
pointycastle: ^4.0.0
url_launcher: ^6.3.0
path: ^1.9.0
photo_view:
git:
url: https://github.com/wgh136/photo_view
ref: 94724a0b
ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
mime: ^2.0.0
share_plus: ^10.1.3
share_plus: ^10.1.4
scrollable_positioned_list:
git:
url: https://github.com/venera-app/flutter.widgets
ref: 09e756b1f1b04e6298318d99ec20a787fb360f59
path: packages/scrollable_positioned_list
flutter_reorderable_grid_view: ^5.4.0
yaml: any
uuid: ^4.5.1
desktop_webview_window:
git:
url: https://github.com/wgh136/flutter_desktop_webview
path: packages/desktop_webview_window
flutter_inappwebview: ^6.1.5
app_links: ^6.3.3
flutter_inappwebview:
git:
url: https://github.com/pichillilorenzo/flutter_inappwebview
path: flutter_inappwebview
ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676
app_links: ^6.4.0
sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2
file_selector: ^1.0.3
zip_flutter: ^0.0.6
zip_flutter: ^0.0.10
lodepng_flutter:
git:
url: https://github.com/venera-app/lodepng_flutter
ref: 9a784b193af5d55b2a35e58fa390bda3e4f35d00
rhttp: 0.9.8
rhttp: 0.10.0
webdav_client:
git:
url: https://github.com/wgh136/webdav_client
@@ -67,23 +68,25 @@ dependencies:
git:
url: https://github.com/pkuislm/flutter_saf.git
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
pdf: ^3.11.1
dynamic_color: ^1.7.0
shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1
syntax_highlight: ^0.4.0
text_scroll: ^0.2.0
flutter_7zip:
git:
url: https://github.com/wgh136/flutter_7zip
ref: b33344797f1d2469339e0e1b75f5f954f1da224c
flex_seed_scheme: ^3.5.0
flutter_localizations:
sdk: flutter
yaml: ^3.1.3
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter_to_arch: ^1.0.1
flutter_to_debian:
flutter_to_debian: ^2.0.2
flutter:
uses-material-design: true

View File

@@ -3,11 +3,36 @@
#define MyAppName "Venera"
#define MyAppVersion "{{version}}"
#define MyAppPublisher "wgh136"
#define MyAppPublisher "nyne"
#define MyAppURL "https://github.com/venera-app/venera"
#define MyAppExeName "venera.exe"
#define RootPath "{{root_path}}"
[Code]
procedure CurStepChanged(CurStep: TSetupStep);
var
OldVersionPath, ShortcutPath: string;
begin
if CurStep = ssInstall then
begin
OldVersionPath := 'C:\Program Files (x86)\Venera';
if DirExists(OldVersionPath) then
begin
DelTree(OldVersionPath, True, True, True);
ShortcutPath := GetEnv('USERPROFILE') + '\Desktop\Venera.lnk';
if FileExists(ShortcutPath) then
begin
DeleteFile(ShortcutPath);
end;
ShortcutPath := 'C:\Users\Public\Desktop\Venera.lnk';
if FileExists(ShortcutPath) then
begin
DeleteFile(ShortcutPath);
end;
end;
end;
end;
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
@@ -30,6 +55,8 @@ SetupIconFile={#RootPath}\windows\runner\resources\app_icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
ArchitecturesAllowed=x64compatible
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"

View File

@@ -29,7 +29,7 @@ file.close()
if not os.path.exists("windows/ChineseSimplified.isl"):
# download ChineseSimplified.isl
url = "https://raw.githubusercontent.com/kira-96/Inno-Setup-Chinese-Simplified-Translation/refs/heads/main/ChineseSimplified.isl"
url = "https://cdn.jsdelivr.net/gh/kira-96/Inno-Setup-Chinese-Simplified-Translation@latest/ChineseSimplified.isl"
response = httpx.get(url)
with open('windows/ChineseSimplified.isl', 'wb') as file:
file.write(response.content)