110 Commits

Author SHA1 Message Date
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
75 changed files with 4506 additions and 1858 deletions

View File

@@ -7,6 +7,10 @@ body:
attributes: attributes:
value: | value: |
Thank you for reporting a problem, please complete the title and fill in the following information. Thank you for reporting a problem, please complete the title and fill in the following information.
**Please do not report any issues related to config files.**
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 - type: textarea
id: what-happened id: what-happened
attributes: attributes:
@@ -19,7 +23,8 @@ body:
attributes: attributes:
label: Version label: Version
description: | description: |
App version App version.
Please try to update if it is not the latest version Please try to update if it is not the latest version
validations: validations:
required: true required: true

View File

@@ -39,12 +39,18 @@ jobs:
ln -s /Applications dist/dmg_contents/Applications ln -s /Applications dist/dmg_contents/Applications
hdiutil create -volname "venera" -srcfolder dist/dmg_contents -ov -format UDZO "dist/venera.dmg" hdiutil create -volname "venera" -srcfolder dist/dmg_contents -ov -format UDZO "dist/venera.dmg"
- name: Add version to filename
run: |
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
mkdir -p result
mv dist/venera.dmg result/venera-$APP_VERSION.dmg
# Step 4: Attach and upload artifacts (optional) # Step 4: Attach and upload artifacts (optional)
- name: Upload DMG - name: Upload DMG
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: venera.dmg name: macos_build
path: dist/venera.dmg path: result/
Build_IOS: Build_IOS:
runs-on: macos-15 runs-on: macos-15
steps: steps:
@@ -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 mv /Users/runner/work/venera/venera/build/ios/iphoneos/Runner.app /Users/runner/work/venera/venera/build/ios/iphoneos/Payload
cd /Users/runner/work/venera/venera/build/ios/iphoneos/ cd /Users/runner/work/venera/venera/build/ios/iphoneos/
zip -r venera-ios.ipa Payload zip -r venera-ios.ipa Payload
- name: Add version to filename
run: |
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
mkdir -p result
mv build/ios/iphoneos/venera-ios.ipa result/venera-ios-$APP_VERSION.ipa
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: app-ios.ipa name: ios_build
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa path: result/
Build_Android: Build_Android:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
@@ -86,6 +97,10 @@ jobs:
with: with:
distribution: 'oracle' distribution: 'oracle'
java-version: '17' java-version: '17'
- name: Setup Rust
run: |
rustup update
rustup default stable
- run: flutter pub get - run: flutter pub get
- run: flutter build apk --release - run: flutter build apk --release
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
@@ -114,7 +129,7 @@ jobs:
name: windows_build name: windows_build
path: build/windows/Venera-* path: build/windows/Venera-*
Build_Linux: Build_Linux:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
@@ -126,7 +141,7 @@ jobs:
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1 sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian 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: dart run flutter_to_arch
- run: | - run: |
sudo rm -rf build/linux/arch/app.tar.gz sudo rm -rf build/linux/arch/app.tar.gz
@@ -141,19 +156,43 @@ jobs:
with: with:
name: arch_build name: arch_build
path: build/linux/arch/ path: build/linux/arch/
Build_Linux_ARM64:
runs-on: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
run: |
FLUTTER_VERSION=$(grep " flutter:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
sudo apt-get update -y && sudo apt-get upgrade -y;
sudo apt-get install -y curl git unzip xz-utils zip libglu1-mesa clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev
git clone --depth 1 --branch $FLUTTER_VERSION https://github.com/flutter/flutter.git $RUNNER_TEMP/flutter
echo "$RUNNER_TEMP/flutter/bin" >> $GITHUB_PATH
- name: Install Flutter
run: flutter doctor
- name: Install dependencies
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
- 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: Release:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux] needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux, Build_Linux_ARM64]
if: github.event_name == 'release' # 仅在 push 事件时执行 if: github.event_name == 'release' # 仅在 push 事件时执行
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: venera.dmg name: macos_build
path: outputs path: outputs
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: app-ios.ipa name: ios_build
path: outputs path: outputs
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
@@ -171,6 +210,10 @@ jobs:
with: with:
name: arch_build name: arch_build
path: outputs path: outputs
- uses: actions/download-artifact@v4
with:
name: deb_arm64_build
path: outputs
- uses: softprops/action-gh-release@v2 - uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}

View File

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

View File

