mirror of
https://github.com/venera-app/venera.git
synced 2025-09-28 16:27:25 +00:00
Compare commits
110 Commits
Author | SHA1 | Date | |
---|---|---|---|
fec1926774 | |||
![]() |
7cd0a20785 | ||
ed124d0419 | |||
14c3e9ea43 | |||
d2aca7ce44 | |||
34194559f5 | |||
18c5d5d85a | |||
9b1bafcbe1 | |||
dd7e2d6744 | |||
51c2bf0d6f | |||
53e5ebbbf6 | |||
c600d99c58 | |||
f4804faf52 | |||
c7d72347a9 | |||
a4e2d4f6e4 | |||
5c7cd7a304 | |||
9fb63e47ea | |||
fc66e8ae2d | |||
d04c872491 | |||
426936082e | |||
5129530e56 | |||
3735249de6 | |||
![]() |
8868a02a7e | ||
![]() |
e1b95c9e23 | ||
0b65b4ab53 | |||
df4263f969 | |||
17ef17ca5b | |||
![]() |
e55c45a589 | ||
591f2836d4 | |||
8ab4f7a34b | |||
614c01872b | |||
6be258092a | |||
ce50812857 | |||
f0b1135eb7 | |||
![]() |
cc0f070df5 | ||
35429c132c | |||
998d4c31d3 | |||
0122bb8f28 | |||
33a9fa062b | |||
13081332f2 | |||
![]() |
cdc6c95579 | ||
![]() |
3aca3baafc | ||
58d6ccdde1 | |||
23404b86f6 | |||
![]() |
965187e9de | ||
![]() |
24155746f2 | ||
340496da30 | |||
28a56b4612 | |||
4e6f71ef36 | |||
739685f60f | |||
8c5dae1e59 | |||
e2c69d882f | |||
0b9f0b7d35 | |||
9ea749a84a | |||
d675af3fb4 | |||
d99a30b7d8 | |||
![]() |
3c3c07b6fb | ||
![]() |
e688ab759a | ||
![]() |
64a3ef352f | ||
ef8dc9e8d4 | |||
![]() |
19af2d79dd | ||
5a11168f98 | |||
1564156e28 | |||
2534c55ffb | |||
ba4eff66db | |||
b43d907763 | |||
f5a814cfe4 | |||
24b9bcd86e | |||
812b36d1e9 | |||
bab2578b65 | |||
5cf2f9f33a | |||
040a5d7ad2 | |||
69da66904a | |||
11e4d7a9f2 | |||
7bd0c2b82a | |||
6b0a5184b9 | |||
864980079b | |||
de51b66d39 | |||
23205c518d | |||
3ae5c7c7f2 | |||
312e991935 | |||
5184130ff8 | |||
e555779419 | |||
5ef973cbfb | |||
8e2520f8e8 | |||
87f0f5bb55 | |||
![]() |
578c06fdc1 | ||
8645dda967 | |||
ded9055363 | |||
ff42c726fa | |||
53b033258a | |||
6ec4817dc1 | |||
283afbc6d4 | |||
c3a09c8870 | |||
f2388c81e0 | |||
c334e4fa05 | |||
![]() |
cc8277d462 | ||
e6b7f5b014 | |||
1edf284709 | |||
6033a3cde9 | |||
27e7356721 | |||
d88ae57320 | |||
7b7710b441 | |||
63346396e0 | |||
51b7df02e7 | |||
![]() |
811fbb04dc | ||
![]() |
eaf94363ae | ||
5e3ff48d35 | |||
c6ec38632f | |||
1c1f418019 |
7
.github/ISSUE_TEMPLATE/bug.yaml
vendored
7
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -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
|
||||||
|
65
.github/workflows/main.yml
vendored
65
.github/workflows/main.yml
vendored
@@ -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 }}
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
# venera
|
# venera
|
||||||
|
|
||||||
[](https://flutter.dev/)
|
[](https://flutter.dev/)
|
||||||
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
||||||
[](https://github.com/venera-app/venera/releases)
|
[](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
|
||||||
|
|
||||||
|
@@ -83,9 +83,19 @@ android {
|
|||||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||||
}
|
}
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
ndk {
|
||||||
|
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||||
|
}
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
applicationVariants.all { variant ->
|
applicationVariants.all { variant ->
|
||||||
variant.outputs.all { output ->
|
variant.outputs.all { output ->
|
||||||
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||||
|
if (variant.buildType.name == "release") {
|
||||||
if (abi != null) {
|
if (abi != null) {
|
||||||
outputFileName = "venera-${variant.versionName}-${abi}.apk"
|
outputFileName = "venera-${variant.versionName}-${abi}.apk"
|
||||||
def abiVersionCode = project.ext.abiCodes.get(abi)
|
def abiVersionCode = project.ext.abiCodes.get(abi)
|
||||||
@@ -96,7 +106,8 @@ android {
|
|||||||
outputFileName = "venera-${variant.versionName}.apk"
|
outputFileName = "venera-${variant.versionName}.apk"
|
||||||
versionCodeOverride = variant.versionCode * 10
|
versionCodeOverride = variant.versionCode * 10
|
||||||
}
|
}
|
||||||
}
|
} else if (variant.buildType.name == "debug") {
|
||||||
|
versionCodeOverride = variant.versionCode * 10 + 4
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
118
assets/init.js
118
assets/init.js
@@ -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'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
@@ -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
11
debian/build.py
vendored
@@ -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
6
debian/debian.yaml
vendored
@@ -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
|
||||||
|
1
debian/gui/venera.desktop
vendored
1
debian/gui/venera.desktop
vendored
@@ -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
655
doc/comic_source.md
Normal 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
61
doc/import_comic.md
Normal 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
513
doc/js_api.md
Normal 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 = {}
|
||||||
|
}
|
||||||
|
```
|
@@ -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'
|
||||||
|
@@ -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,11 +112,19 @@ class _AppbarState extends State<Appbar> {
|
|||||||
],
|
],
|
||||||
).paddingTop(context.padding.top),
|
).paddingTop(context.padding.top),
|
||||||
);
|
);
|
||||||
|
if (widget.style == AppbarStyle.shadow) {
|
||||||
|
return Material(
|
||||||
|
color: context.colorScheme.surface,
|
||||||
|
elevation: _scrolledUnder ? 2 : 0,
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
return BlurEffect(
|
return BlurEffect(
|
||||||
blur: _scrolledUnder ? 15 : 0,
|
blur: _scrolledUnder ? 15 : 0,
|
||||||
child: content,
|
child: content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppbarStyle {
|
enum AppbarStyle {
|
||||||
@@ -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,7 +385,8 @@ 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),
|
||||||
);
|
);
|
||||||
@@ -384,7 +404,8 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -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++)
|
||||||
|
@@ -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,19 +562,16 @@ 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(
|
decoration: BoxDecoration(
|
||||||
color: s == "Unavailable"
|
color: s == "Unavailable"
|
||||||
? Theme.of(context).colorScheme.errorContainer
|
? context.colorScheme.errorContainer
|
||||||
: Theme.of(context)
|
: context.colorScheme.secondaryContainer,
|
||||||
.colorScheme
|
borderRadius: BorderRadius.circular(8),
|
||||||
.secondaryContainer,
|
|
||||||
borderRadius:
|
|
||||||
const BorderRadius.all(Radius.circular(8)),
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
widthFactor: 1,
|
widthFactor: 1,
|
||||||
@@ -587,7 +583,9 @@ class _ComicDescription extends StatelessWidget {
|
|||||||
softWrap: true,
|
softWrap: true,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
))),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
).toAlign(Alignment.topCenter);
|
).toAlign(Alignment.topCenter);
|
||||||
@@ -608,6 +606,8 @@ class _ComicDescription extends StatelessWidget {
|
|||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12.0,
|
fontSize: 12.0,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -624,7 +624,8 @@ class _ComicDescription extends StatelessWidget {
|
|||||||
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
|
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
|
||||||
style: const TextStyle(fontSize: 12),
|
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,7 +1522,8 @@ class SimpleComicTile extends StatelessWidget {
|
|||||||
|
|
||||||
return AnimatedTapRegion(
|
return AnimatedTapRegion(
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
onTap: onTap ?? () {
|
onTap: onTap ??
|
||||||
|
() {
|
||||||
context.to(
|
context.to(
|
||||||
() => ComicPage(
|
() => ComicPage(
|
||||||
id: comic.id,
|
id: comic.id,
|
||||||
|
@@ -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';
|
||||||
|
@@ -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,
|
||||||
|
color: context.colorScheme.surface,
|
||||||
|
shadowColor: context.colorScheme.shadow,
|
||||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||||
boxShadow: isHovered
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: context.colorScheme.outline,
|
|
||||||
blurRadius: 2,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
BoxShadow(
|
|
||||||
color: context.colorScheme.outlineVariant,
|
|
||||||
blurRadius: 1,
|
|
||||||
offset: const Offset(0, 1),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@@ -277,17 +277,19 @@ 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(
|
result = RawImage(
|
||||||
image: _imageInfo?.image,
|
image: _imageInfo?.image,
|
||||||
width: widget.width,
|
width: widget.width,
|
||||||
@@ -306,6 +308,7 @@ class _AnimatedImageState extends State<AnimatedImage>
|
|||||||
isAntiAlias: widget.isAntiAlias,
|
isAntiAlias: widget.isAntiAlias,
|
||||||
filterQuality: widget.filterQuality,
|
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
242
lib/components/js_ui.dart
Normal 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),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@@ -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) {
|
||||||
|
if (oldDelegate is! SliverGridDelegateWithComics) return true;
|
||||||
|
if (oldDelegate.scale != scale ||
|
||||||
|
oldDelegate.useBriefMode != useBriefMode) {
|
||||||
return true;
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
@@ -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, {
|
||||||
|
void Function()? onCancel,
|
||||||
bool barrierDismissible = true,
|
bool barrierDismissible = true,
|
||||||
bool allowCancel = true,
|
bool allowCancel = true,
|
||||||
String? message,
|
String? message,
|
||||||
String cancelButtonText = "Cancel"}) {
|
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();
|
||||||
},
|
}
|
||||||
child: Text(cancelButtonText.tl))
|
: null,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
@@ -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._(
|
||||||
|
@@ -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() {
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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(
|
||||||
|
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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!;
|
||||||
|
@@ -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(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@@ -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}");
|
||||||
|
};
|
||||||
}
|
}
|
@@ -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) {
|
||||||
|
@@ -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',
|
||||||
|
@@ -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 {
|
||||||
await App.rootContext.to(
|
bool success = false;
|
||||||
() => AppWebview(
|
void check(InAppWebViewController controller) async {
|
||||||
initialUrl: url,
|
var head = await controller.evaluateJavascript(
|
||||||
singlePage: true,
|
source: "document.head.innerHTML") as String;
|
||||||
onLoadStop: (controller) async {
|
Log.info("Cloudflare", "Checking head: $head");
|
||||||
var res = await controller.platform.evaluateJavascript(
|
var isChallenging = head.contains('#challenge-success-text') ||
|
||||||
source:
|
head.contains("#challenge-error-text") ||
|
||||||
"document.head.innerHTML.includes('#challenge-success-text')");
|
head.contains("#challenge-form");
|
||||||
if (res == false) {
|
if (!isChallenging) {
|
||||||
|
Log.info(
|
||||||
|
"Cloudflare",
|
||||||
|
"Cloudflare is passed due to there is no challenge css",
|
||||||
|
);
|
||||||
var ua = await controller.getUA();
|
var ua = await controller.getUA();
|
||||||
if (ua != null) {
|
if (ua != null) {
|
||||||
appdata.implicitData['ua'] = ua;
|
appdata.implicitData['ua'] = ua;
|
||||||
appdata.writeImplicitData();
|
appdata.writeImplicitData();
|
||||||
}
|
}
|
||||||
var cookies = await controller.getCookies(url) ?? [];
|
var cookies = await controller.getCookies(url) ?? [];
|
||||||
if(cookies.firstWhereOrNull((element) => element.name == 'cf_clearance') == null) {
|
if (cookies.firstWhereOrNull(
|
||||||
|
(element) => element.name == 'cf_clearance') ==
|
||||||
|
null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
|
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
|
||||||
|
if (!success) {
|
||||||
App.rootPop();
|
App.rootPop();
|
||||||
|
success = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await App.rootContext.to(
|
||||||
|
() => AppWebview(
|
||||||
|
initialUrl: url,
|
||||||
|
singlePage: true,
|
||||||
|
onTitleChange: (title, controller) async {
|
||||||
|
check(controller);
|
||||||
|
},
|
||||||
|
onLoadStop: (controller) async {
|
||||||
|
check(controller);
|
||||||
},
|
},
|
||||||
onStarted: (controller) async {
|
onStarted: (controller) async {
|
||||||
var ua = await controller.getUA();
|
var ua = await controller.getUA();
|
||||||
|
@@ -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();
|
||||||
|
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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(),
|
||||||
|
@@ -3,56 +3,101 @@ 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<CategoriesPage> createState() => _CategoriesPageState();
|
||||||
return StateBuilder<SimpleController>(
|
}
|
||||||
tag: "category",
|
|
||||||
init: SimpleController(),
|
class _CategoriesPageState extends State<CategoriesPage> {
|
||||||
builder: (controller) {
|
var categories = <String>[];
|
||||||
var categories = List.from(appdata.settings["categories"]);
|
|
||||||
|
void onSettingsChanged() {
|
||||||
|
var categories =
|
||||||
|
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||||
var allCategories = ComicSource.all()
|
var allCategories = ComicSource.all()
|
||||||
.map((e) => e.categoryData?.key)
|
.map((e) => e.categoryData?.key)
|
||||||
.where((element) => element != null)
|
.where((element) => element != null)
|
||||||
.map((e) => e!)
|
.map((e) => e!)
|
||||||
.toList();
|
.toList();
|
||||||
categories = categories
|
categories =
|
||||||
.where((element) => allCategories.contains(element))
|
categories.where((element) => allCategories.contains(element)).toList();
|
||||||
.toList();
|
if (!categories.isEqualTo(this.categories)) {
|
||||||
|
setState(() {
|
||||||
|
this.categories = categories;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(categories.isEmpty) {
|
@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;
|
var msg = "No Category Pages".tl;
|
||||||
msg += '\n';
|
msg += '\n';
|
||||||
if(ComicSource.isEmpty) {
|
VoidCallback onTap;
|
||||||
msg += "Add a comic source in home page".tl;
|
if (ComicSource.isEmpty) {
|
||||||
|
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,
|
||||||
controller.update();
|
|
||||||
},
|
|
||||||
withAppbar: false,
|
withAppbar: false,
|
||||||
|
buttonText: "Manage".tl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (categories.isEmpty) {
|
||||||
|
return buildEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
child: DefaultTabController(
|
child: DefaultTabController(
|
||||||
length: categories.length,
|
length: categories.length,
|
||||||
key: Key(categories.toString()),
|
key: Key(categories.toString()),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
FilledTabBar(
|
AppTabBar(
|
||||||
key: PageStorageKey(categories.toString()),
|
key: PageStorageKey(categories.toString()),
|
||||||
tabs: categories.map((e) {
|
tabs: categories.map((e) {
|
||||||
String title = e;
|
String title = e;
|
||||||
@@ -66,18 +111,21 @@ class CategoriesPage extends StatelessWidget {
|
|||||||
key: Key(e),
|
key: Key(e),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
actionButton: TabActionButton(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
text: "Add".tl,
|
||||||
|
onPressed: addPage,
|
||||||
|
),
|
||||||
).paddingTop(context.padding.top),
|
).paddingTop(context.padding.top),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
children:
|
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||||
categories.map((e) => _CategoryPage(e)).toList()),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -49,20 +49,20 @@ 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) {
|
|
||||||
history = newHistory;
|
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildLoading() {
|
Widget buildLoading() {
|
||||||
@@ -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,7 +204,8 @@ 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(
|
||||||
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -255,13 +254,14 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).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,20 +1100,46 @@ 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!;
|
||||||
|
|
||||||
|
return SliverLayoutBuilder(
|
||||||
|
builder: (context, constrains) {
|
||||||
int length = eps.length;
|
int length = eps.length;
|
||||||
|
bool canShowAll = showAll;
|
||||||
if (!showAll) {
|
if (!showAll) {
|
||||||
length = math.min(length, 20);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return SliverMainAxisGroup(
|
return SliverMainAxisGroup(
|
||||||
@@ -1129,26 +1163,25 @@ class _ComicChaptersState extends State<_ComicChapters> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverGrid(
|
SliverGrid(
|
||||||
delegate:
|
delegate: SliverChildBuilderDelegate(
|
||||||
SliverChildBuilderDelegate(childCount: length, (context, i) {
|
childCount: length,
|
||||||
|
(context, i) {
|
||||||
if (reverse) {
|
if (reverse) {
|
||||||
i = eps.length - i - 1;
|
i = eps.length - i - 1;
|
||||||
}
|
}
|
||||||
var key = eps.keys.elementAt(i);
|
var key = eps.keys.elementAt(i);
|
||||||
var value = eps[key]!;
|
var value = eps[key]!;
|
||||||
bool visited =
|
bool visited = (history?.readEpisode ?? {}).contains(i + 1);
|
||||||
(state.history?.readEpisode ?? const {}).contains(i + 1);
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: context.colorScheme.surfaceContainer,
|
color: context.colorScheme.surfaceContainer,
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => state.read(i + 1),
|
onTap: () => state.read(i + 1),
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
value,
|
value,
|
||||||
@@ -1156,7 +1189,9 @@ class _ComicChaptersState extends State<_ComicChapters> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: visited ? context.colorScheme.outline : null,
|
color: visited
|
||||||
|
? context.colorScheme.outline
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1164,25 +1199,25 @@ class _ComicChaptersState extends State<_ComicChapters> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedHeight(
|
gridDelegate: const SliverGridDelegateWithFixedHeight(
|
||||||
maxCrossAxisExtent: 200, itemHeight: 48),
|
maxCrossAxisExtent: 200,
|
||||||
|
itemHeight: 48,
|
||||||
|
),
|
||||||
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
|
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
|
||||||
if (eps.length > 20 && !showAll)
|
if (eps.length > 20 && !canShowAll)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: FilledButton.tonal(
|
child: TextButton.icon(
|
||||||
style: ButtonStyle(
|
icon: const Icon(Icons.arrow_drop_down),
|
||||||
shape: WidgetStateProperty.all(const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)))),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
showAll = true;
|
showAll = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Text("${"Show all".tl} (${eps.length})"),
|
label: Text("${"Show all".tl} (${eps.length})"),
|
||||||
).paddingTop(12),
|
).paddingTop(12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1191,6 +1226,8 @@ class _ComicChaptersState extends State<_ComicChapters> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,20 +1336,20 @@ 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:
|
|
||||||
const BorderRadius.all(Radius.circular(16)),
|
|
||||||
child: AnimatedImage(
|
child: AnimatedImage(
|
||||||
image: CachedImageProvider(
|
image: CachedImageProvider(
|
||||||
url,
|
url,
|
||||||
@@ -1324,7 +1363,6 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 4,
|
height: 4,
|
||||||
),
|
),
|
||||||
@@ -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,34 +1425,60 @@ 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,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
TabBar(tabs: [
|
TabBar(
|
||||||
|
controller: tabController,
|
||||||
|
tabs: [
|
||||||
Tab(text: "Local".tl),
|
Tab(text: "Local".tl),
|
||||||
if (hasNetwork) Tab(text: "Network".tl),
|
if (hasNetwork) Tab(text: "Network".tl),
|
||||||
]),
|
],
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
|
controller: tabController,
|
||||||
children: [
|
children: [
|
||||||
buildLocal(),
|
buildLocal(),
|
||||||
if (hasNetwork) buildNetwork(),
|
if (hasNetwork) buildNetwork(),
|
||||||
@@ -1423,7 +1487,6 @@ class _FavoritePanelState extends State<_FavoritePanel> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
@@ -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(
|
||||||
|
() => _EditFilePage(source.filePath, () async {
|
||||||
await ComicSource.reload();
|
await ComicSource.reload();
|
||||||
setState(() {});
|
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(
|
|
||||||
color: context.colorScheme.primary,
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await widget.onAdd(
|
var fileName = json![index]["fileName"];
|
||||||
"https://raw.githubusercontent.com/venera-app/venera-configs/master/${json![index]["fileName"]}");
|
var url = json![index]["url"];
|
||||||
|
if (url == null || !(url.toString()).isURL) {
|
||||||
|
var listUrl =
|
||||||
|
appdata.settings['comicSourceListUrl'] as String;
|
||||||
|
if (listUrl
|
||||||
|
.replaceFirst("https://", "")
|
||||||
|
.replaceFirst("http://", "")
|
||||||
|
.contains("/")) {
|
||||||
|
url =
|
||||||
|
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
|
||||||
|
fileName;
|
||||||
|
} else {
|
||||||
|
url = '$listUrl/$fileName';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await widget.onAdd(url);
|
||||||
setState(() {});
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -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() {
|
||||||
|
if (!isRendered) {
|
||||||
render();
|
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') {
|
||||||
|
@@ -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
|
||||||
|
@@ -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);
|
||||||
|
|
||||||
|
@@ -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';
|
||||||
|
|
||||||
|
@@ -476,11 +476,12 @@ 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,
|
||||||
|
content: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 0),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -490,20 +491,13 @@ class _CreateFolderDialogState extends State<_CreateFolderDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 200,
|
height: 16
|
||||||
height: 10,
|
|
||||||
),
|
),
|
||||||
if (loading)
|
],
|
||||||
Center(
|
),
|
||||||
child: const CircularProgressIndicator(
|
actions: [
|
||||||
strokeWidth: 2,
|
Button.filled(
|
||||||
).fixWidth(24).fixHeight(24),
|
isLoading: loading,
|
||||||
)
|
|
||||||
else
|
|
||||||
SizedBox(
|
|
||||||
height: 35,
|
|
||||||
child: Center(
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -522,8 +516,6 @@ class _CreateFolderDialogState extends State<_CreateFolderDialog> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: Text("Submit".tl),
|
child: Text("Submit".tl),
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
@@ -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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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();
|
||||||
|
|
||||||
|
@@ -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(),
|
||||||
),
|
),
|
||||||
|
@@ -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});
|
||||||
@@ -152,8 +153,8 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
));
|
));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
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);
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -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(
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
|
@@ -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) {
|
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
_battery.batteryLevel.then((level) => {
|
_battery.batteryLevel.then((level) {
|
||||||
if (_batteryLevel != level)
|
if (_batteryLevel != level) {
|
||||||
{
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_batteryLevel = level;
|
_batteryLevel = level;
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_hasBattery = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_hasBattery = false;
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
@@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@@ -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(() {});
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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();
|
||||||
|
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
@@ -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",
|
||||||
|
@@ -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 StatefulBuilder(builder: (context, setState) {
|
||||||
return ContentDialog(
|
return ContentDialog(
|
||||||
title: "Add".tl,
|
title: "Add".tl,
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: canAdd.entries
|
children: canAdd.entries
|
||||||
.map(
|
.map(
|
||||||
(e) => ListTile(
|
(e) => CheckboxListTile(
|
||||||
|
value: selected.contains(e.key),
|
||||||
title: Text(e.value),
|
title: Text(e.value),
|
||||||
key: Key(e.key),
|
key: Key(e.key),
|
||||||
onTap: () {
|
onChanged: (value) {
|
||||||
context.pop();
|
|
||||||
setState(() {
|
setState(() {
|
||||||
keys.add(e.key);
|
if (value!) {
|
||||||
|
selected.add(e.key);
|
||||||
|
} else {
|
||||||
|
selected.remove(e.key);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
updateSetting();
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
if (selected.length < canAdd.length)
|
||||||
|
TextButton(
|
||||||
|
child: Text("Select All".tl),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
selected = canAdd.keys.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),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -170,23 +170,56 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(child: buildRight())
|
Expanded(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constrains) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, _) {
|
||||||
|
var width = constrains.maxWidth;
|
||||||
|
var value = animation.isForwardOrCompleted
|
||||||
|
? 1 - animation.value
|
||||||
|
: 1;
|
||||||
|
var left = width * value;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: left,
|
||||||
|
width: width,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: buildRight(),
|
||||||
|
),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constrains) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(child: buildLeft()),
|
Positioned.fill(child: buildLeft()),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: offset,
|
left: offset,
|
||||||
right: 0,
|
width: constrains.maxWidth,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
child: Listener(
|
child: Listener(
|
||||||
onPointerDown: handlePointerDown,
|
onPointerDown: handlePointerDown,
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 200),
|
||||||
reverseDuration: const Duration(milliseconds: 300),
|
|
||||||
switchInCurve: Curves.fastOutSlowIn,
|
switchInCurve: Curves.fastOutSlowIn,
|
||||||
switchOutCurve: Curves.fastOutSlowIn,
|
switchOutCurve: Curves.fastOutSlowIn,
|
||||||
transitionBuilder: (child, animation) {
|
transitionBuilder: (child, animation) {
|
||||||
@@ -198,16 +231,17 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
|||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: currentPage == -1
|
child: Material(
|
||||||
? const SizedBox(
|
key: ValueKey(currentPage),
|
||||||
key: Key("1"),
|
child: buildRight(),
|
||||||
)
|
),
|
||||||
: 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);
|
||||||
|
@@ -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.data == null) {
|
if (!e.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
AppWebview.webViewEnvironment = e.data;
|
|
||||||
return createWebviewWithEnvironment(
|
return createWebviewWithEnvironment(
|
||||||
AppWebview.webViewEnvironment);
|
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) {
|
||||||
|
@@ -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);
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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,9 +187,9 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@@ -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@?^=%&/~+#-])?$',
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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";
|
||||||
|
163
pubspec.lock
163
pubspec.lock
@@ -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"
|
||||||
|
27
pubspec.yaml
27
pubspec.yaml
@@ -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
|
||||||
|
@@ -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"
|
||||||
|
@@ -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)
|
||||||
|
Reference in New Issue
Block a user