@@ -83,20 +83,31 @@ android {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64" abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
} }
signingConfig signingConfigs.release signingConfig signingConfigs.release
applicationVariants.all { variant -> }
variant.outputs.all { output -> debug {
def abi = output.getFilter(com.android.build.OutputFile.ABI) ndk {
if (abi != null) { abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
outputFileName = "venera-${variant.versionName}-${abi}.apk" }
def abiVersionCode = project.ext.abiCodes.get(abi) signingConfig signingConfigs.debug
if (abiVersionCode != null) { }
versionCodeOverride = variant.versionCode * 10 + abiVersionCode }
}
} else { applicationVariants.all { variant ->
outputFileName = "venera-${variant.versionName}.apk" variant.outputs.all { output ->
versionCodeOverride = variant.versionCode * 10 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. * [fetch] function for sending HTTP requests. Same api as the browser fetch.
* @param url {string} * @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>)}>} * @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 * @since 1.2.0
*/ */
@@ -921,7 +921,7 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
* @param description {string?} * @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined} * @param tags {Map<string, string[]> | {} | null | undefined}
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title * @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null * @param isFavorite {boolean | null | undefined} - favorite status.
* @param subId {string?} - a param which is passed to comments api * @param subId {string?} - a param which is passed to comments api
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails * @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[]?} - related comics * @param recommend {Comic[]?} - related comics
@@ -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() { } init() { }
static sources = {} static sources = {}
@@ -1205,6 +1218,10 @@ class Image {
} }
} }
/**
* UI related apis
* @since 1.2.0
*/
let UI = { let UI = {
/** /**
* Show a message * Show a message
@@ -1222,7 +1239,9 @@ let UI = {
* Show a dialog. Any action will close the dialog. * Show a dialog. Any action will close the dialog.
* @param title {string} * @param title {string}
* @param content {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) => { showDialog: (title, content, actions) => {
sendMessage({ sendMessage({
@@ -1245,4 +1264,97 @@ let UI = {
url: url, 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

@@ -139,8 +139,8 @@
"Block": "屏蔽", "Block": "屏蔽",
"Add new favorite to": "添加新收藏到", "Add new favorite to": "添加新收藏到",
"Move favorite after reading": "阅读后移动收藏", "Move favorite after reading": "阅读后移动收藏",
"Delete folder?" : "除文件夾?", "Delete folder?" : "除文件夹?",
"Delete folder '@f' ?" : "删除文件夹 '@f' ", "Delete folder '@f' ?" : "删除文件夹 '@f' ?",
"Import from file": "从文件导入", "Import from file": "从文件导入",
"Failed to import": "导入失败", "Failed to import": "导入失败",
"Cache Limit": "缓存限制", "Cache Limit": "缓存限制",
@@ -148,11 +148,6 @@
"Size in MB": "大小MB", "Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录", "Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
"Help": "帮助", "Help": "帮助",
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有当目录满足以下条件之一时,才被视为漫画:",
"1. The directory only contains image files." : "1. 目录只包含图片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n",
"Export as cbz": "导出为cbz", "Export as cbz": "导出为cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)", "Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一个归档文件", "An archive file" : "一个归档文件",
@@ -165,7 +160,7 @@
"Date Desc": "日期降序", "Date Desc": "日期降序",
"Start": "开始", "Start": "开始",
"Export App Data": "导出应用数据", "Export App Data": "导出应用数据",
"Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)", "Import App Data": "导入应用数据",
"Export": "导出", "Export": "导出",
"Download Threads": "下载线程数", "Download Threads": "下载线程数",
"Update Time": "更新时间", "Update Time": "更新时间",
@@ -234,7 +229,7 @@
"Clear History": "清除历史", "Clear History": "清除历史",
"Are you sure you want to clear your history?": "确定要清除您的历史记录吗?", "Are you sure you want to clear your history?": "确定要清除您的历史记录吗?",
"No Explore Pages": "没有探索页面", "No Explore Pages": "没有探索页面",
"Add a comic source in home page": "在主页添加一个漫画源", "Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置", "Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面", "No Category Pages": "没有分类页面",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
@@ -318,7 +313,26 @@
"Imported @a comics": "已导入 @a 本漫画", "Imported @a comics": "已导入 @a 本漫画",
"New Version": "新版本", "New Version": "新版本",
"@c updates": "@c 项更新", "@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": "显示全部"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -468,11 +482,6 @@
"Size in MB": "大小MB", "Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄", "Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
"Help": "幫助", "Help": "幫助",
"A directory is considered as a comic only if it matches one of the following conditions:" : "只有當目錄滿足以下條件之一時,才被視為漫畫:",
"1. The directory only contains image files." : "1. 目錄只包含圖片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n",
"Export as cbz": "匯出為cbz", "Export as cbz": "匯出為cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)", "Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一個歸檔文件", "An archive file" : "一個歸檔文件",
@@ -486,7 +495,7 @@
"Start": "開始", "Start": "開始",
"Reversed successfully": "反轉成功", "Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據", "Export App Data": "匯出應用數據",
"Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)", "Import App Data": "匯入應用數據",
"Export": "匯出", "Export": "匯出",
"Download Threads": "下載線程數", "Download Threads": "下載線程數",
"Update Time": "更新時間", "Update Time": "更新時間",
@@ -555,7 +564,7 @@
"Clear History": "清除歷史", "Clear History": "清除歷史",
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?", "Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
"No Explore Pages": "沒有探索頁面", "No Explore Pages": "沒有探索頁面",
"Add a comic source in home page": "在主頁添加一個漫畫源", "Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定", "Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面", "No Category Pages": "沒有分類頁面",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
@@ -639,6 +648,25 @@
"Imported @a comics": "已匯入 @a 部漫畫", "Imported @a comics": "已匯入 @a 部漫畫",
"New Version": "新版本", "New Version": "新版本",
"@c updates": "@c 項更新", "@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": "顯示全部"
} }
} }

11
debian/build.py vendored
View File

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

6
debian/debian.yaml vendored
View File

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

View File

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

655
doc/comic_source.md Normal file
View File

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

61
doc/import_comic.md Normal file
View File

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

513
doc/js_api.md Normal file
View File

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

View File

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

View File

@@ -1,12 +1,14 @@
part of 'components.dart'; part of 'components.dart';
class Appbar extends StatefulWidget implements PreferredSizeWidget { class Appbar extends StatefulWidget implements PreferredSizeWidget {
const Appbar( const Appbar({
{required this.title, required this.title,
this.leading, this.leading,
this.actions, this.actions,
this.backgroundColor, this.backgroundColor,
super.key}); this.style = AppbarStyle.blur,
super.key,
});
final Widget title; final Widget title;
@@ -16,6 +18,8 @@ class Appbar extends StatefulWidget implements PreferredSizeWidget {
final Color? backgroundColor; final Color? backgroundColor;
final AppbarStyle style;
@override @override
State<Appbar> createState() => _AppbarState(); State<Appbar> createState() => _AppbarState();
@@ -108,10 +112,18 @@ class _AppbarState extends State<Appbar> {
], ],
).paddingTop(context.padding.top), ).paddingTop(context.padding.top),
); );
return BlurEffect( if (widget.style == AppbarStyle.shadow) {
blur: _scrolledUnder ? 15 : 0, return Material(
child: content, color: context.colorScheme.surface,
); elevation: _scrolledUnder ? 2 : 0,
child: content,
);
} else {
return BlurEffect(
blur: _scrolledUnder ? 15 : 0,
child: content,
);
}
} }
} }
@@ -256,18 +268,25 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
} }
} }
class FilledTabBar extends StatefulWidget { class AppTabBar extends StatefulWidget {
const FilledTabBar({super.key, this.controller, required this.tabs}); const AppTabBar({
super.key,
this.controller,
required this.tabs,
this.actionButton,
});
final TabController? controller; final TabController? controller;
final List<Tab> tabs; final List<Tab> tabs;
final Widget? actionButton;
@override @override
State<FilledTabBar> createState() => _FilledTabBarState(); State<AppTabBar> createState() => _AppTabBarState();
} }
class _FilledTabBarState extends State<FilledTabBar> { class _AppTabBarState extends State<AppTabBar> {
late TabController _controller; late TabController _controller;
late List<GlobalKey> keys; late List<GlobalKey> keys;
@@ -315,7 +334,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
} }
@override @override
void didUpdateWidget(covariant FilledTabBar oldWidget) { void didUpdateWidget(covariant AppTabBar oldWidget) {
if (widget.controller != oldWidget.controller) { if (widget.controller != oldWidget.controller) {
_controller = widget.controller ?? DefaultTabController.of(context); _controller = widget.controller ?? DefaultTabController.of(context);
_controller.animation!.addListener(onTabChanged); _controller.animation!.addListener(onTabChanged);
@@ -366,25 +385,27 @@ class _FilledTabBarState extends State<FilledTabBar> {
painter: painter, painter: painter,
child: _TabRow( child: _TabRow(
callback: _tabLayoutCallback, callback: _tabLayoutCallback,
children: List.generate(widget.tabs.length, buildTab), children: List.generate(widget.tabs.length, buildTab)
..addIfNotNull(widget.actionButton?.padding(tabPadding)),
), ),
).paddingHorizontal(4), ).paddingHorizontal(4),
); );
}, },
); );
return Container( return Container(
key: tabBarKey, key: tabBarKey,
height: _kTabHeight, height: _kTabHeight,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: context.colorScheme.outlineVariant, color: context.colorScheme.outlineVariant,
width: 0.6, width: 0.6,
),
), ),
), ),
child: widget.tabs.isEmpty ? const SizedBox() : child); ),
child: widget.tabs.isEmpty ? const SizedBox() : child,
);
} }
int? previousIndex; int? previousIndex;
@@ -544,7 +565,7 @@ class _IndicatorPainter extends CustomPainter {
var rect = Rect.fromLTWH( var rect = Rect.fromLTWH(
tabLeft + padding.left + horizontalPadding, tabLeft + padding.left + horizontalPadding,
_FilledTabBarState._kTabHeight - 3.6, _AppTabBarState._kTabHeight - 3.6,
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2, tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
3, 3,
); );
@@ -621,7 +642,6 @@ class _TabViewBodyState extends State<TabViewBody> {
} }
} }
class SearchBarController { class SearchBarController {
_SearchBarMixin? _state; _SearchBarMixin? _state;
@@ -894,3 +914,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() { Widget buildLineNumbers() {
return SizedBox( return SizedBox(
width: 32, width: 36,
child: Column( child: Column(
children: [ children: [
for (var i = 1; i <= lineCount; i++) for (var i = 1; i <= lineCount; i++)

View File

@@ -356,14 +356,13 @@ class ComicTile extends StatelessWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0), padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
child: TextScroll( child: Text(
comic.title.replaceAll('\n', ''), comic.title.replaceAll('\n', ''),
mode: TextScrollMode.endless, maxLines: 1,
overflow: TextOverflow.clip,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
delayBefore: Duration(milliseconds: 500),
velocity: const Velocity(pixelsPerSecond: Offset(40, 0)),
), ),
), ),
], ],
@@ -551,7 +550,7 @@ class _ComicDescription extends StatelessWidget {
int cnt = (constraints.maxHeight - 22).toInt() ~/ 25; int cnt = (constraints.maxHeight - 22).toInt() ~/ 25;
return Container( return Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
height: 22 + cnt * 25, height: 21 + cnt * 24,
width: double.infinity, width: double.infinity,
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
child: Wrap( child: Wrap(
@@ -563,31 +562,30 @@ class _ComicDescription extends StatelessWidget {
children: [ children: [
for (var s in tags!) for (var s in tags!)
Container( Container(
height: 22, height: 21,
padding: const EdgeInsets.fromLTRB(3, 2, 3, 2), padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45, maxWidth: constraints.maxWidth * 0.45,
),
decoration: BoxDecoration(
color: s == "Unavailable"
? context.colorScheme.errorContainer
: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
), ),
decoration: BoxDecoration( ),
color: s == "Unavailable" ),
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius:
const BorderRadius.all(Radius.circular(8)),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
))),
], ],
), ),
).toAlign(Alignment.topCenter); ).toAlign(Alignment.topCenter);
@@ -608,23 +606,26 @@ class _ComicDescription extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
fontSize: 12.0, fontSize: 12.0,
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
), ),
if (badge != null) if (badge != null)
Container( Container(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.tertiaryContainer, color: Theme.of(context).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(Radius.circular(8)), 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),
),
)),
], ],
) )
], ],
@@ -741,7 +742,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
@override @override
void didUpdateWidget(covariant SliverGridComics oldWidget) { void didUpdateWidget(covariant SliverGridComics oldWidget) {
if (oldWidget.comics != widget.comics) { if (oldWidget.comics.isEqualTo(widget.comics)) {
comics.clear(); comics.clear();
for (var comic in widget.comics) { for (var comic in widget.comics) {
if (isBlocked(comic) == null) { if (isBlocked(comic) == null) {
@@ -1521,14 +1522,15 @@ class SimpleComicTile extends StatelessWidget {
return AnimatedTapRegion( return AnimatedTapRegion(
borderRadius: 8, borderRadius: 8,
onTap: onTap ?? () { onTap: onTap ??
context.to( () {
() => ComicPage( context.to(
id: comic.id, () => ComicPage(
sourceKey: comic.sourceKey, id: comic.id,
), sourceKey: comic.sourceKey,
); ),
}, );
},
child: Container( child: Container(
width: 92, width: 92,
height: 114, height: 114,

View File

@@ -9,7 +9,6 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:syntax_highlight/syntax_highlight.dart'; import 'package:syntax_highlight/syntax_highlight.dart';
import 'package:text_scroll/text_scroll.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/app_page_route.dart'; import 'package:venera/foundation/app_page_route.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';

View File

@@ -58,26 +58,12 @@ class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
}, },
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: AnimatedContainer( child: AnimatedPhysicalModel(
duration: _fastAnimationDuration, duration: _fastAnimationDuration,
decoration: BoxDecoration( elevation: isHovered ? 3 : 1,
borderRadius: BorderRadius.circular(widget.borderRadius), color: context.colorScheme.surface,
boxShadow: isHovered shadowColor: context.colorScheme.shadow,
? [ borderRadius: BorderRadius.circular(widget.borderRadius),
BoxShadow(
color: context.colorScheme.outline,
blurRadius: 2,
offset: const Offset(0, 2),
),
]
: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: widget.child, child: widget.child,
), ),
), ),

View File

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

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

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

View File

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

View File

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

View File

@@ -168,7 +168,15 @@ Future<void> showConfirmDialog({
} }
class LoadingDialogController { class LoadingDialogController {
void Function()? closeDialog; double? _progress;
String? _message;
void Function()? _closeDialog;
void Function(double? value)? _serProgress;
void Function(String message)? _setMessage;
bool closed = false; bool closed = false;
@@ -177,63 +185,86 @@ class LoadingDialogController {
return; return;
} }
closed = true; closed = true;
if (closeDialog == null) { if (_closeDialog == null) {
Future.microtask(closeDialog!); Future.microtask(_closeDialog!);
} else { } else {
closeDialog!(); _closeDialog!();
} }
} }
void setProgress(double? value) {
if (closed) {
return;
}
_serProgress?.call(value);
}
void setMessage(String message) {
if (closed) {
return;
}
_setMessage?.call(message);
}
} }
LoadingDialogController showLoadingDialog(BuildContext context, LoadingDialogController showLoadingDialog(
{void Function()? onCancel, BuildContext context, {
bool barrierDismissible = true, void Function()? onCancel,
bool allowCancel = true, bool barrierDismissible = true,
String? message, bool allowCancel = true,
String cancelButtonText = "Cancel"}) { String? message,
String cancelButtonText = "Cancel",
bool withProgress = false,
}) {
var controller = LoadingDialogController(); var controller = LoadingDialogController();
controller._message = message;
if (withProgress) {
controller._progress = 0;
}
var loadingDialogRoute = DialogRoute( var loadingDialogRoute = DialogRoute(
context: context, context: context,
barrierDismissible: barrierDismissible, barrierDismissible: barrierDismissible,
builder: (BuildContext context) { builder: (BuildContext context) {
return Dialog( return StatefulBuilder(builder: (context, setState) {
child: Container( controller._serProgress = (value) {
width: 100, setState(() {
padding: const EdgeInsets.all(16.0), controller._progress = value;
child: Row( });
children: [ };
const SizedBox( controller._setMessage = (message) {
width: 30, setState(() {
height: 30, controller._message = message;
child: CircularProgressIndicator(), });
), };
const SizedBox( return ContentDialog(
width: 16, title: controller._message ?? 'Loading',
), content: LinearProgressIndicator(
Text( value: controller._progress,
message ?? 'Loading', backgroundColor: context.colorScheme.surfaceContainer,
style: const TextStyle(fontSize: 16), ).paddingHorizontal(16).paddingVertical(16),
), actions: [
const Spacer(), FilledButton(
if (allowCancel) onPressed: allowCancel
TextButton( ? () {
onPressed: () { controller.close();
controller.close(); onCancel?.call();
onCancel?.call(); }
}, : null,
child: Text(cancelButtonText.tl)) child: Text(cancelButtonText.tl),
], )
), ],
),
); );
}); });
},
);
var navigator = Navigator.of(context, rootNavigator: true); var navigator = Navigator.of(context, rootNavigator: true);
navigator.push(loadingDialogRoute).then((value) => controller.closed = true); navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
controller.closeDialog = () { controller._closeDialog = () {
navigator.removeRoute(loadingDialogRoute); navigator.removeRoute(loadingDialogRoute);
}; };
@@ -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, _controller.position.maxScrollExtent,
); );
if (_futurePosition == old) return; if (_futurePosition == old) return;
_controller.animateTo(_futurePosition!, var target = _futurePosition!;
duration: _fastAnimationDuration, curve: Curves.linear); _controller.animateTo(
_futurePosition!,
duration: _fastAnimationDuration,
curve: Curves.linear,
).then((_) {
var current = _controller.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
}
});
} }
}, },
child: ScrollControllerProvider._( child: ScrollControllerProvider._(

View File

@@ -10,7 +10,7 @@ export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.2.0"; final version = "1.2.5";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;
@@ -52,7 +52,7 @@ class _App {
BuildContext get rootContext => rootNavigatorKey.currentContext!; BuildContext get rootContext => rootNavigatorKey.currentContext!;
void rootPop() { void rootPop() {
rootNavigatorKey.currentState?.pop(); rootNavigatorKey.currentState?.maybePop();
} }
void pop() { void pop() {

View File

@@ -90,13 +90,15 @@ class _Appdata {
/// Sync data from another device /// Sync data from another device
void syncData(Map<String, dynamic> data) { void syncData(Map<String, dynamic> data) {
for (var key in data.keys) { if (data['settings'] is Map) {
if (_disableSync.contains(key)) { var settings = data['settings'] as Map<String, dynamic>;
continue; 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(); saveData();
} }
@@ -124,6 +126,7 @@ class _Settings with ChangeNotifier {
'explore_pages': [], 'explore_pages': [],
'categories': [], 'categories': [],
'favorites': [], 'favorites': [],
'searchSources': null,
'showFavoriteStatusOnTile': true, 'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false, 'showHistoryStatusOnTile': false,
'blockedWords': [], 'blockedWords': [],
@@ -132,6 +135,7 @@ class _Settings with ChangeNotifier {
'readerMode': 'galleryLeftToRight', // values of [ReaderMode] 'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumber': 1, // 1 - 5 'readerScreenPicNumber': 1, // 1 - 5
'enableTapToTurnPages': true, 'enableTapToTurnPages': true,
'reverseTapToTurnPages': false,
'enablePageAnimation': true, 'enablePageAnimation': true,
'language': 'system', // system, zh-CN, zh-TW, en-US 'language': 'system', // system, zh-CN, zh-TW, en-US
'cacheSize': 2048, // in MB 'cacheSize': 2048, // in MB
@@ -153,6 +157,7 @@ class _Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing, 'customImageProcessing': defaultCustomImageProcessing,
'sni': true, 'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
}; };
operator [](String key) { operator [](String key) {

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -1,4 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_saf/flutter_saf.dart'; import 'package:flutter_saf/flutter_saf.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -8,12 +10,12 @@ import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart'; import 'foundation/appdata.dart';
extension FutureInit<T> on Future<T> { extension _FutureInit<T> on Future<T> {
/// Prevent unhandled exception /// Prevent unhandled exception
/// ///
/// A unhandled exception occurred in init() will cause the app to crash. /// A unhandled exception occurred in init() will cause the app to crash.
@@ -27,6 +29,7 @@ extension FutureInit<T> on Future<T> {
} }
Future<void> init() async { Future<void> init() async {
await Rhttp.init();
await SAFTaskWorker().init().wait(); await SAFTaskWorker().init().wait();
await AppTranslation.init().wait(); await AppTranslation.init().wait();
await appdata.init().wait(); await appdata.init().wait();
@@ -39,4 +42,16 @@ Future<void> init() async {
await ComicSource.init().wait(); await ComicSource.init().wait();
await LocalManager().init().wait(); await LocalManager().init().wait();
CacheManager().setLimitSize(appdata.settings['cacheSize']); CacheManager().setLimitSize(appdata.settings['cacheSize']);
if (appdata.settings['searchSources'] == null) {
appdata.settings['searchSources'] = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
}
if (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
} }

View File

@@ -1,14 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flex_seed_scheme/flex_seed_scheme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/pages/auth_page.dart'; import 'package:venera/pages/auth_page.dart';
import 'package:venera/pages/main_page.dart'; import 'package:venera/pages/main_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'components/components.dart'; import 'components/components.dart';
@@ -18,21 +17,11 @@ import 'foundation/appdata.dart';
import 'init.dart'; import 'init.dart';
void main(List<String> args) { void main(List<String> args) {
if (runWebViewTitleBarWidget(args)) { if (runWebViewTitleBarWidget(args)) return;
return;
}
overrideIO(() { overrideIO(() {
runZonedGuarded(() async { runZonedGuarded(() async {
await Rhttp.init();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await init(); await init();
if (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");
};
runApp(const MyApp()); runApp(const MyApp());
if (App.isDesktop) { if (App.isDesktop) {
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
@@ -55,7 +44,7 @@ void main(List<String> args) {
}); });
} }
}, (error, stack) { }, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack"); Log.error("Unhandled Exception", error, stack);
}); });
}); });
} }
@@ -143,6 +132,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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget home; Widget home;
@@ -156,50 +177,29 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
home = const MainPage(); home = const MainPage();
} }
return DynamicColorBuilder(builder: (light, dark) { return DynamicColorBuilder(builder: (light, dark) {
Color? primary, secondary, tertiary;
if (appdata.settings['color'] != 'system' || if (appdata.settings['color'] != 'system' ||
light == null || light == null ||
dark == null) { dark == null) {
var color = translateColorSetting(); primary = translateColorSetting();
light = ColorScheme.fromSeed(
seedColor: color,
surface: Colors.white,
);
dark = ColorScheme.fromSeed(
seedColor: color,
brightness: Brightness.dark,
surface: Colors.black,
);
} else { } else {
light = ColorScheme.fromSeed( primary = light.primary;
seedColor: light.primary, secondary = light.secondary;
surface: Colors.white, tertiary = light.tertiary;
);
dark = ColorScheme.fromSeed(
seedColor: dark.primary,
brightness: Brightness.dark,
surface: Colors.black,
);
} }
return MaterialApp( return MaterialApp(
home: home, home: home,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: getTheme(primary, secondary, tertiary, Brightness.light),
colorScheme: light,
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
),
navigatorKey: App.rootNavigatorKey, navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData( darkTheme: getTheme(primary, secondary, tertiary, Brightness.dark),
colorScheme: dark,
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
),
themeMode: switch (appdata.settings['theme_mode']) { themeMode: switch (appdata.settings['theme_mode']) {
'light' => ThemeMode.light, 'light' => ThemeMode.light,
'dark' => ThemeMode.dark, 'dark' => ThemeMode.dark,
_ => ThemeMode.system _ => ThemeMode.system
}, },
localizationsDelegates: const [ localizationsDelegates: [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
locale: () { locale: () {
@@ -215,9 +215,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
}; };
}(), }(),
supportedLocales: const [ supportedLocales: const [
Locale('en'),
Locale('zh', 'CN'), Locale('zh', 'CN'),
Locale('zh', 'TW'), Locale('zh', 'TW'),
Locale('en'),
], ],
builder: (context, widget) { builder: (context, widget) {
ErrorWidget.builder = (details) { ErrorWidget.builder = (details) {

View File

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

View File

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

View File

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

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

View File

@@ -3,80 +3,128 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/ranking_page.dart'; import 'package:venera/pages/ranking_page.dart';
import 'package:venera/pages/search_result_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 'package:venera/utils/translations.dart';
import 'category_comics_page.dart'; import 'category_comics_page.dart';
import 'comic_source_page.dart';
class CategoriesPage extends StatelessWidget { class CategoriesPage extends StatefulWidget {
const CategoriesPage({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StateBuilder<SimpleController>( if (categories.isEmpty) {
tag: "category", return buildEmpty();
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 Material(
var msg = "No Category Pages".tl; child: DefaultTabController(
msg += '\n'; length: categories.length,
if(ComicSource.isEmpty) { key: Key(categories.toString()),
msg += "Add a comic source in home page".tl; child: Column(
} else { children: [
msg += "Please check your settings".tl; AppTabBar(
} key: PageStorageKey(categories.toString()),
return NetworkError( tabs: categories.map((e) {
message: msg, String title = e;
retry: () { try {
controller.update(); title = getCategoryDataWithKey(e).title;
}, } catch (e) {
withAppbar: false, //
); }
} return Tab(
text: title,
return Material( key: Key(e),
child: DefaultTabController( );
length: categories.length, }).toList(),
key: Key(categories.toString()), actionButton: TabActionButton(
child: Column( icon: const Icon(Icons.add),
children: [ text: "Add".tl,
FilledTabBar( onPressed: addPage,
key: PageStorageKey(categories.toString()), ),
tabs: categories.map((e) { ).paddingTop(context.padding.top),
String title = e; Expanded(
try { child: TabBarView(
title = getCategoryDataWithKey(e).title; children: categories.map((e) => _CategoryPage(e)).toList(),
} catch (e) { ),
// )
} ],
return Tab( ),
text: title, ),
key: Key(e),
);
}).toList(),
).paddingTop(context.padding.top),
Expanded(
child: TabBarView(
children:
categories.map((e) => _CategoryPage(e)).toList()),
)
],
),
),
);
},
); );
} }
} }

View File

@@ -49,19 +49,19 @@ class ComicPage extends StatefulWidget {
class _ComicPageState extends LoadingState<ComicPage, ComicDetails> class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
with _ComicPageActions { with _ComicPageActions {
@override
History? history;
bool showAppbarTitle = false; bool showAppbarTitle = false;
var scrollController = ScrollController(); var scrollController = ScrollController();
bool isDownloaded = false; bool isDownloaded = false;
void updateHistory() async { @override
var newHistory = await HistoryManager() void onReadEnd() {
.find(widget.id, ComicType(widget.sourceKey.hashCode)); // The history is passed by reference, so it will be updated automatically.
if (newHistory?.ep != history?.ep || newHistory?.page != history?.page) { update();
history = newHistory;
update();
}
} }
@override @override
@@ -77,14 +77,12 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override @override
void initState() { void initState() {
scrollController.addListener(onScroll); scrollController.addListener(onScroll);
HistoryManager().addListener(updateHistory);
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
scrollController.removeListener(onScroll); scrollController.removeListener(onScroll);
HistoryManager().removeListener(updateHistory);
super.dispose(); super.dispose();
} }
@@ -206,62 +204,64 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
yield const SliverPadding(padding: EdgeInsets.only(top: 8)); yield const SliverPadding(padding: EdgeInsets.only(top: 8));
yield Row( yield SliverLazyToBoxAdapter(
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 16), children: [
Hero( const SizedBox(width: 16),
tag: "cover${comic.id}${comic.sourceKey}", Hero(
child: Container( tag: "cover${comic.id}${comic.sourceKey}",
decoration: BoxDecoration( child: Container(
color: context.colorScheme.primaryContainer, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color: context.colorScheme.primaryContainer,
boxShadow: [ borderRadius: BorderRadius.circular(8),
BoxShadow( boxShadow: [
color: context.colorScheme.outlineVariant, BoxShadow(
blurRadius: 1, color: context.colorScheme.outlineVariant,
offset: const Offset(0, 1), 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,
), ),
], ],
), ),
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,
),
],
),
),
],
).toSliver();
} }
Widget buildActions() { Widget buildActions() {
bool isMobile = context.width < changePoint; bool isMobile = context.width < changePoint;
bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1); bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1);
return SliverToBoxAdapter( return SliverLazyToBoxAdapter(
child: Column( child: Column(
children: [ children: [
ListView( ListView(
@@ -354,7 +354,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.description == null || comic.description!.trim().isEmpty) { if (comic.description == null || comic.description!.trim().isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
return SliverToBoxAdapter( return SliverLazyToBoxAdapter(
child: Column( child: Column(
children: [ children: [
ListTile( ListTile(
@@ -482,7 +482,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
bool enableTranslation = bool enableTranslation =
App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate; App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate;
return SliverToBoxAdapter( return SliverLazyToBoxAdapter(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -550,7 +550,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.chapters == null) { if (comic.chapters == null) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
return const _ComicChapters(); return _ComicChapters(history);
} }
Widget buildThumbnails() { Widget buildThumbnails() {
@@ -592,7 +592,7 @@ abstract mixin class _ComicPageActions {
ComicSource get comicSource => ComicSource.find(comic.sourceKey)!; ComicSource get comicSource => ComicSource.find(comic.sourceKey)!;
History? history; History? get history;
bool isLiking = false; bool isLiking = false;
@@ -612,8 +612,10 @@ abstract mixin class _ComicPageActions {
update(); update();
} }
/// whether the comic is added to local favorite
bool isAddToLocalFav = false; bool isAddToLocalFav = false;
/// whether the comic is favorite on the server
bool isFavorite = false; bool isFavorite = false;
FavoriteItem _toFavoriteItem() { FavoriteItem _toFavoriteItem() {
@@ -684,11 +686,13 @@ abstract mixin class _ComicPageActions {
chapters: comic.chapters, chapters: comic.chapters,
initialChapter: ep, initialChapter: ep,
initialPage: page, initialPage: page,
history: History.fromModel(model: comic, ep: 0, page: 0), history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '', author: comic.findAuthor() ?? '',
tags: comic.plainTags, tags: comic.plainTags,
), ),
); ).then((_) {
onReadEnd();
});
} }
void continueRead() { void continueRead() {
@@ -697,6 +701,8 @@ abstract mixin class _ComicPageActions {
read(ep, page); read(ep, page);
} }
void onReadEnd();
void download() async { void download() async {
if (LocalManager().isDownloading(comic.id, comic.comicType)) { if (LocalManager().isDownloading(comic.id, comic.comicType)) {
App.rootContext.showMessage(message: "The comic is downloading".tl); App.rootContext.showMessage(message: "The comic is downloading".tl);
@@ -1079,7 +1085,9 @@ class _ActionButton extends StatelessWidget {
} }
class _ComicChapters extends StatefulWidget { class _ComicChapters extends StatefulWidget {
const _ComicChapters(); const _ComicChapters(this.history);
final History? history;
@override @override
State<_ComicChapters> createState() => _ComicChaptersState(); State<_ComicChapters> createState() => _ComicChaptersState();
@@ -1092,104 +1100,133 @@ class _ComicChaptersState extends State<_ComicChapters> {
bool showAll = false; bool showAll = false;
late History? history;
@override
void initState() {
super.initState();
history = widget.history;
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!; state = context.findAncestorStateOfType<_ComicPageState>()!;
super.didChangeDependencies(); super.didChangeDependencies();
} }
@override
void didUpdateWidget(covariant _ComicChapters oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() {
history = widget.history;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final eps = state.comic.chapters!; final eps = state.comic.chapters!;
int length = eps.length; return SliverLayoutBuilder(
builder: (context, constrains) {
int length = eps.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 == eps.length) {
canShowAll = true;
}
}
if (!showAll) { return SliverMainAxisGroup(
length = math.min(length, 20); slivers: [
} SliverToBoxAdapter(
child: ListTile(
return SliverMainAxisGroup( title: Text("Chapters".tl),
slivers: [ trailing: Tooltip(
SliverToBoxAdapter( message: "Order".tl,
child: ListTile( child: IconButton(
title: Text("Chapters".tl), icon: Icon(reverse
trailing: Tooltip( ? Icons.vertical_align_top
message: "Order".tl, : Icons.vertical_align_bottom_outlined),
child: IconButton( onPressed: () {
icon: Icon(reverse setState(() {
? Icons.vertical_align_top reverse = !reverse;
: Icons.vertical_align_bottom_outlined), });
onPressed: () { },
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverGrid(
delegate:
SliverChildBuilderDelegate(childCount: length, (context, i) {
if (reverse) {
i = eps.length - i - 1;
}
var key = eps.keys.elementAt(i);
var value = eps[key]!;
bool visited =
(state.history?.readEpisode ?? const {}).contains(i + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Material(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: InkWell(
onTap: () => state.read(i + 1),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 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 (eps.length > 20 && !showAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: FilledButton.tonal(
style: ButtonStyle(
shape: WidgetStateProperty.all(const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)))),
),
onPressed: () {
setState(() {
showAll = true;
});
},
child: Text("${"Show all".tl} (${eps.length})"),
).paddingTop(12),
), ),
), SliverGrid(
const SliverToBoxAdapter( delegate: SliverChildBuilderDelegate(
child: Divider(), childCount: length,
), (context, i) {
], if (reverse) {
i = eps.length - i - 1;
}
var key = eps.keys.elementAt(i);
var value = eps[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 (eps.length > 20 && !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} (${eps.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
},
); );
} }
} }
@@ -1283,7 +1320,9 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
y2 = double.parse(r.split('-')[1]); y2 = double.parse(r.split('-')[1]);
} }
} }
} finally {} } catch (_) {
// ignore
}
part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2); part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2);
} }
return Padding( return Padding(
@@ -1297,30 +1336,29 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
child: InkWell( child: InkWell(
onTap: () => state.read(null, index + 1), onTap: () => state.read(null, index + 1),
borderRadius: borderRadius:
const BorderRadius.all(Radius.circular(16)), const BorderRadius.all(Radius.circular(8)),
child: Container( child: Container(
decoration: BoxDecoration( foregroundDecoration: BoxDecoration(
borderRadius: borderRadius: BorderRadius.circular(8),
const BorderRadius.all(Radius.circular(16)),
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.outline, color: Theme.of(context).colorScheme.outline,
), ),
), ),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
child: ClipRRect( clipBehavior: Clip.antiAlias,
borderRadius: child: AnimatedImage(
const BorderRadius.all(Radius.circular(16)), image: CachedImageProvider(
child: AnimatedImage( url,
image: CachedImageProvider( sourceKey: state.widget.sourceKey,
url,
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
part: part,
), ),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
part: part,
), ),
), ),
), ),
@@ -1336,7 +1374,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
), ),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 200,
childAspectRatio: 0.65, childAspectRatio: 0.68,
), ),
), ),
if (error != null) if (error != null)
@@ -1387,42 +1425,67 @@ class _FavoritePanel extends StatefulWidget {
State<_FavoritePanel> createState() => _FavoritePanelState(); State<_FavoritePanel> createState() => _FavoritePanelState();
} }
class _FavoritePanelState extends State<_FavoritePanel> { class _FavoritePanelState extends State<_FavoritePanel>
with SingleTickerProviderStateMixin {
late ComicSource comicSource; late ComicSource comicSource;
late TabController tabController;
late bool hasNetwork;
@override @override
void initState() { void initState() {
comicSource = widget.type.comicSource!; comicSource = widget.type.comicSource!;
localFolders = LocalFavoritesManager().folderNames; localFolders = LocalFavoritesManager().folderNames;
added = LocalFavoritesManager().find(widget.cid, widget.type); 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(); super.initState();
} }
@override
void dispose() {
var currentIndex = tabController.index;
appdata.implicitData['favoritePanelIndex'] = currentIndex;
appdata.writeImplicitData();
tabController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
return Scaffold( return Scaffold(
appBar: Appbar( appBar: Appbar(
title: Text("Favorite".tl), title: Text("Favorite".tl),
), ),
body: DefaultTabController( body: Column(
length: hasNetwork ? 2 : 1, children: [
child: Column( TabBar(
children: [ controller: tabController,
TabBar(tabs: [ tabs: [
Tab(text: "Local".tl), Tab(text: "Local".tl),
if (hasNetwork) Tab(text: "Network".tl), if (hasNetwork) Tab(text: "Network".tl),
]), ],
Expanded( ),
child: TabBarView( Expanded(
children: [ child: TabBarView(
buildLocal(), controller: tabController,
if (hasNetwork) buildNetwork(), children: [
], buildLocal(),
), if (hasNetwork) buildNetwork(),
],
), ),
], ),
), ],
), ),
); );
} }
@@ -1644,6 +1707,42 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
} }
Widget buildMultiFolder() { 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) { if (isLoadingFolders) {
loadFolders(); loadFolders();
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -1849,7 +1948,7 @@ class _CommentsPartState extends State<_CommentsPart> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiSliver( return MultiSliver(
children: [ children: [
SliverToBoxAdapter( SliverLazyToBoxAdapter(
child: ListTile( child: ListTile(
title: Text("Comments".tl), title: Text("Comments".tl),
trailing: Row( trailing: Row(
@@ -2000,6 +2099,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
} }
return Shimmer( return Shimmer(
color: context.isDarkMode ? Colors.grey.shade700 : Colors.white,
child: Column( child: Column(
children: [ children: [
Appbar(title: Text(""), backgroundColor: context.colorScheme.surface), Appbar(title: Text(""), backgroundColor: context.colorScheme.surface),

View File

@@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io' as io;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -7,11 +9,13 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/webview.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
class ComicSourcePage extends StatefulWidget { class ComicSourcePage extends StatelessWidget {
const ComicSourcePage({super.key}); const ComicSourcePage({super.key});
static Future<int> checkComicSourceUpdate() async { static Future<int> checkComicSourceUpdate() async {
@@ -19,8 +23,7 @@ class ComicSourcePage extends StatefulWidget {
return 0; return 0;
} }
var dio = AppDio(); var dio = AppDio();
var res = await dio.get<String>( var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
if (res.statusCode != 200) { if (res.statusCode != 200) {
return -1; return -1;
} }
@@ -45,11 +48,6 @@ class ComicSourcePage extends StatefulWidget {
return shouldUpdate.length; return shouldUpdate.length;
} }
@override
State<ComicSourcePage> createState() => _ComicSourcePageState();
}
class _ComicSourcePageState extends State<ComicSourcePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -93,167 +91,19 @@ class _BodyState extends State<_Body> {
style: AppbarStyle.shadow, style: AppbarStyle.shadow,
), ),
buildCard(context), buildCard(context),
for (var source in ComicSource.all()) buildSource(context, source), for (var source in ComicSource.all())
_SliverComicSource(
key: ValueKey(source.key),
source: source,
edit: edit,
update: update,
delete: delete,
),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
], ],
); );
} }
Widget buildSource(BuildContext context, ComicSource source) {
var newVersion = ComicSource.availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
return SliverToBoxAdapter(
child: Column(
children: [
const Divider(),
ListTile(
title: Row(
children: [
Text(source.name),
const SizedBox(width: 6),
if (hasUpdate)
Tooltip(
message: newVersion,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
"New Version".tl,
style: const TextStyle(fontSize: 13),
),
),
)
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: "Edit".tl,
child: IconButton(
onPressed: () => edit(source),
icon: const Icon(Icons.edit_note)),
),
Tooltip(
message: "Update".tl,
child: IconButton(
onPressed: () => update(source),
icon: const Icon(Icons.update)),
),
Tooltip(
message: "Delete".tl,
child: IconButton(
onPressed: () => delete(source),
icon: const Icon(Icons.delete)),
),
],
),
),
ListTile(
title: const Text("Version"),
subtitle: Text(source.version),
),
...buildSourceSettings(source),
],
),
);
}
Iterable<Widget> buildSourceSettings(ComicSource source) sync* {
if (source.settings == null) {
return;
} else if (source.data['settings'] == null) {
source.data['settings'] = {};
}
for (var item in source.settings!.entries) {
var key = item.key;
String type = item.value['type'];
try {
if (type == "select") {
var current = source.data['settings'][key];
if (current == null) {
var d = item.value['default'];
for (var option in item.value['options']) {
if (option['value'] == d) {
current = option['text'] ?? option['value'];
break;
}
}
} else {
current = item.value['options']
.firstWhere((e) => e['value'] == current)['text'] ??
current;
}
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Select(
current: (current as String).ts(source.key),
values: (item.value['options'] as List)
.map<String>((e) =>
((e['text'] ?? e['value']) as String).ts(source.key))
.toList(),
onTap: (i) {
source.data['settings'][key] =
item.value['options'][i]['value'];
source.saveData();
setState(() {});
},
),
);
} else if (type == "switch") {
var current = source.data['settings'][key] ?? item.value['default'];
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Switch(
value: current,
onChanged: (v) {
source.data['settings'][key] = v;
source.saveData();
setState(() {});
},
),
);
} else if (type == "input") {
var current =
source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
subtitle:
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
showInputDialog(
context: context,
title: (item.value['title'] as String).ts(source.key),
initialValue: current,
inputValidator: item.value['validator'] == null
? null
: RegExp(item.value['validator']),
onConfirm: (value) {
source.data['settings'][key] = value;
source.saveData();
setState(() {});
return null;
},
);
},
),
);
} else if (type == "callback") {
yield _CallbackSetting(setting: item);
}
} catch (e, s) {
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
}
}
}
void delete(ComicSource source) { void delete(ComicSource source) {
showConfirmDialog( showConfirmDialog(
context: App.rootContext, context: App.rootContext,
@@ -298,10 +148,12 @@ class _BodyState extends State<_Body> {
// //
} }
} }
context.to(() => _EditFilePage(source.filePath)).then((value) async { context.to(
await ComicSource.reload(); () => _EditFilePage(source.filePath, () async {
setState(() {}); await ComicSource.reload();
}); setState(() {});
}),
);
} }
static Future<void> update(ComicSource source) async { static Future<void> update(ComicSource source) async {
@@ -419,7 +271,8 @@ class _BodyState extends State<_Body> {
} }
void help() { void help() {
launchUrlString("https://github.com/venera-app/venera-configs"); launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
} }
Future<void> handleAddSource(String url) async { Future<void> handleAddSource(String url) async {
@@ -469,8 +322,7 @@ class _ComicSourceListState extends State<_ComicSourceList> {
void load() async { void load() async {
var dio = AppDio(); var dio = AppDio();
var res = await dio.get<String>( var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
if (res.statusCode != 200) { if (res.statusCode != 200) {
context.showMessage(message: "Network error".tl); context.showMessage(message: "Network error".tl);
return; return;
@@ -485,6 +337,27 @@ class _ComicSourceListState extends State<_ComicSourceList> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopUpWidgetScaffold( return PopUpWidgetScaffold(
title: "Comic Source".tl, title: "Comic Source".tl,
tailing: [
IconButton(
icon: Icon(Icons.settings),
onPressed: () async {
await showInputDialog(
context: context,
title: "Set comic source list url".tl,
initialValue: appdata.settings['comicSourceListUrl'],
onConfirm: (value) {
appdata.settings['comicSourceListUrl'] = value;
appdata.saveData();
setState(() {
loading = true;
json = null;
});
return null;
},
);
},
)
],
body: buildBody(), body: buildBody(),
); );
} }
@@ -501,18 +374,29 @@ class _ComicSourceListState extends State<_ComicSourceList> {
var key = json![index]["key"]; var key = json![index]["key"];
var action = currentKey.contains(key) var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8) ? const Icon(Icons.check, size: 20).paddingRight(8)
: Tooltip( : Button.filled(
message: "Add", child: Text("Add".tl),
child: Button.icon( onPressed: () async {
color: context.colorScheme.primary, var fileName = json![index]["fileName"];
icon: const Icon(Icons.add), var url = json![index]["url"];
onPressed: () async { if (url == null || !(url.toString()).isURL) {
await widget.onAdd( var listUrl =
"https://raw.githubusercontent.com/venera-app/venera-configs/master/${json![index]["fileName"]}"); appdata.settings['comicSourceListUrl'] as String;
setState(() {}); if (listUrl
}, .replaceFirst("https://", "")
), .replaceFirst("http://", "")
); .contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
},
).fixHeight(32);
return ListTile( return ListTile(
title: Text(json![index]["name"]), title: Text(json![index]["name"]),
@@ -597,10 +481,12 @@ void _addAllPagesWithComicSource(ComicSource source) {
} }
class _EditFilePage extends StatefulWidget { class _EditFilePage extends StatefulWidget {
const _EditFilePage(this.path); const _EditFilePage(this.path, this.onExit);
final String path; final String path;
final void Function() onExit;
@override @override
State<_EditFilePage> createState() => __EditFilePageState(); State<_EditFilePage> createState() => __EditFilePageState();
} }
@@ -617,6 +503,7 @@ class __EditFilePageState extends State<_EditFilePage> {
@override @override
void dispose() { void dispose() {
File(widget.path).writeAsStringSync(current); File(widget.path).writeAsStringSync(current);
widget.onExit();
super.dispose(); super.dispose();
} }
@@ -682,10 +569,12 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
} }
class _CallbackSetting extends StatefulWidget { class _CallbackSetting extends StatefulWidget {
const _CallbackSetting({required this.setting}); const _CallbackSetting({required this.setting, required this.sourceKey});
final MapEntry<String, Map<String, dynamic>> setting; final MapEntry<String, Map<String, dynamic>> setting;
final String sourceKey;
@override @override
State<_CallbackSetting> createState() => _CallbackSettingState(); State<_CallbackSetting> createState() => _CallbackSettingState();
} }
@@ -719,12 +608,575 @@ class _CallbackSettingState extends State<_CallbackSetting> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
title: Text(title.ts(key)), title: Text(title.ts(widget.sourceKey)),
trailing: Button.normal( trailing: Button.normal(
onPressed: onClick, onPressed: onClick,
isLoading: isLoading, isLoading: isLoading,
child: Text(buttonText.ts(key)), child: Text(buttonText.ts(widget.sourceKey)),
).fixHeight(32), ).fixHeight(32),
); );
} }
} }
class _SliverComicSource extends StatefulWidget {
const _SliverComicSource({
super.key,
required this.source,
required this.edit,
required this.update,
required this.delete,
});
final ComicSource source;
final void Function(ComicSource source) edit;
final void Function(ComicSource source) update;
final void Function(ComicSource source) delete;
@override
State<_SliverComicSource> createState() => _SliverComicSourceState();
}
class _SliverComicSourceState extends State<_SliverComicSource> {
ComicSource get source => widget.source;
@override
Widget build(BuildContext context) {
var newVersion = ComicSource.availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
return SliverMainAxisGroup(
slivers: [
SliverPadding(padding: const EdgeInsets.only(top: 16)),
SliverToBoxAdapter(
child: ListTile(
title: Row(
children: [
Text(
source.name,
style: ts.s18,
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
source.version,
style: const TextStyle(fontSize: 13),
),
),
if (hasUpdate)
Tooltip(
message: newVersion,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
"New Version".tl,
style: const TextStyle(fontSize: 13),
),
),
).paddingLeft(4)
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: "Edit".tl,
child: IconButton(
onPressed: () => widget.edit(source),
icon: const Icon(Icons.edit_note),
),
),
Tooltip(
message: "Update".tl,
child: IconButton(
onPressed: () => widget.update(source),
icon: const Icon(Icons.update),
),
),
Tooltip(
message: "Delete".tl,
child: IconButton(
onPressed: () => widget.delete(source),
icon: const Icon(Icons.delete),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
),
),
SliverToBoxAdapter(
child: Column(
children: buildSourceSettings().toList(),
),
),
SliverToBoxAdapter(
child: Column(
children: _buildAccount().toList(),
),
),
],
);
}
Iterable<Widget> buildSourceSettings() sync* {
if (source.settings == null) {
return;
} else if (source.data['settings'] == null) {
source.data['settings'] = {};
}
for (var item in source.settings!.entries) {
var key = item.key;
String type = item.value['type'];
try {
if (type == "select") {
var current = source.data['settings'][key];
if (current == null) {
var d = item.value['default'];
for (var option in item.value['options']) {
if (option['value'] == d) {
current = option['text'] ?? option['value'];
break;
}
}
} else {
current = item.value['options']
.firstWhere((e) => e['value'] == current)['text'] ??
current;
}
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Select(
current: (current as String).ts(source.key),
values: (item.value['options'] as List)
.map<String>((e) =>
((e['text'] ?? e['value']) as String).ts(source.key))
.toList(),
onTap: (i) {
source.data['settings'][key] =
item.value['options'][i]['value'];
source.saveData();
setState(() {});
},
),
);
} else if (type == "switch") {
var current = source.data['settings'][key] ?? item.value['default'];
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Switch(
value: current,
onChanged: (v) {
source.data['settings'][key] = v;
source.saveData();
setState(() {});
},
),
);
} else if (type == "input") {
var current =
source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
subtitle:
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
showInputDialog(
context: context,
title: (item.value['title'] as String).ts(source.key),
initialValue: current,
inputValidator: item.value['validator'] == null
? null
: RegExp(item.value['validator']),
onConfirm: (value) {
source.data['settings'][key] = value;
source.saveData();
setState(() {});
return null;
},
);
},
),
);
} else if (type == "callback") {
yield _CallbackSetting(setting: item, sourceKey: source.key);
}
} catch (e, s) {
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
}
}
}
final _reLogin = <String, bool>{};
Iterable<Widget> _buildAccount() sync* {
if (source.account == null) return;
final bool logged = source.isLogged;
if (!logged) {
yield ListTile(
title: Text("Log in".tl),
trailing: const Icon(Icons.arrow_right),
onTap: () async {
await context.to(
() => _LoginPage(
config: source.account!,
source: source,
),
);
source.saveData();
setState(() {});
},
);
}
if (logged) {
for (var item in source.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 (source.data["account"] is List) {
bool loading = _reLogin[source.key] == true;
yield ListTile(
title: Text("Re-login".tl),
subtitle: Text("Click if login expired".tl),
onTap: () async {
if (source.data["account"] == null) {
context.showMessage(message: "No data".tl);
return;
}
setState(() {
_reLogin[source.key] = true;
});
final List account = source.data["account"];
var res = await source.account!.login!(account[0], account[1]);
if (res.error) {
context.showMessage(message: res.errorMessage!);
} else {
context.showMessage(message: "Success".tl);
}
setState(() {
_reLogin[source.key] = false;
});
},
trailing: loading
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh),
);
}
yield ListTile(
title: Text("Log out".tl),
onTap: () {
source.data["account"] = null;
source.account?.logout();
source.saveData();
ComicSource.notifyListeners();
setState(() {});
},
trailing: const Icon(Icons.logout),
);
}
}
}
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: () {
if (App.isLinux) {
loginWithWebview2();
} else {
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();
}
}
// for linux
void loginWithWebview2() async {
if (!await DesktopWebview.isAvailable()) {
context.showMessage(message: "Webview is not available".tl);
}
var url = widget.config.loginWebsite!;
var title = '';
bool success = false;
void onClose() {
if (success) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
}
}
void validate(DesktopWebview webview) async {
if (widget.config.checkLoginStatus != null &&
widget.config.checkLoginStatus!(url, title)) {
var cookiesMap = await webview.getCookies(url);
var cookies = <io.Cookie>[];
cookiesMap.forEach((key, value) {
cookies.add(io.Cookie(key, value));
});
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
widget.config.onLoginWithWebviewSuccess?.call();
webview.close();
onClose();
}
}
var webview = DesktopWebview(
initialUrl: widget.config.loginWebsite!,
onTitleChange: (t, webview) {
title = t;
validate(webview);
},
onNavigation: (u, webview) {
url = u;
validate(webview);
},
onClose: onClose,
);
webview.open();
}
}

View File

@@ -42,7 +42,7 @@ class _CommentsPageState extends State<CommentsPage> {
_error = res.errorMessage; _error = res.errorMessage;
_loading = false; _loading = false;
}); });
} else { } else if (mounted) {
setState(() { setState(() {
_comments = res.data; _comments = res.data;
_loading = false; _loading = false;
@@ -73,6 +73,7 @@ class _CommentsPageState extends State<CommentsPage> {
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: Appbar( appBar: Appbar(
title: Text("Comments".tl), title: Text("Comments".tl),
style: AppbarStyle.shadow,
), ),
body: buildBody(context), body: buildBody(context),
); );
@@ -529,6 +530,7 @@ class _Tag {
'u' => style.underline, 'u' => style.underline,
's' => style.lineThrough, 's' => style.lineThrough,
'a' => style.withColor(context.colorScheme.primary), 'a' => style.withColor(context.colorScheme.primary),
'strong' => style.bold,
'span' => () { 'span' => () {
if (attributes.containsKey('style')) { if (attributes.containsKey('style')) {
var s = attributes['style']!; var s = attributes['style']!;
@@ -622,10 +624,14 @@ class RichCommentContent extends StatefulWidget {
class _RichCommentContentState extends State<RichCommentContent> { class _RichCommentContentState extends State<RichCommentContent> {
var textSpan = <InlineSpan>[]; var textSpan = <InlineSpan>[];
var images = <_CommentImage>[]; var images = <_CommentImage>[];
bool isRendered = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
render(); if (!isRendered) {
render();
isRendered = true;
}
super.didChangeDependencies(); super.didChangeDependencies();
} }
@@ -670,7 +676,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', ''); 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)) { if (acceptedTags.contains(tagName)) {
writeBuffer(); writeBuffer();
if (tagName == 'img') { if (tagName == 'img') {

View File

@@ -46,6 +46,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
i--; i--;
return _DownloadTaskTile( return _DownloadTaskTile(
key: ValueKey(LocalManager().downloadingTasks[i]),
task: LocalManager().downloadingTasks[i], task: LocalManager().downloadingTasks[i],
); );
}, },
@@ -120,7 +121,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
} }
class _DownloadTaskTile extends StatefulWidget { class _DownloadTaskTile extends StatefulWidget {
const _DownloadTaskTile({required this.task}); const _DownloadTaskTile({required this.task, super.key});
final DownloadTask task; final DownloadTask task;
@@ -129,20 +130,33 @@ class _DownloadTaskTile extends StatefulWidget {
} }
class _DownloadTaskTileState extends State<_DownloadTaskTile> { class _DownloadTaskTileState extends State<_DownloadTaskTile> {
late DownloadTask task;
@override @override
void initState() { void initState() {
widget.task.addListener(update); task = widget.task;
task.addListener(update);
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
widget.task.removeListener(update); task.removeListener(update);
super.dispose(); 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() { void update() {
context.findAncestorStateOfType<_DownloadingPageState>()?.update(); setState(() {});
} }
@override @override

View File

@@ -5,7 +5,9 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/foundation/state_controller.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/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -35,7 +37,7 @@ class _ExplorePageState extends State<ExplorePage>
.expand((e) => e.map((e) => e.title)) .expand((e) => e.map((e) => e.title))
.toList(); .toList();
explorePages = explorePages.where((e) => all.contains(e)).toList(); explorePages = explorePages.where((e) => all.contains(e)).toList();
if (!pages.isEqualsTo(explorePages)) { if (!pages.isEqualTo(explorePages)) {
setState(() { setState(() {
pages = explorePages; pages = explorePages;
controller = TabController( controller = TabController(
@@ -56,6 +58,10 @@ class _ExplorePageState extends State<ExplorePage>
} }
} }
void addPage() {
showPopUpWidget(App.rootContext, setExplorePagesWidget());
}
NaviPaneState? naviPane; NaviPaneState? naviPane;
@override @override
@@ -117,26 +123,21 @@ class _ExplorePageState extends State<ExplorePage>
Widget buildEmpty() { Widget buildEmpty() {
var msg = "No Explore Pages".tl; var msg = "No Explore Pages".tl;
msg += '\n'; msg += '\n';
VoidCallback onTap;
if (ComicSource.isEmpty) { if (ComicSource.isEmpty) {
msg += "Add a comic source in home page".tl; msg += "Please add some sources".tl;
onTap = () {
context.to(() => ComicSourcePage());
};
} else { } else {
msg += "Please check your settings".tl; msg += "Please check your settings".tl;
onTap = addPage;
} }
return NetworkError( return NetworkError(
message: msg, message: msg,
retry: () { retry: onTap,
setState(() {
pages = ComicSource.all()
.map((e) => e.explorePages)
.expand((e) => e.map((e) => e.title))
.toList();
controller = TabController(
length: pages.length,
vsync: this,
);
});
},
withAppbar: false, withAppbar: false,
buttonText: "Manage".tl,
); );
} }
@@ -148,10 +149,15 @@ class _ExplorePageState extends State<ExplorePage>
} }
Widget tabBar = Material( Widget tabBar = Material(
child: FilledTabBar( child: AppTabBar(
key: PageStorageKey(pages.toString()), key: PageStorageKey(pages.toString()),
tabs: pages.map((e) => buildTab(e)).toList(), tabs: pages.map((e) => buildTab(e)).toList(),
controller: controller, controller: controller,
actionButton: TabActionButton(
icon: const Icon(Icons.add),
text: "Add".tl,
onPressed: addPage,
),
), ),
).paddingTop(context.padding.top); ).paddingTop(context.padding.top);

View File

@@ -16,6 +16,7 @@ import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';

View File

@@ -476,55 +476,47 @@ class _CreateFolderDialogState extends State<_CreateFolderDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SimpleDialog( return ContentDialog(
title: Text("Create a folder".tl), title: "Create a folder".tl,
children: [ content: Column(
Padding( children: [
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), Padding(
child: TextField( padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
controller: controller, child: TextField(
decoration: InputDecoration( controller: controller,
border: const OutlineInputBorder(), decoration: InputDecoration(
labelText: "name".tl, 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),
), ),
), ),
) ),
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>[]; 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 @override
void initState() { void initState() {
favPage = widget.favPage ?? favPage = widget.favPage ??
context.findAncestorStateOfType<_FavoritesPageState>()!; context.findAncestorStateOfType<_FavoritesPageState>()!;
favPage.folderList = this; favPage.folderList = this;
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all() findNetworkFolders();
.where((e) => e.favoriteData != null && e.isLogged) appdata.settings.addListener(updateFolders);
.map((e) => e.favoriteData!.key)
.toList();
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
appdata.settings.removeListener(updateFolders);
} }
@override @override
@@ -102,7 +115,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
onClick: () { onClick: () {
newFolder().then((value) { newFolder().then((value) {
setState(() { setState(() {
folders = LocalFavoritesManager().folderNames; folders =
LocalFavoritesManager().folderNames;
}); });
}); });
}, },
@@ -113,7 +127,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
onClick: () { onClick: () {
sortFolders().then((value) { sortFolders().then((value) {
setState(() { setState(() {
folders = LocalFavoritesManager().folderNames; folders =
LocalFavoritesManager().folderNames;
}); });
}); });
}, },
@@ -143,15 +158,24 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
), ),
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 16),
Icon( Icon(
Icons.cloud, Icons.cloud,
color: context.colorScheme.secondary, color: context.colorScheme.secondary,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text("Network".tl), Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
], ],
), ).paddingHorizontal(16),
); );
} }
index--; index--;
@@ -241,10 +265,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all() findNetworkFolders();
.where((e) => e.favoriteData != null)
.map((e) => e.favoriteData!.key)
.toList();
}); });
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -8,7 +9,6 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/downloading_page.dart';
@@ -35,7 +35,6 @@ class HomePage extends StatelessWidget {
const _History(), const _History(),
const _Local(), const _Local(),
const _ComicSourceWidget(), const _ComicSourceWidget(),
const _AccountsWidget(),
const ImageFavorites(), const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
], ],
@@ -535,38 +534,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
], ],
), ),
onPressed: () { onPressed: () {
showDialog( launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
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();
},
),
],
);
},
);
}, },
).fixWidth(90).paddingRight(8), ).fixWidth(90).paddingRight(8),
Button.filled( Button.filled(
@@ -728,115 +696,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 { class _AnimatedDownloadingIcon extends StatefulWidget {
const _AnimatedDownloadingIcon(); const _AnimatedDownloadingIcon();

View File

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

View File

@@ -12,6 +12,7 @@ import 'package:venera/utils/epub.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart'; import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:zip_flutter/zip_flutter.dart';
class LocalComicsPage extends StatefulWidget { class LocalComicsPage extends StatefulWidget {
const LocalComicsPage({super.key}); const LocalComicsPage({super.key});
@@ -147,13 +148,13 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
text: "View Detail".tl, text: "View Detail".tl,
onClick: () { onClick: () {
context.to(() => ComicPage( context.to(() => ComicPage(
id: selectedComics.keys.first.id, id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey, sourceKey: selectedComics.keys.first.sourceKey,
)); ));
}, },
), ),
if (selectedComics.length == 1) if (selectedComics.isNotEmpty)
...exportActions(selectedComics.keys.first), ...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; return isDeleted;
} }
List<MenuEntry> exportActions(LocalComic c) { List<MenuEntry> exportActions(List<LocalComic> comics) {
return [ return [
MenuEntry( MenuEntry(
icon: Icons.outbox_outlined, icon: Icons.outbox_outlined,
text: "Export as cbz".tl, text: "Export as cbz".tl,
onClick: () async { onClick: () {
var controller = showLoadingDialog( exportComics(comics, CBZ.export, ".cbz");
context, },
allowCancel: false, ),
);
try {
var file = await CBZ.export(c);
await saveFile(filename: file.name, file: file);
await file.delete();
} catch (e, s) {
context.showMessage(message: e.toString());
Log.error("CBZ Export", e, s);
}
controller.close();
}),
MenuEntry( MenuEntry(
icon: Icons.picture_as_pdf_outlined, icon: Icons.picture_as_pdf_outlined,
text: "Export as pdf".tl, text: "Export as pdf".tl,
onClick: () async { onClick: () async {
var cache = FilePath.join(App.cachePath, 'temp.pdf'); exportComics(comics, createPdfFromComicIsolate, ".pdf");
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
await createPdfFromComicIsolate(
comic: c,
savePath: cache,
);
await saveFile(
file: File(cache),
filename: "${c.title}.pdf",
);
} catch (e, s) {
Log.error("PDF Export", e, s);
context.showMessage(message: e.toString());
} finally {
controller.close();
File(cache).deleteIgnoreError();
}
}, },
), ),
MenuEntry( MenuEntry(
icon: Icons.import_contacts_outlined, icon: Icons.import_contacts_outlined,
text: "Export as epub".tl, text: "Export as epub".tl,
onClick: () async { onClick: () async {
var controller = showLoadingDialog( exportComics(comics, createEpubWithLocalComic, ".epub");
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();
}
}, },
) )
]; ];
} }
/// 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

@@ -24,6 +24,8 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
int fingers = 0; int fingers = 0;
late _ReaderState reader;
@override @override
void initState() { void initState() {
_tapGestureRecognizer = TapGestureRecognizer() _tapGestureRecognizer = TapGestureRecognizer()
@@ -33,6 +35,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
}; };
super.initState(); super.initState();
context.readerScaffold._gestureDetectorState = this; context.readerScaffold._gestureDetectorState = this;
reader = context.reader;
} }
@override @override
@@ -166,7 +169,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
} }
void onTap(Offset location) { void onTap(Offset location) {
if (context.readerScaffold.isOpen) { if (reader._imageViewController!.handleOnTap(location)) {
return;
} else if (context.readerScaffold.isOpen) {
context.readerScaffold.openOrClose(); context.readerScaffold.openOrClose();
} else { } else {
if (appdata.settings['enableTapToTurnPages']) { if (appdata.settings['enableTapToTurnPages']) {
@@ -186,31 +191,37 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
isBottom = true; isBottom = true;
} }
bool isCenter = false; 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) { switch (context.reader.mode) {
case ReaderMode.galleryLeftToRight: case ReaderMode.galleryLeftToRight:
case ReaderMode.continuousLeftToRight: case ReaderMode.continuousLeftToRight:
if (isLeft) { if (isLeft) {
context.reader.toPrevPage(); prev();
} else if (isRight) { } else if (isRight) {
context.reader.toNextPage(); next();
} else { } else {
isCenter = true; isCenter = true;
} }
case ReaderMode.galleryRightToLeft: case ReaderMode.galleryRightToLeft:
case ReaderMode.continuousRightToLeft: case ReaderMode.continuousRightToLeft:
if (isLeft) { if (isLeft) {
context.reader.toNextPage(); next();
} else if (isRight) { } else if (isRight) {
context.reader.toPrevPage(); prev();
} else { } else {
isCenter = true; isCenter = true;
} }
case ReaderMode.galleryTopToBottom: case ReaderMode.galleryTopToBottom:
case ReaderMode.continuousTopToBottom: case ReaderMode.continuousTopToBottom:
if (isTop) { if (isTop) {
context.reader.toPrevPage(); prev();
} else if (isBottom) { } else if (isBottom) {
context.reader.toNextPage(); next();
} else { } else {
isCenter = true; isCenter = true;
} }

View File

@@ -111,9 +111,7 @@ class _GalleryModeState extends State<_GalleryMode>
late _ReaderState reader; late _ReaderState reader;
int get totalPages => ((reader.images!.length + reader.imagesPerPage - 1) / int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
reader.imagesPerPage)
.ceil();
@override @override
void initState() { void initState() {
@@ -228,6 +226,8 @@ class _GalleryModeState extends State<_GalleryMode>
? Axis.vertical ? Axis.vertical
: Axis.horizontal; : Axis.horizontal;
bool reverse = reader.mode == ReaderMode.galleryRightToLeft;
List<Widget> imageWidgets = images.map((imageKey) { List<Widget> imageWidgets = images.map((imageKey) {
ImageProvider imageProvider = ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context); _createImageProviderFromKey(imageKey, context);
@@ -239,6 +239,10 @@ class _GalleryModeState extends State<_GalleryMode>
); );
}).toList(); }).toList();
if (reverse) {
imageWidgets = imageWidgets.reversed.toList();
}
return axis == Axis.vertical return axis == Axis.vertical
? Column(children: imageWidgets) ? Column(children: imageWidgets)
: Row(children: imageWidgets); : Row(children: imageWidgets);
@@ -331,6 +335,11 @@ class _GalleryModeState extends State<_GalleryMode>
} }
} }
} }
@override
bool handleOnTap(Offset location) {
return false;
}
} }
const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
@@ -362,6 +371,18 @@ class _ContinuousModeState extends State<_ContinuousMode>
var fingers = 0; var fingers = 0;
bool disableScroll = false; bool disableScroll = false;
/// 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,
);
}
@override @override
void initState() { void initState() {
reader = context.reader; reader = context.reader;
@@ -370,6 +391,12 @@ class _ContinuousModeState extends State<_ContinuousMode>
super.initState(); super.initState();
} }
@override
void dispose() {
itemPositionsListener.itemPositions.removeListener(onPositionChanged);
super.dispose();
}
void onPositionChanged() { void onPositionChanged() {
var page = itemPositionsListener.itemPositions.value.first.index; var page = itemPositionsListener.itemPositions.value.first.index;
page = page.clamp(1, reader.maxPage); page = page.clamp(1, reader.maxPage);
@@ -485,6 +512,14 @@ class _ContinuousModeState extends State<_ContinuousMode>
}); });
} }
}, },
onPointerCancel: (event) {
fingers--;
if (fingers <= 1 && disableScroll) {
setState(() {
disableScroll = false;
});
}
},
onPointerPanZoomUpdate: (event) { onPointerPanZoomUpdate: (event) {
if (event.scale == 1.0) { if (event.scale == 1.0) {
smoothTo(0 - event.panDelta.dy); smoothTo(0 - event.panDelta.dy);
@@ -512,8 +547,14 @@ class _ContinuousModeState extends State<_ContinuousMode>
child: widget, child: widget,
); );
widget = NotificationListener<ScrollUpdateNotification>( widget = NotificationListener<ScrollNotification>(
onNotification: (notification) { onNotification: (notification) {
if (notification is ScrollStartNotification) {
delayedSetIsScrolling(true);
} else if (notification is ScrollEndNotification) {
delayedSetIsScrolling(false);
}
var length = reader.maxChapter; var length = reader.maxChapter;
if (!scrollController.hasClients) return false; if (!scrollController.hasClients) return false;
if (scrollController.position.pixels <= if (scrollController.position.pixels <=
@@ -588,7 +629,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override @override
void handleLongPressDown(Offset location) { void handleLongPressDown(Offset location) {
if (!appdata.settings['enableLongPressToZoom']) { if (!appdata.settings['enableLongPressToZoom'] || delayedIsScrolling) {
return; return;
} }
double target = photoViewController.getInitialScale!.call()! * 1.75; double target = photoViewController.getInitialScale!.call()! * 1.75;
@@ -663,6 +704,14 @@ class _ContinuousModeState extends State<_ContinuousMode>
); );
} }
} }
@override
bool handleOnTap(Offset location) {
if (delayedIsScrolling) {
return true;
}
return false;
}
} }
ImageProvider _createImageProviderFromKey( ImageProvider _createImageProviderFromKey(

View File

@@ -98,8 +98,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} }
@override @override
int get maxPage => int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
((images?.length ?? 1) + imagesPerPage - 1) ~/ imagesPerPage;
ComicType get type => widget.type; ComicType get type => widget.type;
@@ -238,6 +237,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
history!.maxPage = maxPage; history!.maxPage = maxPage;
} }
history!.readEpisode.add(chapter); history!.readEpisode.add(chapter);
print(history!.readEpisode);
history!.time = DateTime.now(); history!.time = DateTime.now();
HistoryManager().addHistory(history!); HistoryManager().addHistory(history!);
} }
@@ -431,4 +431,7 @@ abstract interface class _ImageViewController {
void handleLongPressUp(Offset location); void handleLongPressUp(Offset location);
void handleKeyEvent(KeyEvent event); void handleKeyEvent(KeyEvent event);
/// Returns true if the event is handled.
bool handleOnTap(Offset location);
} }

View File

@@ -48,7 +48,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
var readerMode = context.reader.mode; var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) { if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1; showFloatingButtonValue = 1;
_floatingButtonDragListener = _DragListener( _floatingButtonDragListener = _DragListener(
onMove: (offset) { onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) { if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy; fABValue.value -= offset.dy;
@@ -660,12 +660,16 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
App.rootContext.pop(); App.rootContext.pop();
}, },
child: Container( child: Container(
decoration: BoxDecoration( foregroundDecoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.outline, color: Theme.of(context).colorScheme.outline,
), ),
), ),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
child: Image( child: Image(
@@ -845,6 +849,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
late int _batteryLevel = 100; late int _batteryLevel = 100;
Timer? _timer; Timer? _timer;
bool _hasBattery = false; bool _hasBattery = false;
BatteryState state = BatteryState.unknown;
@override @override
void initState() { void initState() {
@@ -856,29 +861,23 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
void _checkBatteryAvailability() async { void _checkBatteryAvailability() async {
try { try {
_batteryLevel = await _battery.batteryLevel; _batteryLevel = await _battery.batteryLevel;
if (_batteryLevel != -1) { state = await _battery.batteryState;
if (_batteryLevel > 0 && state != BatteryState.unknown) {
setState(() { setState(() {
_hasBattery = true; _hasBattery = true;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) { });
_battery.batteryLevel.then((level) => { _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_batteryLevel != level) _battery.batteryLevel.then((level) {
{ if (_batteryLevel != level) {
setState(() { setState(() {
_batteryLevel = level; _batteryLevel = level;
}) });
} }
});
}); });
}); });
} else {
setState(() {
_hasBattery = false;
});
} }
} catch (e) { } catch (_) {
setState(() { // ignore
_hasBattery = false;
});
} }
} }
@@ -900,7 +899,9 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
IconData batteryIcon; IconData batteryIcon;
Color batteryColor = context.colorScheme.onSurface; 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; batteryIcon = Icons.battery_full_sharp;
} else if (batteryLevel >= 84) { } else if (batteryLevel >= 84) {
batteryIcon = Icons.battery_6_bar_sharp; batteryIcon = Icons.battery_6_bar_sharp;

View File

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

View File

@@ -116,13 +116,13 @@ class _SearchResultPageState extends State<SearchResultPage> {
@override @override
void initState() { void initState() {
sourceKey = widget.sourceKey; sourceKey = widget.sourceKey;
text = checkAutoLanguage(widget.text);
controller = SearchBarController( controller = SearchBarController(
currentText: checkAutoLanguage(widget.text), currentText: text,
onSearch: search, onSearch: search,
); );
options = widget.options ?? const []; options = widget.options ?? const [];
validateOptions(); validateOptions();
text = widget.text;
appdata.addSearchHistory(text); appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller); suggestionsController = _SuggestionsController(controller);
super.initState(); super.initState();
@@ -187,7 +187,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
suggestionsController.remove(); suggestionsController.remove();
} }
var previousOptions = options; var previousOptions = List<String>.from(options);
var previousSourceKey = sourceKey; var previousSourceKey = sourceKey;
await showDialog( await showDialog(
context: context, context: context,
@@ -196,7 +196,8 @@ class _SearchResultPageState extends State<SearchResultPage> {
return _SearchSettingsDialog(state: this); return _SearchSettingsDialog(state: this);
}, },
); );
if (previousOptions != options || previousSourceKey != sourceKey) { if (!previousOptions.isEqualTo(options) ||
previousSourceKey != sourceKey) {
text = checkAutoLanguage(controller.text); text = checkAutoLanguage(controller.text);
controller.currentText = text; controller.currentText = text;
setState(() {}); setState(() {});

View File

@@ -86,7 +86,7 @@ class _AboutSettingsState extends State<AboutSettings> {
Future<bool> checkUpdate() async { Future<bool> checkUpdate() async {
var res = await AppDio().get( var res = await AppDio().get(
"https://raw.githubusercontent.com/venera-app/venera/refs/heads/master/pubspec.yaml"); "https://cdn.jsdelivr.net/gh/venera-app/venera@latest/pubspec.yaml");
if (res.statusCode == 200) { if (res.statusCode == 200) {
var data = loadYaml(res.data); var data = loadYaml(res.data);
if (data["version"] != null) { if (data["version"] != null) {

View File

@@ -107,7 +107,7 @@ class _AppSettingsState extends State<AppSettings> {
actionTitle: 'Export'.tl, actionTitle: 'Export'.tl,
).toSliver(), ).toSliver(),
_CallbackSetting( _CallbackSetting(
title: "Import App Data (Please restart after success)".tl, title: "Import App Data".tl,
callback: () async { callback: () async {
var controller = showLoadingDialog(context); var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera', 'picadata']); var file = await selectFile(ext: ['venera', 'picadata']);
@@ -126,6 +126,7 @@ class _AppSettingsState extends State<AppSettings> {
context.showMessage(message: "Failed to import data".tl); context.showMessage(message: "Failed to import data".tl);
} finally { } finally {
cacheFile.deleteIgnoreError(); cacheFile.deleteIgnoreError();
App.forceRebuild();
} }
} }
controller.close(); controller.close();

View File

@@ -30,51 +30,19 @@ class _ExploreSettingsState extends State<ExploreSettings> {
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Explore Pages".tl, title: "Explore Pages".tl,
builder: () { builder: setExplorePagesWidget,
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,
);
},
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Category Pages".tl, title: "Category Pages".tl,
builder: () { builder: 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,
);
},
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Network Favorite Pages".tl, title: "Network Favorite Pages".tl,
builder: () { builder: setFavoritesPagesWidget,
var pages = <String, String>{}; ).toSliver(),
for (var c in ComicSource.all()) { _PopupWindowSetting(
if (c.favoriteData != null) { title: "Search Sources".tl,
pages[c.favoriteData!.key] = c.favoriteData!.title; builder: setSearchSourcesWidget,
}
}
return _MultiPagesFilter(
title: "Network Favorite Pages".tl,
settingsIndex: "favorites",
pages: pages,
);
},
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Show favorite status on comic tile".tl, title: "Show favorite status on comic tile".tl,
@@ -132,8 +100,9 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
return PopUpWidgetScaffold( return PopUpWidgetScaffold(
title: "Keyword blocking".tl, title: "Keyword blocking".tl,
tailing: [ tailing: [
IconButton( TextButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: Text("Add".tl),
onPressed: add, onPressed: add,
), ),
], ],
@@ -159,7 +128,6 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
void add() { void add() {
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
barrierColor: Colors.black.toOpacity(0.1),
builder: (context) { builder: (context) {
var controller = TextEditingController(); var controller = TextEditingController();
String? error; 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"); widget.onChanged?.call("enableTapToTurnPages");
}, },
).toSliver(), ).toSliver(),
_SwitchSetting(
title: "Reverse tap to turn Pages".tl,
settingKey: "reverseTapToTurnPages",
onChanged: () {
widget.onChanged?.call("reverseTapToTurnPages");
},
).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Page animation".tl, title: "Page animation".tl,
settingKey: "enablePageAnimation", settingKey: "enablePageAnimation",

View File

@@ -376,6 +376,14 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
super.initState(); super.initState();
} }
@override
void dispose() {
super.dispose();
Future.microtask(() {
updateSetting();
});
}
var reorderWidgetKey = UniqueKey(); var reorderWidgetKey = UniqueKey();
var scrollController = ScrollController(); var scrollController = ScrollController();
final _key = GlobalKey(); final _key = GlobalKey();
@@ -404,7 +412,6 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
setState(() { setState(() {
keys = List.from(reorderFunc(keys)); keys = List.from(reorderFunc(keys));
}); });
updateSetting();
}, },
children: tiles, children: tiles,
builder: (children) { builder: (children) {
@@ -424,7 +431,11 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
title: widget.title, title: widget.title,
tailing: [ tailing: [
if (keys.length < widget.pages.length) 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, body: view,
); );
@@ -438,9 +449,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
setState(() { setState(() {
keys.remove(key); keys.remove(key);
}); });
updateSetting();
}, },
icon: const Icon(Icons.delete)), icon: const Icon(Icons.delete_outline)),
); );
return ListTile( return ListTile(
@@ -463,30 +473,68 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
canAdd[key] = value; canAdd[key] = value;
} }
}); });
var selected = <String>[];
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return ContentDialog( return StatefulBuilder(builder: (context, setState) {
title: "Add".tl, return ContentDialog(
content: Column( title: "Add".tl,
mainAxisSize: MainAxisSize.min, content: Column(
children: canAdd.entries mainAxisSize: MainAxisSize.min,
.map( children: canAdd.entries
(e) => ListTile( .map(
title: Text(e.value), (e) => CheckboxListTile(
key: Key(e.key), value: selected.contains(e.key),
onTap: () { title: Text(e.value),
context.pop(); key: Key(e.key),
setState(() { onChanged: (value) {
keys.add(e.key); setState(() {
}); if (value!) {
updateSetting(); 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()) Expanded(
], child: AnimatedSwitcher(
); duration: const Duration(milliseconds: 200),
} else { transitionBuilder: (child, animation) {
return Stack( return LayoutBuilder(
children: [ builder: (context, constrains) {
Positioned.fill(child: buildLeft()), return AnimatedBuilder(
Positioned( animation: animation,
left: offset, builder: (context, _) {
right: 0, var width = constrains.maxWidth;
top: 0, var value = animation.isForwardOrCompleted
bottom: 0, ? 1 - animation.value
child: Listener( : 1;
onPointerDown: handlePointerDown, var left = width * value;
child: AnimatedSwitcher( return Stack(
duration: const Duration(milliseconds: 300), children: [
reverseDuration: const Duration(milliseconds: 300), Positioned(
switchInCurve: Curves.fastOutSlowIn, top: 0,
switchOutCurve: Curves.fastOutSlowIn, bottom: 0,
transitionBuilder: (child, animation) { left: left,
var tween = Tween<Offset>( width: width,
begin: const Offset(1, 0), end: const Offset(0, 0)); child: child,
),
return SlideTransition( ],
position: tween.animate(animation), );
child: child, },
); );
}, },
child: currentPage == -1 );
? const SizedBox( },
key: Key("1"), child: buildRight(),
)
: 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() { Widget buildRight() {
final Widget body = switch (currentPage) { return switch (currentPage) {
-1 => const SizedBox(), -1 => const SizedBox(),
0 => const ExploreSettings(), 0 => const ExploreSettings(),
1 => const ReaderSettings(), 1 => const ReaderSettings(),
@@ -318,10 +352,6 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
6 => const AboutSettings(), 6 => const AboutSettings(),
_ => throw UnimplementedError() _ => throw UnimplementedError()
}; };
return Material(
child: body,
);
} }
var canPop = ValueNotifier(true); var canPop = ValueNotifier(true);

View File

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

View File

@@ -85,6 +85,10 @@ abstract class CBZ {
if (cache.existsSync()) cache.deleteSync(recursive: true); if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync(); cache.createSync();
await extractArchive(file, cache); 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')); var metaDataFile = File(FilePath.join(cache.path, 'metadata.json'));
ComicMetaData? metaData; ComicMetaData? metaData;
if (metaDataFile.existsSync()) { if (metaDataFile.existsSync()) {
@@ -111,7 +115,17 @@ abstract class CBZ {
cache.deleteSync(recursive: true); cache.deleteSync(recursive: true);
throw Exception('No images found in the archive'); 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( var coverFile = files.firstWhereOrNull(
(element) => (element) =>
element.path.endsWith('cover.${element.path.split('.').last}'), element.path.endsWith('cover.${element.path.split('.').last}'),
@@ -171,7 +185,7 @@ abstract class CBZ {
return comic; 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')); var cache = Directory(FilePath.join(App.cachePath, 'cbz_export'));
if (cache.existsSync()) cache.deleteSync(recursive: true); if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync(); cache.createSync();
@@ -230,7 +244,7 @@ abstract class CBZ {
).toJson(), ).toJson(),
), ),
); );
var cbz = File(FilePath.join(App.cachePath, sanitizeFileName('${comic.title}.cbz'))); var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync(); if (cbz.existsSync()) cbz.deleteSync();
await _compress(cache.path, cbz.path); await _compress(cache.path, cbz.path);
cache.deleteSync(recursive: true); cache.deleteSync(recursive: true);

View File

@@ -55,7 +55,7 @@ class DataSync with ChangeNotifier {
} }
Future<Res<bool>> uploadData() async { Future<Res<bool>> uploadData() async {
if(isDownloading) return const Res(true); if (isDownloading) return const Res(true);
if (haveWaitingTask) return const Res(true); if (haveWaitingTask) return const Res(true);
while (isUploading) { while (isUploading) {
haveWaitingTask = true; haveWaitingTask = true;
@@ -109,7 +109,7 @@ class DataSync with ChangeNotifier {
filename += '.venera'; filename += '.venera';
var files = await client.readDir('/'); var files = await client.readDir('/');
files = files.where((e) => e.name!.endsWith('.venera')).toList(); 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) { if (old != null) {
await client.remove(old.name!); await client.remove(old.name!);
} }
@@ -118,6 +118,7 @@ class DataSync with ChangeNotifier {
await client.remove(files.first.name!); await client.remove(files.first.name!);
} }
await client.write(filename, await data.readAsBytes()); await client.write(filename, await data.readAsBytes());
data.deleteIgnoreError();
Log.info("Upload Data", "Data uploaded successfully"); Log.info("Upload Data", "Data uploaded successfully");
return const Res(true); return const Res(true);
} catch (e, s) { } catch (e, s) {
@@ -176,8 +177,11 @@ class DataSync with ChangeNotifier {
var files = await client.readDir('/'); var files = await client.readDir('/');
files.sort((a, b) => b.name!.compareTo(a.name!)); files.sort((a, b) => b.name!.compareTo(a.name!));
var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera')); var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera'));
if (file == null) {
throw 'No data file found';
}
var version = var version =
file!.name!.split('-').elementAtOrNull(1)?.split('.').first; file.name!.split('-').elementAtOrNull(1)?.split('.').first;
if (version != null && int.tryParse(version) != null) { if (version != null && int.tryParse(version) != null) {
var currentVersion = appdata.settings['dataVersion']; var currentVersion = appdata.settings['dataVersion'];
if (currentVersion != null && int.parse(version) <= currentVersion) { if (currentVersion != null && int.parse(version) <= currentVersion) {

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')); final workingDir = Directory(FilePath.join(cacheDir, 'epub'));
if (workingDir.existsSync()) { if (workingDir.existsSync()) {
workingDir.deleteSync(recursive: true); workingDir.deleteSync(recursive: true);
@@ -109,8 +110,7 @@ ${images.map((e) => ' <img src="$e" alt="$e"/>').join('\n')}
} }
// content.opf // content.opf
final contentOpf = final contentOpf = File(FilePath.join(workingDir.path, 'content.opf'));
File(FilePath.join(workingDir.path, 'content.opf'));
final uuid = const Uuid().v4(); final uuid = const Uuid().v4();
var spineStrBuilder = StringBuffer(); var spineStrBuilder = StringBuffer();
for (var i = 0; i < chapterIndex; i++) { for (var i = 0; i < chapterIndex; i++) {
@@ -171,16 +171,15 @@ ${navMapStrBuilder.toString()}
</ncx> </ncx>
'''); ''');
// zip ZipFile.compressFolder(workingDir.path, outFilePath);
final zipPath = FilePath.join(cacheDir, '${data.title}.epub');
ZipFile.compressFolder(workingDir.path, zipPath);
workingDir.deleteSync(recursive: true); 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>>{}; var chapters = <String, List<File>>{};
if (comic.chapters == null) { if (comic.chapters == null) {
chapters[comic.title] = chapters[comic.title] =
@@ -188,11 +187,11 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
.map((e) => File(e)) .map((e) => File(e))
.toList(); .toList();
} else { } else {
for (var chapter in comic.chapters!.keys) { for (var chapter in comic.downloadedChapters) {
chapters[comic.chapters![chapter]!] = (await LocalManager() chapters[comic.chapters![chapter]!] =
.getImages(comic.id, comic.comicType, chapter)) (await LocalManager().getImages(comic.id, comic.comicType, chapter))
.map((e) => File(e)) .map((e) => File(e))
.toList(); .toList();
} }
} }
var data = EpubData( var data = EpubData(
@@ -205,6 +204,6 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
final cacheDir = App.cachePath; final cacheDir = App.cachePath;
return Isolate.run(() => overrideIO(() async { 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){ if(length != list.length){
return false; return false;
} }
@@ -81,10 +83,6 @@ extension StringExt on String{
return '$before$to$after'; return '$before$to$after';
} }
static bool hasMatch(String? value, String pattern) {
return (value == null) ? false : RegExp(pattern).hasMatch(value);
}
bool _isURL(){ bool _isURL(){
final regex = RegExp( final regex = RegExp(
r'^((http|https|ftp)://)[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-|]*[\w@?^=%&/~+#-])?$', r'^((http|https|ftp)://)[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-|]*[\w@?^=%&/~+#-])?$',

View File

@@ -26,7 +26,7 @@ class Image {
var codec = await ui.instantiateImageCodec(data); var codec = await ui.instantiateImageCodec(data);
var frame = await codec.getNextFrame(); var frame = await codec.getNextFrame();
codec.dispose(); codec.dispose();
var info = await frame.image.toByteData(); var info = await frame.image.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
if (info == null) { if (info == null) {
throw Exception('Failed to decode image'); throw Exception('Failed to decode image');
} }
@@ -39,6 +39,14 @@ class Image {
return 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) { Image copyRange(int x, int y, int width, int height) {
if (width + x > this.width) { if (width + x > this.width) {
throw ArgumentError(''' throw ArgumentError('''
@@ -176,11 +184,11 @@ class Color {
Color.fromValue(this.value); Color.fromValue(this.value);
int get r => (value >> 16) & 0xFF; int get r => value & 0xFF;
int get g => (value >> 8) & 0xFF; int get g => (value >> 8) & 0xFF;
int get b => value & 0xFF; int get b => (value >> 16) & 0xFF;
int get a => (value >> 24) & 0xFF; int get a => (value >> 24) & 0xFF;
} }

View File

@@ -35,19 +35,9 @@ class FilePath {
} }
extension FileSystemEntityExt on FileSystemEntity { extension FileSystemEntityExt on FileSystemEntity {
/// Get the base name of the file or directory.
String get name { String get name {
var path = this.path; return p.basename(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);
} }
Future<void> deleteIgnoreError({bool recursive = false}) async { 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]. // Stream is not usable since [AndroidFile] does not support [openRead].
await newFile.writeAsBytes(await readAsBytes()); await newFile.writeAsBytes(await readAsBytes());
} }
String get basenameWithoutExt {
return p.basenameWithoutExtension(path);
}
} }
extension DirectoryExtension on Directory { extension DirectoryExtension on Directory {

View File

@@ -1,33 +1,28 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate'; import 'dart:isolate';
import 'package:venera/foundation/app.dart';
import 'package:pdf/widgets.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/utils/image.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:zip_flutter/zip_flutter.dart';
typedef DecodeImage = Future<Image> Function(Uint8List data);
Future<void> _createPdfFromComic({ Future<void> _createPdfFromComic({
required LocalComic comic, required LocalComic comic,
required String savePath, required String savePath,
required String localPath, required String localPath,
required DecodeImage decodeImage,
}) async { }) async {
final pdf = Document( var images = <String>[];
title: comic.title,
author: comic.subTitle ?? "",
producer: "Venera",
);
pdf.document.outline;
var baseDir = comic.directory.contains('/') || comic.directory.contains('\\') var baseDir = comic.directory.contains('/') || comic.directory.contains('\\')
? comic.directory ? comic.directory
: FilePath.join(localPath, comic.directory); : FilePath.join(localPath, comic.directory);
// add cover // add cover
var imageData = File(FilePath.join(baseDir, comic.cover)).readAsBytesSync(); images.add(FilePath.join(baseDir, comic.cover));
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
bool multiChapters = comic.chapters != null; bool multiChapters = comic.chapters != null;
@@ -35,14 +30,14 @@ Future<void> _createPdfFromComic({
files.removeWhere( files.removeWhere(
(element) => element is! File || element.path.startsWith('cover')); (element) => element is! File || element.path.startsWith('cover'));
files.sort((a, b) { files.sort((a, b) {
var aName = (a as File).name; var aName = (a as File).basenameWithoutExt;
var bName = (b as File).name; var bName = (b as File).basenameWithoutExt;
var aNumber = int.tryParse(aName); var aNumber = int.tryParse(aName);
var bNumber = int.tryParse(bName); var bNumber = int.tryParse(bName);
if (aNumber != null && bNumber != null) { if (aNumber != null && bNumber != null) {
return aNumber.compareTo(bNumber); return aNumber.compareTo(bNumber);
} }
return aName.compareTo(bName); return a.name.compareTo(b.name);
}); });
} }
@@ -51,42 +46,358 @@ Future<void> _createPdfFromComic({
reorderFiles(files); reorderFiles(files);
for (var file in files) { for (var file in files) {
var imageData = (file as File).readAsBytesSync(); images.add(file.path);
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
} }
} else { } else {
for (var chapter in comic.chapters!.keys) { for (var chapter in comic.downloadedChapters) {
var files = Directory(FilePath.join(baseDir, chapter)).listSync(); var files = Directory(FilePath.join(baseDir, chapter)).listSync();
reorderFiles(files); reorderFiles(files);
for (var file in files) { for (var file in files) {
var imageData = (file as File).readAsBytesSync(); images.add(file.path);
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
} }
} }
} }
final file = File(savePath); var generator = PdfGenerator(
file.writeAsBytesSync(await pdf.save()); title: comic.title,
author: comic.subtitle,
imagePaths: images,
outputPath: savePath,
decodeImage: decodeImage,
);
await generator.generate();
} }
Future<void> createPdfFromComicIsolate({ Future<Isolate> _runIsolate(
required LocalComic comic, LocalComic comic, String savePath, SendPort sendPort) {
required String savePath,
}) async {
var localPath = LocalManager().path; var localPath = LocalManager().path;
return Isolate.run(() => overrideIO(() async { return Isolate.spawn<SendPort>(
return await _createPdfFromComic( (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, comic: comic,
savePath: savePath, savePath: savePath,
localPath: localPath, 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 final Map<String, Map<String, String>> _data = {};
static Future<void> readData() async{ static Future<void> readData() async{
if(App.locale.languageCode != "zh"){
return;
}
var fileName = App.locale.countryCode == 'TW' var fileName = App.locale.countryCode == 'TW'
? "assets/tags_tw.json" ? "assets/tags_tw.json"
: "assets/tags.json"; : "assets/tags.json";

View File

@@ -33,14 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -57,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003
url: "https://pub.dev"
source: hosted
version: "2.2.8"
battery_plus: battery_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -81,14 +65,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
bidi:
dependency: transitive
description:
name: bidi
sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d"
url: "https://pub.dev"
source: hosted
version: "2.0.12"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -298,6 +274,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -323,19 +307,21 @@ packages:
flutter_inappwebview: flutter_inappwebview:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_inappwebview path: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://pub.dev" resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
source: hosted url: "https://github.com/pichillilorenzo/flutter_inappwebview"
version: "6.1.5" source: git
version: "6.2.0-beta.3"
flutter_inappwebview_android: flutter_inappwebview_android:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_android path: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://pub.dev" resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
source: hosted url: "https://github.com/pichillilorenzo/flutter_inappwebview"
version: "1.1.3" source: git
version: "1.2.0-beta.3"
flutter_inappwebview_internal_annotations: flutter_inappwebview_internal_annotations:
dependency: transitive dependency: transitive
description: description:
@@ -347,43 +333,48 @@ packages:
flutter_inappwebview_ios: flutter_inappwebview_ios:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_ios path: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://pub.dev" resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
source: hosted url: "https://github.com/pichillilorenzo/flutter_inappwebview"
version: "1.1.2" source: git
version: "1.2.0-beta.3"
flutter_inappwebview_macos: flutter_inappwebview_macos:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_macos path: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://pub.dev" resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
source: hosted url: "https://github.com/pichillilorenzo/flutter_inappwebview"
version: "1.1.2" source: git
version: "1.2.0-beta.3"
flutter_inappwebview_platform_interface: flutter_inappwebview_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_platform_interface path: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://pub.dev" resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
source: hosted url: "https://github.com/pichillilorenzo/flutter_inappwebview"
version: "1.3.0+1" source: git
version: "1.4.0-beta.3"
flutter_inappwebview_web: flutter_inappwebview_web:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_web path: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://pub.dev" resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
source: hosted url: "https://github.com/pichillilorenzo/flutter_inappwebview"
version: "1.1.2" source: git
version: "1.2.0-beta.3"
flutter_inappwebview_windows: flutter_inappwebview_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_inappwebview_windows path: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
url: "https://pub.dev" resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
source: hosted url: "https://github.com/pichillilorenzo/flutter_inappwebview"
version: "0.6.0" source: git
version: "0.7.0-beta.3"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -417,8 +408,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "9c99ac258a11f8e91761a5466a190efba3ca64af" ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
resolved-ref: "9c99ac258a11f8e91761a5466a190efba3ca64af" resolved-ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
url: "https://github.com/wgh136/flutter_qjs" url: "https://github.com/wgh136/flutter_qjs"
source: git source: git
version: "0.3.7" version: "0.3.7"
@@ -521,14 +512,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.0" version: "0.1.0"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -690,14 +673,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -746,14 +721,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07"
url: "https://pub.dev"
source: hosted
version: "3.11.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -795,14 +762,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.9.1" version: "3.9.1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
rhttp: rhttp:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -977,14 +936,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.3" 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"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1142,18 +1093,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: yaml name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.3"
zip_flutter: zip_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: zip_flutter name: zip_flutter
sha256: be21152c35fcb6d0ef4ce89fc3aed681f7adc0db5490ca3eb5893f23fd20e646 sha256: bbf3160062610a43901b7ebbc6f6dd46519540f03a84027dc7b1fff399dda1ac
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.6" version: "0.0.10"
sdks: sdks:
dart: ">=3.6.0 <4.0.0" dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.1" flutter: ">=3.27.4"

View File

@@ -2,18 +2,16 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.2.0+120 version: 1.2.5+125
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.6.0 <4.0.0'
flutter: 3.27.1 flutter: 3.27.4
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
path_provider: any path_provider: any
flutter_localizations:
sdk: flutter
intl: ^0.19.0 intl: ^0.19.0
window_manager: ^0.4.3 window_manager: ^0.4.3
sqlite3: ^2.4.7 sqlite3: ^2.4.7
@@ -21,11 +19,11 @@ dependencies:
flutter_qjs: flutter_qjs:
git: git:
url: https://github.com/wgh136/flutter_qjs url: https://github.com/wgh136/flutter_qjs
ref: 9c99ac258a11f8e91761a5466a190efba3ca64af ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3
crypto: ^3.0.6 crypto: ^3.0.6
dio: ^5.7.0 dio: ^5.7.0
html: ^0.15.5 html: ^0.15.5
pointycastle: any pointycastle: ^3.9.1
url_launcher: ^6.3.0 url_launcher: ^6.3.0
path: ^1.9.0 path: ^1.9.0
photo_view: photo_view:
@@ -40,18 +38,21 @@ dependencies:
ref: 09e756b1f1b04e6298318d99ec20a787fb360f59 ref: 09e756b1f1b04e6298318d99ec20a787fb360f59
path: packages/scrollable_positioned_list path: packages/scrollable_positioned_list
flutter_reorderable_grid_view: ^5.4.0 flutter_reorderable_grid_view: ^5.4.0
yaml: any
uuid: ^4.5.1 uuid: ^4.5.1
desktop_webview_window: desktop_webview_window:
git: git:
url: https://github.com/wgh136/flutter_desktop_webview url: https://github.com/wgh136/flutter_desktop_webview
path: packages/desktop_webview_window path: packages/desktop_webview_window
flutter_inappwebview: ^6.1.5 flutter_inappwebview:
git:
url: https://github.com/pichillilorenzo/flutter_inappwebview
path: flutter_inappwebview
ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676
app_links: ^6.3.3 app_links: ^6.3.3
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2 flutter_file_dialog: ^3.0.2
file_selector: ^1.0.3 file_selector: ^1.0.3
zip_flutter: ^0.0.6 zip_flutter: ^0.0.10
lodepng_flutter: lodepng_flutter:
git: git:
url: https://github.com/venera-app/lodepng_flutter url: https://github.com/venera-app/lodepng_flutter
@@ -67,23 +68,25 @@ dependencies:
git: git:
url: https://github.com/pkuislm/flutter_saf.git url: https://github.com/pkuislm/flutter_saf.git
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
pdf: ^3.11.1
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
shimmer_animation: ^2.1.0 shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1 flutter_memory_info: ^0.0.1
syntax_highlight: ^0.4.0 syntax_highlight: ^0.4.0
text_scroll: ^0.2.0
flutter_7zip: flutter_7zip:
git: git:
url: https://github.com/wgh136/flutter_7zip url: https://github.com/wgh136/flutter_7zip
ref: b33344797f1d2469339e0e1b75f5f954f1da224c ref: b33344797f1d2469339e0e1b75f5f954f1da224c
flex_seed_scheme: ^3.5.0
flutter_localizations:
sdk: flutter
yaml: ^3.1.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
flutter_to_arch: ^1.0.1 flutter_to_arch: ^1.0.1
flutter_to_debian: flutter_to_debian: ^2.0.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -3,11 +3,36 @@
#define MyAppName "Venera" #define MyAppName "Venera"
#define MyAppVersion "{{version}}" #define MyAppVersion "{{version}}"
#define MyAppPublisher "wgh136" #define MyAppPublisher "nyne"
#define MyAppURL "https://github.com/venera-app/venera" #define MyAppURL "https://github.com/venera-app/venera"
#define MyAppExeName "venera.exe" #define MyAppExeName "venera.exe"
#define RootPath "{{root_path}}" #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] [Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. ; 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.) ; (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 Compression=lzma
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
ArchitecturesAllowed=x64compatible
[Languages] [Languages]
Name: "english"; MessagesFile: "compiler:Default.isl" Name: "english"; MessagesFile: "compiler:Default.isl"

View File

@@ -29,7 +29,7 @@ file.close()
if not os.path.exists("windows/ChineseSimplified.isl"): if not os.path.exists("windows/ChineseSimplified.isl"):
# download 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) response = httpx.get(url)
with open('windows/ChineseSimplified.isl', 'wb') as file: with open('windows/ChineseSimplified.isl', 'wb') as file:
file.write(response.content) file.write(response.content)