mirror of
https://github.com/venera-app/venera.git
synced 2025-12-15 14:41:15 +00:00
Compare commits
65 Commits
v1.4.6
...
feat/js-di
| Author | SHA1 | Date | |
|---|---|---|---|
| e8d98e8274 | |||
| 09a1d2821c | |||
|
|
7842b5a1ac | ||
|
|
079f574e2f | ||
|
|
b08f11f6ac | ||
|
|
cd925df125 | ||
|
|
8c87c4a906 | ||
|
|
c234a53518 | ||
| 49fd64358c | |||
| 3426d707fe | |||
| ebc106d45b | |||
| 0cda9a2921 | |||
| 0eb5d76687 | |||
| 29d25f7fcd | |||
| 7d60e78f27 | |||
|
|
e93b56a008 | ||
|
|
d10873a903 | ||
|
|
2d27f7d650 | ||
| e1fbdfbd50 | |||
| 0a5b70b161 | |||
|
|
5a76a10fb2 | ||
| 9173665afe | |||
|
|
f09e766a8a | ||
| e0ea449c17 | |||
| c438a84537 | |||
| 8c625e212a | |||
| ab786ed2ab | |||
| d9303aab2e | |||
|
|
b7f79476c8 | ||
|
|
44bcce4385 | ||
|
|
6ce6066de2 | ||
|
|
7fa48cec29 | ||
| e549a18dbf | |||
| c17c4abb5b | |||
| af57bc31b1 | |||
| 16449a1440 | |||
| a7c1983f35 | |||
| 4c257d7178 | |||
| 3a9d634edf | |||
|
|
e179c8f67f | ||
|
|
c4b85471c1 | ||
|
|
a898b57d96 | ||
| 50c6bec4cd | |||
|
|
8c44f83d6c | ||
|
|
103b6b2832 | ||
| 4129349c70 | |||
| 77a9aa5457 | |||
| 97940b9492 | |||
| 7945c0e54f | |||
| dfee65c3af | |||
| fa2dbd79f6 | |||
| 9a9f539906 | |||
| d7331f36e9 | |||
|
|
d0b76de465 | ||
| 894a922b8f | |||
| a91d7fff2d | |||
|
|
926a3a530e | ||
| d308c2ac60 | |||
| ac13807ef4 | |||
| 38a5b2b8cf | |||
| 3a7c8d5e38 | |||
|
|
ce0d10aeb2 | ||
|
|
0ac857ef9a | ||
| 3928f5afe7 | |||
| 8a61a4750b |
9
.github/workflows/main.yml
vendored
9
.github/workflows/main.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
|
||||
- run: flutter pub get
|
||||
# Step 1: Decode and install the certificate
|
||||
- name: Decode and install certificate
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
channel: "stable"
|
||||
flutter-version-file: pubspec.yaml
|
||||
architecture: x64
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app
|
||||
- run: sudo xcode-select --switch /Applications/Xcode_16.4.app
|
||||
- run: flutter pub get
|
||||
- run: flutter build ios --release --no-codesign
|
||||
- run: |
|
||||
@@ -116,6 +116,8 @@ jobs:
|
||||
run: |
|
||||
choco install yq -y
|
||||
pip install httpx
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup --no-progress
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
@@ -170,6 +172,9 @@ jobs:
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
- name: "Patch font"
|
||||
run: |
|
||||
dart run patch/font.dart
|
||||
- run: python3 debian/build.py arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
13
.github/workflows/update_alt_store.yml
vendored
13
.github/workflows/update_alt_store.yml
vendored
@@ -40,8 +40,19 @@ jobs:
|
||||
if git diff --staged --quiet; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# Create a new branch for the PR
|
||||
branch_name="update-altstore-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -m "Updated source with latest release"
|
||||
git push
|
||||
git push -u origin "$branch_name"
|
||||
|
||||
# Create PR using GitHub CLI
|
||||
gh pr create \
|
||||
--title "Update AltStore source with latest release" \
|
||||
--body "This PR updates the alt_store.json file with the latest release information." \
|
||||
--head "$branch_name" \
|
||||
--base master
|
||||
|
||||
echo "changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
12
README.md
12
README.md
@@ -1,15 +1,14 @@
|
||||
# venera
|
||||
[](https://flutter.dev/)
|
||||
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
||||
[](https://github.com/venera-app/venera/releases)
|
||||
[](https://github.com/venera-app/venera/stargazers)
|
||||
[](https://t.me/venera_release)
|
||||
|
||||
A comic reader that support reading local and network comics.
|
||||
[](https://github.com/venera-app/venera/releases)
|
||||
[](https://aur.archlinux.org/packages/venera-bin)
|
||||
[](https://f-droid.org/packages/com.github.wgh136.venera/)
|
||||
|
||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid"
|
||||
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
|
||||
A comic reader that support reading local and network comics.
|
||||
|
||||
## Features
|
||||
- Read local comics
|
||||
@@ -34,4 +33,7 @@ See [Comic Source](doc/comic_source.md)
|
||||
### Tags Translation
|
||||
[](https://github.com/EhTagTranslation/Database)
|
||||
|
||||
## Headless Mode
|
||||
See [Headless Doc](doc/headless_doc.md)
|
||||
|
||||
The Chinese translation of the manga tags is from this project.
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
"bundleIdentifier": "com.github.wgh136.venera",
|
||||
"developerName": "wgh136",
|
||||
"subtitle": "A comic reader that supports reading local and network comics",
|
||||
"version": "1.4.5",
|
||||
"versionDate": "2025-06-18",
|
||||
"versionDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||
"version": "1.5.3",
|
||||
"versionDate": "2025-10-13",
|
||||
"versionDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"localizedDescription": "A comic reader that supports reading local and network comics",
|
||||
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||
"tintColor": "#0784FC",
|
||||
"category": "utilities",
|
||||
"size": 14960268,
|
||||
"size": 15047841,
|
||||
"appPermissions": {
|
||||
"entitlements": [
|
||||
"application-identifier",
|
||||
@@ -39,6 +39,13 @@
|
||||
}
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"date": "2025-10-13",
|
||||
"localizedDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"size": 15047841
|
||||
},
|
||||
{
|
||||
"version": "1.4.5",
|
||||
"date": "2025-06-18",
|
||||
@@ -59,6 +66,16 @@
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.4.5 - Venera 18/06/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
|
||||
},
|
||||
{
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": "Update of Venera just got released!",
|
||||
"date": "2025-10-13T12:47:27Z",
|
||||
"identifier": "release-v1.5.3",
|
||||
"notify": true,
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.5.3 - Venera 13/10/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.5.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,7 +23,7 @@ linter:
|
||||
rules:
|
||||
collection_methods_unrelated_type: false
|
||||
use_build_context_synchronously: false
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
avoid_print: false
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
|
||||
@@ -34,6 +34,12 @@ android {
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion "28.0.13004108"
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
useLegacyPackaging true
|
||||
}
|
||||
}
|
||||
|
||||
splits{
|
||||
abi {
|
||||
reset()
|
||||
@@ -78,6 +84,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
@@ -58,8 +59,6 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<!-- [flutter 3.27.1] Impeller is still worse than skia, disable it -->
|
||||
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false"/>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.9.0' apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
|
||||
@@ -4,6 +4,18 @@ Venera JavaScript Library
|
||||
This library provides a set of APIs for interacting with the Venera app.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @function sendMessage
|
||||
* @global
|
||||
* @param {Object} message
|
||||
* @returns {any}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set a timeout to execute a callback function after a specified delay.
|
||||
* @param callback {Function}
|
||||
* @param delay {number} - delay in milliseconds
|
||||
*/
|
||||
function setTimeout(callback, delay) {
|
||||
sendMessage({
|
||||
method: 'delay',
|
||||
@@ -1322,7 +1334,7 @@ let UI = {
|
||||
* 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.
|
||||
* @param image {string?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
|
||||
* @param image {string | ArrayBuffer | null | undefined} - Since 1.4.6, you can pass an image url to show an image in the dialog. Since 1.5.3, you can also pass an ArrayBuffer to show a custom image.
|
||||
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
|
||||
*/
|
||||
showInputDialog: (title, validator, image) => {
|
||||
@@ -1412,3 +1424,18 @@ function getClipboard() {
|
||||
method: 'getClipboard'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a function with arguments. The function will be executed in the engine pool which is not in the main thread.
|
||||
* @param func {string} - A js code string which can be evaluated to a function. The function will receive the args as its only argument.
|
||||
* @param args {any[]} - The arguments to pass to the function.
|
||||
* @returns {Promise<any>} - The result of the function.
|
||||
* @since 1.5.0
|
||||
*/
|
||||
function compute(func, ...args) {
|
||||
return sendMessage({
|
||||
method: 'compute',
|
||||
function: func,
|
||||
args: args
|
||||
})
|
||||
}
|
||||
@@ -83,7 +83,10 @@
|
||||
"New Folder": "新建文件夹",
|
||||
"Reading": "阅读中",
|
||||
"Appearance": "外观",
|
||||
"Network Favorites": "网络收藏",
|
||||
"Local Favorites": "本地收藏",
|
||||
"Show local favorites before network favorites": "在网络收藏之前显示本地收藏",
|
||||
"Auto close favorite panel after operation": "自动关闭收藏面板",
|
||||
"APP": "应用",
|
||||
"About": "关于",
|
||||
"Display mode of comic tile": "漫画缩略图的显示模式",
|
||||
@@ -406,7 +409,12 @@
|
||||
"Disable Length Limitation": "禁用长度限制",
|
||||
"Only valid for this run": "仅对本次运行有效",
|
||||
"Logs": "日志",
|
||||
"Export logs": "导出日志"
|
||||
"Export logs": "导出日志",
|
||||
"Clear specific reader settings for all comics": "清除所有漫画的特殊阅读设置",
|
||||
"Clear specific reader settings for this comic": "清除该漫画的特殊阅读设置",
|
||||
"Enable comic specific settings": "启用此漫画特定设置",
|
||||
"Ignore Certificate Errors": "忽略证书错误",
|
||||
"Mouse scroll speed": "鼠标滚动速度"
|
||||
},
|
||||
"zh_TW": {
|
||||
"Home": "首頁",
|
||||
@@ -492,7 +500,10 @@
|
||||
"New Folder": "建立資料夾",
|
||||
"Reading": "閱讀中",
|
||||
"Appearance": "外觀",
|
||||
"Network Favorites": "網路收藏",
|
||||
"Local Favorites": "本機收藏",
|
||||
"Show local favorites before network favorites": "在網路收藏之前顯示本機收藏",
|
||||
"Auto close favorite panel after operation": "自動關閉收藏面板",
|
||||
"APP": "應用",
|
||||
"About": "關於",
|
||||
"Display mode of comic tile": "漫畫縮圖的顯示模式",
|
||||
@@ -815,6 +826,11 @@
|
||||
"Disable Length Limitation": "禁用長度限制",
|
||||
"Only valid for this run": "僅對本次運行有效",
|
||||
"Logs": "日誌",
|
||||
"Export logs": "匯出日誌"
|
||||
"Export logs": "匯出日誌",
|
||||
"Clear specific reader settings for all comics": "清除所有漫畫的特殊閱讀設定",
|
||||
"Clear specific reader settings for this comic": "清除該漫畫的特殊閱讀設定",
|
||||
"Enable comic specific settings": "啟用此漫畫特定設定",
|
||||
"Ignore Certificate Errors": "忽略證書錯誤",
|
||||
"Mouse scroll speed": "滑鼠滾動速度"
|
||||
}
|
||||
}
|
||||
180
doc/headless_doc.md
Normal file
180
doc/headless_doc.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Venera Headless Mode
|
||||
|
||||
Venera's headless mode allows you to run key features from the command line, making it easy to automate tasks and integrate with other tools. This document outlines the available commands and their usage.
|
||||
|
||||
## How to Use
|
||||
|
||||
To activate headless mode, use the `--headless` flag when running the Venera executable, followed by the desired command.
|
||||
|
||||
```bash
|
||||
venera --headless <command> [subcommand] [options]
|
||||
```
|
||||
|
||||
## Global Options
|
||||
|
||||
- **`--ignore-disheadless-log`**: Suppresses log output, providing a cleaner output for scripting.
|
||||
|
||||
## Commands
|
||||
|
||||
### `webdav`
|
||||
|
||||
Manage WebDAV data synchronization.
|
||||
|
||||
- **`webdav up`**: Uploads your local configuration to the WebDAV server.
|
||||
- **`webdav down`**: Downloads and applies the remote configuration from the WebDAV server.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
venera --headless webdav up
|
||||
```
|
||||
|
||||
### `updatescript`
|
||||
|
||||
Update comic source scripts.
|
||||
|
||||
- **`updatescript all`**: Checks for and applies all available updates for your comic source scripts.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
venera --headless updatescript all
|
||||
```
|
||||
|
||||
**Output Format:**
|
||||
|
||||
The `updatescript` command provides detailed progress and a final summary.
|
||||
|
||||
**Progress Logs:**
|
||||
|
||||
- **`Progress`**: Indicates a successful update for a single script.
|
||||
- **`ProgressError`**: Indicates a failure during a script update.
|
||||
|
||||
**Example `Progress` Log:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"message": "Progress",
|
||||
"data": {
|
||||
"current": 1,
|
||||
"total": 5,
|
||||
"source": {
|
||||
"key": "source-key",
|
||||
"name": "Source Name",
|
||||
"version": "1.0.0",
|
||||
"url": "https://example.com/source.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Final Summary:**
|
||||
|
||||
A summary is provided at the end, detailing the total number of scripts, how many were updated, and how many failed.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "All scripts updated.",
|
||||
"data": {
|
||||
"total": 5,
|
||||
"updated": 4,
|
||||
"errors": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `updatesubscribe`
|
||||
|
||||
Update your subscribed comics and retrieve a list of updated comics.
|
||||
|
||||
- **`updatesubscribe`**: Checks all subscribed comics for updates.
|
||||
- **`updatesubscribe --update-comic-by-id-type <id> <type>`**: Updates a single comic specified by its `id` and `type`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
# Update all subscriptions
|
||||
venera --headless updatesubscribe
|
||||
|
||||
# Update a single comic
|
||||
venera --headless updatesubscribe --update-comic-by-id-type "comic-id" "source-key"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
All headless commands output JSON objects prefixed with `[CLI PRINT]`. This structured format allows for easy parsing in automated scripts. The JSON object always contains a `status` and a `message`. For commands that return data, a `data` field will also be present.
|
||||
|
||||
### `updatesubscribe` Output
|
||||
|
||||
The `updatesubscribe` command provides detailed progress and final results in JSON format.
|
||||
|
||||
**Progress Logs:**
|
||||
|
||||
During an update, you will receive `Progress` or `ProgressError` messages.
|
||||
|
||||
- **`Progress`**: Indicates a successful step in the update process.
|
||||
- **`ProgressError`**: Indicates an error occurred while updating a specific comic.
|
||||
|
||||
**Example `Progress` Log:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"message": "Progress",
|
||||
"data": {
|
||||
"current": 1,
|
||||
"total": 10,
|
||||
"comic": {
|
||||
"id": "some-comic-id",
|
||||
"name": "Some Comic Name",
|
||||
"coverUrl": "https://example.com/cover.jpg",
|
||||
"author": "Author Name",
|
||||
"type": "source-key",
|
||||
"updateTime": "2023-10-27T12:00:00Z",
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example `ProgressError` Log:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"message": "ProgressError",
|
||||
"data": {
|
||||
"current": 2,
|
||||
"total": 10,
|
||||
"comic": {
|
||||
"id": "another-comic-id",
|
||||
"name": "Another Comic Name",
|
||||
...
|
||||
},
|
||||
"error": "Error message here"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Final Output:**
|
||||
|
||||
Once the update process is complete, a final JSON object is returned with a list of all comics that have been updated.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Updated comics list.",
|
||||
"data": [
|
||||
{
|
||||
"id": "some-comic-id",
|
||||
"name": "Some Comic Name",
|
||||
"coverUrl": "https://example.com/cover.jpg",
|
||||
"author": "Author Name",
|
||||
"type": "source-key",
|
||||
"updateTime": "2023-10-27T12:00:00Z",
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ ImageProvider? _findImageProvider(Comic comic) {
|
||||
comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
fallbackToLocalCover: comic is FavoriteItem,
|
||||
);
|
||||
}
|
||||
return image;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -40,7 +42,6 @@ mixin class JsUiApi {
|
||||
var image = message['image'];
|
||||
if (title is! String) return;
|
||||
if (validator != null && validator is! JSInvokable) return;
|
||||
if (image != null && image is! String) return;
|
||||
return _showInputDialog(title, validator, image);
|
||||
case 'showSelectDialog':
|
||||
var title = message['title'];
|
||||
@@ -126,13 +127,25 @@ mixin class JsUiApi {
|
||||
controller?.close();
|
||||
}
|
||||
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async {
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, dynamic image) async {
|
||||
String? result;
|
||||
var func = validator == null ? null : JSAutoFreeFunction(validator);
|
||||
String? imageUrl;
|
||||
Uint8List? imageData;
|
||||
if (image != null) {
|
||||
if (image is String) {
|
||||
imageUrl = image;
|
||||
} else if (image is Uint8List) {
|
||||
imageData = image;
|
||||
} else if (image is List<int>) {
|
||||
imageData = Uint8List.fromList(image);
|
||||
}
|
||||
}
|
||||
await showInputDialog(
|
||||
context: App.rootContext,
|
||||
title: title,
|
||||
image: image,
|
||||
image: imageUrl,
|
||||
imageData: imageData,
|
||||
onConfirm: (v) {
|
||||
if (func != null) {
|
||||
var res = func.call([v]);
|
||||
|
||||
@@ -7,6 +7,7 @@ class NetworkError extends StatelessWidget {
|
||||
this.retry,
|
||||
this.withAppbar = true,
|
||||
this.buttonText,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final String message;
|
||||
@@ -17,6 +18,8 @@ class NetworkError extends StatelessWidget {
|
||||
|
||||
final String? buttonText;
|
||||
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var cfe = CloudflareException.fromString(message);
|
||||
@@ -67,9 +70,16 @@ class NetworkError extends StatelessWidget {
|
||||
child: Text('Verify'.tl),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: retry,
|
||||
child: Text(buttonText ?? 'Retry'.tl),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (action != null)
|
||||
action!.paddingRight(8),
|
||||
FilledButton(
|
||||
onPressed: retry,
|
||||
child: Text(buttonText ?? 'Retry'.tl),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -360,6 +360,7 @@ Future<void> showInputDialog({
|
||||
String cancelText = "Cancel",
|
||||
RegExp? inputValidator,
|
||||
String? image,
|
||||
Uint8List? imageData,
|
||||
}) {
|
||||
var controller = TextEditingController(text: initialValue);
|
||||
bool isLoading = false;
|
||||
@@ -379,6 +380,11 @@ Future<void> showInputDialog({
|
||||
height: 108,
|
||||
child: Image.network(image, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
if (image == null && imageData != null)
|
||||
SizedBox(
|
||||
height: 108,
|
||||
child: Image.memory(imageData, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -7,8 +7,11 @@ class PaneItemEntry {
|
||||
|
||||
IconData activeIcon;
|
||||
|
||||
PaneItemEntry(
|
||||
{required this.label, required this.icon, required this.activeIcon});
|
||||
PaneItemEntry({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
});
|
||||
}
|
||||
|
||||
class PaneActionEntry {
|
||||
@@ -18,20 +21,24 @@ class PaneActionEntry {
|
||||
|
||||
VoidCallback onTap;
|
||||
|
||||
PaneActionEntry(
|
||||
{required this.label, required this.icon, required this.onTap});
|
||||
PaneActionEntry({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
class NaviPane extends StatefulWidget {
|
||||
const NaviPane(
|
||||
{required this.paneItems,
|
||||
required this.paneActions,
|
||||
required this.pageBuilder,
|
||||
this.initialPage = 0,
|
||||
this.onPageChanged,
|
||||
required this.observer,
|
||||
required this.navigatorKey,
|
||||
super.key});
|
||||
const NaviPane({
|
||||
required this.paneItems,
|
||||
required this.paneActions,
|
||||
required this.pageBuilder,
|
||||
this.initialPage = 0,
|
||||
this.onPageChanged,
|
||||
required this.observer,
|
||||
required this.navigatorKey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<PaneItemEntry> paneItems;
|
||||
|
||||
@@ -187,7 +194,8 @@ class NaviPaneState extends State<NaviPane>
|
||||
child: buildLeft(),
|
||||
),
|
||||
Positioned.fill(
|
||||
left: _kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
||||
left:
|
||||
_kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) *
|
||||
((value - 2).clamp(0, 1)),
|
||||
child: buildMainView(),
|
||||
@@ -202,14 +210,19 @@ class NaviPaneState extends State<NaviPane>
|
||||
Widget buildMainView() {
|
||||
return HeroControllerScope(
|
||||
controller: MaterialApp.createMaterialHeroController(),
|
||||
child: Navigator(
|
||||
observers: [widget.observer],
|
||||
key: widget.navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
preventRebuild: false,
|
||||
builder: (context) {
|
||||
return _NaviMainView(state: this);
|
||||
},
|
||||
child: NavigatorPopHandler(
|
||||
onPopWithResult: (result) {
|
||||
widget.navigatorKey.currentState?.maybePop(result);
|
||||
},
|
||||
child: Navigator(
|
||||
observers: [widget.observer],
|
||||
key: widget.navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
preventRebuild: false,
|
||||
builder: (context) {
|
||||
return _NaviMainView(state: this);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -239,7 +252,7 @@ class NaviPaneState extends State<NaviPane>
|
||||
icon: Icon(action.icon),
|
||||
onPressed: action.onTap,
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -261,21 +274,18 @@ class NaviPaneState extends State<NaviPane>
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: List<Widget>.generate(
|
||||
widget.paneItems.length,
|
||||
(index) {
|
||||
return Expanded(
|
||||
child: _SingleBottomNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
onTap: () {
|
||||
updatePage(index);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
children: List<Widget>.generate(widget.paneItems.length, (index) {
|
||||
return Expanded(
|
||||
child: _SingleBottomNaviWidget(
|
||||
enabled: currentPage == index,
|
||||
entry: widget.paneItems[index],
|
||||
onTap: () {
|
||||
updatePage(index);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -286,7 +296,8 @@ class NaviPaneState extends State<NaviPane>
|
||||
const paddingHorizontal = 12.0;
|
||||
return Material(
|
||||
child: Container(
|
||||
width: _kFoldedSideBarWidth +
|
||||
width:
|
||||
_kFoldedSideBarWidth +
|
||||
(_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)),
|
||||
height: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal),
|
||||
@@ -323,9 +334,7 @@ class NaviPaneState extends State<NaviPane>
|
||||
key: ValueKey(index + widget.paneItems.length),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
)
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -334,12 +343,13 @@ class NaviPaneState extends State<NaviPane>
|
||||
}
|
||||
|
||||
class _SideNaviWidget extends StatelessWidget {
|
||||
const _SideNaviWidget(
|
||||
{required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
required this.showTitle,
|
||||
super.key});
|
||||
const _SideNaviWidget({
|
||||
required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
required this.showTitle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@@ -368,18 +378,18 @@ class _SideNaviWidget extends StatelessWidget {
|
||||
? Row(
|
||||
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: icon,
|
||||
),
|
||||
: Align(alignment: Alignment.centerLeft, child: icon),
|
||||
),
|
||||
).paddingVertical(4);
|
||||
}
|
||||
}
|
||||
|
||||
class _PaneActionWidget extends StatelessWidget {
|
||||
const _PaneActionWidget(
|
||||
{required this.entry, required this.showTitle, super.key});
|
||||
const _PaneActionWidget({
|
||||
required this.entry,
|
||||
required this.showTitle,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final PaneActionEntry entry;
|
||||
|
||||
@@ -399,21 +409,19 @@ class _PaneActionWidget extends StatelessWidget {
|
||||
? Row(
|
||||
children: [icon, const SizedBox(width: 12), Text(entry.label)],
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: icon,
|
||||
),
|
||||
: Align(alignment: Alignment.centerLeft, child: icon),
|
||||
),
|
||||
).paddingVertical(4);
|
||||
}
|
||||
}
|
||||
|
||||
class _SingleBottomNaviWidget extends StatefulWidget {
|
||||
const _SingleBottomNaviWidget(
|
||||
{required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
super.key});
|
||||
const _SingleBottomNaviWidget({
|
||||
required this.enabled,
|
||||
required this.entry,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
|
||||
@@ -482,8 +490,9 @@ class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
|
||||
Widget buildContent() {
|
||||
final value = controller.value;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final icon =
|
||||
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon);
|
||||
final icon = Icon(
|
||||
widget.enabled ? widget.entry.activeIcon : widget.entry.icon,
|
||||
);
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 64,
|
||||
@@ -570,8 +579,11 @@ class NaviObserver extends NavigatorObserver implements Listenable {
|
||||
}
|
||||
|
||||
class _NaviPopScope extends StatelessWidget {
|
||||
const _NaviPopScope(
|
||||
{required this.child, this.popGesture = false, required this.action});
|
||||
const _NaviPopScope({
|
||||
required this.child,
|
||||
this.popGesture = false,
|
||||
required this.action,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final bool popGesture;
|
||||
@@ -581,32 +593,25 @@ class _NaviPopScope extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget res = App.isIOS
|
||||
? child
|
||||
: PopScope(
|
||||
canPop: App.isAndroid ? false : true,
|
||||
onPopInvokedWithResult: (value, result) {
|
||||
action();
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
Widget res = child;
|
||||
if (popGesture) {
|
||||
res = GestureDetector(
|
||||
onPanStart: (details) {
|
||||
if (details.globalPosition.dx < 64) {
|
||||
panStartAtEdge = true;
|
||||
onPanStart: (details) {
|
||||
if (details.globalPosition.dx < 64) {
|
||||
panStartAtEdge = true;
|
||||
}
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dx < 0 ||
|
||||
details.velocity.pixelsPerSecond.dx > 0) {
|
||||
if (panStartAtEdge) {
|
||||
action();
|
||||
}
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
if (details.velocity.pixelsPerSecond.dx < 0 ||
|
||||
details.velocity.pixelsPerSecond.dx > 0) {
|
||||
if (panStartAtEdge) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
panStartAtEdge = false;
|
||||
},
|
||||
child: res);
|
||||
}
|
||||
panStartAtEdge = false;
|
||||
},
|
||||
child: res,
|
||||
);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -117,16 +117,25 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
_futurePosition ??= currentLocation;
|
||||
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
|
||||
_futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k;
|
||||
var beforeOffset = (_futurePosition! - currentLocation).abs();
|
||||
_futurePosition = _futurePosition!.clamp(
|
||||
_controller.position.minScrollExtent,
|
||||
_controller.position.maxScrollExtent,
|
||||
);
|
||||
var afterOffset = (_futurePosition! - currentLocation).abs();
|
||||
if (_futurePosition == old) return;
|
||||
var target = _futurePosition!;
|
||||
var duration = _fastAnimationDuration;
|
||||
if (afterOffset < beforeOffset) {
|
||||
duration = duration * (afterOffset / beforeOffset);
|
||||
if (duration < Duration(milliseconds: 10)) {
|
||||
duration = Duration(milliseconds: 10);
|
||||
}
|
||||
}
|
||||
_controller
|
||||
.animateTo(
|
||||
_futurePosition!,
|
||||
duration: _fastAnimationDuration,
|
||||
duration: duration,
|
||||
curve: Curves.linear,
|
||||
)
|
||||
.then((_) {
|
||||
@@ -228,7 +237,7 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
|
||||
double viewHeight = 0;
|
||||
|
||||
final _scrollIndicatorSize = App.isDesktop ? 42.0 : 64.0;
|
||||
final _scrollIndicatorSize = App.isDesktop ? 36.0 : 54.0;
|
||||
|
||||
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
|
||||
|
||||
@@ -345,7 +354,7 @@ class _ScrollIndicatorPainter extends CustomPainter {
|
||||
Offset(size.width, 0),
|
||||
radius: Radius.circular(size.width),
|
||||
);
|
||||
canvas.drawShadow(path, shadowColor, 4, true);
|
||||
canvas.drawShadow(path, shadowColor, 2, true);
|
||||
var backgroundPaint = Paint()
|
||||
..color = backgroundColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.4.6";
|
||||
final version = "1.5.3";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
@@ -30,6 +30,10 @@ class _App {
|
||||
|
||||
bool get isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
// Whether the app has been initialized.
|
||||
// If current Isolate is main Isolate, this value is always true.
|
||||
bool isInitialized = false;
|
||||
|
||||
Locale get locale {
|
||||
Locale deviceLocale = PlatformDispatcher.instance.locale;
|
||||
if (deviceLocale.languageCode == "zh" &&
|
||||
@@ -81,6 +85,7 @@ class _App {
|
||||
if (isAndroid) {
|
||||
externalStoragePath = (await getExternalStorageDirectory())!.path;
|
||||
}
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
Future<void> initComponents() async {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
|
||||
@@ -115,12 +116,19 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
||||
return SlidePageTransitionBuilder().buildTransitions(
|
||||
PageTransitionsBuilder builder;
|
||||
if (App.isAndroid) {
|
||||
builder = PredictiveBackPageTransitionsBuilder();
|
||||
} else {
|
||||
builder = SlidePageTransitionBuilder();
|
||||
}
|
||||
|
||||
return builder.buildTransitions(
|
||||
this,
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
enableIOSGesture
|
||||
enableIOSGesture && App.isIOS
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
@@ -294,7 +302,7 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ class Appdata with Init {
|
||||
var data = jsonEncode(toJson());
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
await file.writeAsString(data);
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
_isSavingData = false;
|
||||
}
|
||||
if (sync) {
|
||||
@@ -57,10 +56,7 @@ class Appdata with Init {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'settings': settings._data,
|
||||
'searchHistory': searchHistory,
|
||||
};
|
||||
return {'settings': settings._data, 'searchHistory': searchHistory};
|
||||
}
|
||||
|
||||
/// Following fields are related to device-specific data and should not be synced.
|
||||
@@ -95,8 +91,7 @@ class Appdata with Init {
|
||||
try {
|
||||
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
|
||||
await file.writeAsString(jsonEncode(implicitData));
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
_isSavingData = false;
|
||||
}
|
||||
}
|
||||
@@ -104,10 +99,7 @@ class Appdata with Init {
|
||||
@override
|
||||
Future<void> doInit() async {
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
var file = File(FilePath.join(
|
||||
dataPath,
|
||||
'appdata.json',
|
||||
));
|
||||
var file = File(FilePath.join(dataPath, 'appdata.json'));
|
||||
if (!await file.exists()) {
|
||||
return;
|
||||
}
|
||||
@@ -119,8 +111,7 @@ class Appdata with Init {
|
||||
}
|
||||
}
|
||||
searchHistory = List.from(json['searchHistory']);
|
||||
}
|
||||
catch(e) {
|
||||
} catch (e) {
|
||||
Log.error("Appdata", "Failed to load appdata", e);
|
||||
Log.info("Appdata", "Resetting appdata");
|
||||
file.deleteIgnoreError();
|
||||
@@ -130,8 +121,7 @@ class Appdata with Init {
|
||||
if (await implicitDataFile.exists()) {
|
||||
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
Log.error("Appdata", "Failed to load implicit data", e);
|
||||
Log.info("Appdata", "Resetting implicit data");
|
||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||
@@ -199,6 +189,11 @@ class Settings with ChangeNotifier {
|
||||
'enableDoubleTapToZoom': true,
|
||||
'reverseChapterOrder': false,
|
||||
'showSystemStatusBar': false,
|
||||
'comicSpecificSettings': <String, Map<String, dynamic>>{},
|
||||
'ignoreBadCertificate': false,
|
||||
'readerScrollSpeed': 1.0, // 0.5 - 3.0
|
||||
'localFavoritesFirst': true,
|
||||
'autoCloseFavoritePanel': false,
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
@@ -212,6 +207,43 @@ class Settings with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void setEnabledComicSpecificSettings(String comicId, String sourceKey, bool enabled) {
|
||||
setReaderSetting(comicId, sourceKey, "enabled", enabled);
|
||||
}
|
||||
|
||||
bool isComicSpecificSettingsEnabled(String? comicId, String? sourceKey) {
|
||||
if (comicId == null || sourceKey == null) {
|
||||
return false;
|
||||
}
|
||||
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] == true;
|
||||
}
|
||||
|
||||
dynamic getReaderSetting(String comicId, String sourceKey, String key) {
|
||||
if (!isComicSpecificSettingsEnabled(comicId, sourceKey)) {
|
||||
return _data[key];
|
||||
}
|
||||
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?[key] ??
|
||||
_data[key];
|
||||
}
|
||||
|
||||
void setReaderSetting(
|
||||
String comicId,
|
||||
String sourceKey,
|
||||
String key,
|
||||
dynamic value,
|
||||
) {
|
||||
(_data['comicSpecificSettings'] as Map<String, dynamic>).putIfAbsent(
|
||||
"$comicId@$sourceKey",
|
||||
() => <String, dynamic>{},
|
||||
)[key] = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetComicReaderSettings(String key) {
|
||||
(_data['comicSpecificSettings'] as Map).remove(key);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _data.toString();
|
||||
@@ -236,4 +268,5 @@ function processImage(image, cid, eid, page, sourceKey) {
|
||||
}
|
||||
''';
|
||||
|
||||
const _defaultSourceListUrl = "https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json";
|
||||
const _defaultSourceListUrl =
|
||||
"https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json";
|
||||
|
||||
@@ -401,9 +401,14 @@ class SearchOptions {
|
||||
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
|
||||
String category, String? param, List<String> options, int page);
|
||||
|
||||
typedef CategoryOptionsLoader = Future<Res<List<CategoryComicsOptions>>> Function(
|
||||
String category, String? param);
|
||||
|
||||
class CategoryComicsData {
|
||||
/// options
|
||||
final List<CategoryComicsOptions> options;
|
||||
final List<CategoryComicsOptions>? options;
|
||||
|
||||
final CategoryOptionsLoader? optionsLoader;
|
||||
|
||||
/// [category] is the one clicked by the user on the category page.
|
||||
///
|
||||
@@ -414,7 +419,7 @@ class CategoryComicsData {
|
||||
|
||||
final RankingData? rankingData;
|
||||
|
||||
const CategoryComicsData(this.options, this.load, {this.rankingData});
|
||||
const CategoryComicsData({this.options, this.optionsLoader, required this.load, this.rankingData});
|
||||
}
|
||||
|
||||
class RankingData {
|
||||
@@ -429,6 +434,9 @@ class RankingData {
|
||||
}
|
||||
|
||||
class CategoryComicsOptions {
|
||||
// The label will not be displayed if it is empty.
|
||||
final String label;
|
||||
|
||||
/// Use a [LinkedHashMap] to describe an option list.
|
||||
/// key is for loading comics, value is the name displayed on screen.
|
||||
/// Default value will be the first of the Map.
|
||||
@@ -439,7 +447,7 @@ class CategoryComicsOptions {
|
||||
|
||||
final List<String>? showWhen;
|
||||
|
||||
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
|
||||
const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen);
|
||||
}
|
||||
|
||||
class LinkHandler {
|
||||
|
||||
@@ -64,8 +64,13 @@ class ComicSourceParser {
|
||||
if (file.existsSync()) {
|
||||
int i = 0;
|
||||
while (file.existsSync()) {
|
||||
file = File(FilePath.join(App.dataPath, "comic_source",
|
||||
"${fileName.split('.').first}($i).js"));
|
||||
file = File(
|
||||
FilePath.join(
|
||||
App.dataPath,
|
||||
"comic_source",
|
||||
"${fileName.split('.').first}($i).js",
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
@@ -80,8 +85,9 @@ class ComicSourceParser {
|
||||
|
||||
Future<ComicSource> parse(String js, String filePath) async {
|
||||
js = js.replaceAll("\r\n", "\n");
|
||||
var line1 =
|
||||
js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class "));
|
||||
var line1 = js
|
||||
.split('\n')
|
||||
.firstWhereOrNull((e) => e.trim().startsWith("class "));
|
||||
if (line1 == null ||
|
||||
!line1.startsWith("class ") ||
|
||||
!line1.contains("extends ComicSource")) {
|
||||
@@ -89,24 +95,27 @@ class ComicSourceParser {
|
||||
}
|
||||
var className = line1.split("class")[1].split("extends ComicSource").first;
|
||||
className = className.trim();
|
||||
JsEngine().runCode("""
|
||||
(() => { $js
|
||||
JsEngine().runCode("""(() => { $js
|
||||
this['temp'] = new $className()
|
||||
}).call()
|
||||
""", className);
|
||||
_name = JsEngine().runCode("this['temp'].name") ??
|
||||
_name =
|
||||
JsEngine().runCode("this['temp'].name") ??
|
||||
(throw ComicSourceParseException('name is required'));
|
||||
var key = JsEngine().runCode("this['temp'].key") ??
|
||||
var key =
|
||||
JsEngine().runCode("this['temp'].key") ??
|
||||
(throw ComicSourceParseException('key is required'));
|
||||
var version = JsEngine().runCode("this['temp'].version") ??
|
||||
var version =
|
||||
JsEngine().runCode("this['temp'].version") ??
|
||||
(throw ComicSourceParseException('version is required'));
|
||||
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
|
||||
var url = JsEngine().runCode("this['temp'].url");
|
||||
if (minAppVersion != null) {
|
||||
if (compareSemVer(minAppVersion, App.version.split('-').first)) {
|
||||
throw ComicSourceParseException(
|
||||
"minAppVersion @version is required"
|
||||
.tlParams({"version": minAppVersion}),
|
||||
"minAppVersion @version is required".tlParams({
|
||||
"version": minAppVersion,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -175,8 +184,10 @@ class ComicSourceParser {
|
||||
}
|
||||
|
||||
bool _checkExists(String index) {
|
||||
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
|
||||
"&& ComicSource.sources.$_key.$index !== undefined");
|
||||
return JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.$index !== null "
|
||||
"&& ComicSource.sources.$_key.$index !== undefined",
|
||||
);
|
||||
}
|
||||
|
||||
dynamic _getValue(String index) {
|
||||
@@ -277,16 +288,24 @@ class ComicSourceParser {
|
||||
if (type == "singlePageWithMultiPart") {
|
||||
loadMultiPart = () async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load()");
|
||||
return Res(List.from(res.keys
|
||||
.map((e) => ExplorePagePart(
|
||||
e,
|
||||
(res[e] as List)
|
||||
.map<Comic>((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
null))
|
||||
.toList()));
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load()",
|
||||
);
|
||||
return Res(
|
||||
List.from(
|
||||
res.keys
|
||||
.map(
|
||||
(e) => ExplorePagePart(
|
||||
e,
|
||||
(res[e] as List)
|
||||
.map<Comic>((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
null,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Data Analysis", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -297,11 +316,15 @@ class ComicSourceParser {
|
||||
loadPage = (int page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})",
|
||||
);
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -311,10 +334,13 @@ class ComicSourceParser {
|
||||
loadNext = (next) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})");
|
||||
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})",
|
||||
);
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -326,8 +352,9 @@ class ComicSourceParser {
|
||||
} else if (type == "multiPartPage") {
|
||||
loadMultiPart = () async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load()");
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load()",
|
||||
);
|
||||
return Res(
|
||||
List.from(
|
||||
(res as List).map((e) {
|
||||
@@ -350,19 +377,22 @@ class ComicSourceParser {
|
||||
loadMixed = (index) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode(
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})");
|
||||
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})",
|
||||
);
|
||||
var list = <Object>[];
|
||||
for (var data in (res['data'] as List)) {
|
||||
if (data is List) {
|
||||
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
|
||||
} else if (data is Map) {
|
||||
list.add(ExplorePagePart(
|
||||
data['title'],
|
||||
(data['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
data['viewMore'],
|
||||
));
|
||||
list.add(
|
||||
ExplorePagePart(
|
||||
data['title'],
|
||||
(data['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
data['viewMore'],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Res(list, subData: res['maxPage']);
|
||||
@@ -372,21 +402,25 @@ class ComicSourceParser {
|
||||
}
|
||||
};
|
||||
}
|
||||
pages.add(ExplorePageData(
|
||||
title,
|
||||
switch (type) {
|
||||
"singlePageWithMultiPart" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPageComicList" => ExplorePageType.multiPageComicList,
|
||||
"mixed" => ExplorePageType.mixed,
|
||||
_ =>
|
||||
throw ComicSourceParseException("Unknown explore page type $type")
|
||||
},
|
||||
loadPage,
|
||||
loadNext,
|
||||
loadMultiPart,
|
||||
loadMixed,
|
||||
));
|
||||
pages.add(
|
||||
ExplorePageData(
|
||||
title,
|
||||
switch (type) {
|
||||
"singlePageWithMultiPart" =>
|
||||
ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPartPage" => ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPageComicList" => ExplorePageType.multiPageComicList,
|
||||
"mixed" => ExplorePageType.mixed,
|
||||
_ => throw ComicSourceParseException(
|
||||
"Unknown explore page type $type",
|
||||
),
|
||||
},
|
||||
loadPage,
|
||||
loadNext,
|
||||
loadMultiPart,
|
||||
loadMixed,
|
||||
),
|
||||
);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
@@ -426,18 +460,17 @@ class ComicSourceParser {
|
||||
if (type == "fixed") {
|
||||
categoryParts.add(FixedCategoryPart(name, cs!));
|
||||
} else if (type == "random") {
|
||||
categoryParts
|
||||
.add(RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1));
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1),
|
||||
);
|
||||
} else if (type == "dynamic" && categories == null) {
|
||||
var loader = c["loader"];
|
||||
if (loader is! JSInvokable) {
|
||||
throw "DynamicCategoryPart loader must be a function";
|
||||
}
|
||||
categoryParts.add(DynamicCategoryPart(
|
||||
name,
|
||||
JSAutoFreeFunction(loader),
|
||||
_key!,
|
||||
));
|
||||
categoryParts.add(
|
||||
DynamicCategoryPart(name, JSAutoFreeFunction(loader), _key!),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// old format
|
||||
@@ -454,30 +487,16 @@ class ComicSourceParser {
|
||||
for (int i = 0; i < tags.length; i++) {
|
||||
PageJumpTarget target;
|
||||
if (itemType == 'category') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'category',
|
||||
{
|
||||
"category": tags[i],
|
||||
"param": categoryParams?.elementAtOrNull(i),
|
||||
},
|
||||
);
|
||||
target = PageJumpTarget(_key!, 'category', {
|
||||
"category": tags[i],
|
||||
"param": categoryParams?.elementAtOrNull(i),
|
||||
});
|
||||
} else if (itemType == 'search') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'search',
|
||||
{
|
||||
"keyword": tags[i],
|
||||
},
|
||||
);
|
||||
target = PageJumpTarget(_key!, 'search', {"keyword": tags[i]});
|
||||
} else if (itemType == 'search_with_namespace') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'search',
|
||||
{
|
||||
"keyword": "$name:$tags[i]",
|
||||
},
|
||||
);
|
||||
target = PageJumpTarget(_key!, 'search', {
|
||||
"keyword": "$name:$tags[i]",
|
||||
});
|
||||
} else {
|
||||
target = PageJumpTarget(_key!, itemType, null);
|
||||
}
|
||||
@@ -486,38 +505,96 @@ class ComicSourceParser {
|
||||
if (type == "fixed") {
|
||||
categoryParts.add(FixedCategoryPart(name, cs));
|
||||
} else if (type == "random") {
|
||||
categoryParts
|
||||
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1));
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, cs, c["randomNumber"] ?? 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CategoryData(
|
||||
title: title,
|
||||
categories: categoryParts,
|
||||
enableRankingPage: enableRankingPage ?? false,
|
||||
key: title);
|
||||
title: title,
|
||||
categories: categoryParts,
|
||||
enableRankingPage: enableRankingPage ?? false,
|
||||
key: title,
|
||||
);
|
||||
}
|
||||
|
||||
CategoryComicsData? _loadCategoryComicsData() {
|
||||
if (!_checkExists("categoryComics")) return null;
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in _getValue("categoryComics.optionList") ?? []) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
|
||||
List<CategoryComicsOptions>? options;
|
||||
if (_checkExists("categoryComics.optionList")) {
|
||||
options = <CategoryComicsOptions>[];
|
||||
for (var element in _getValue("categoryComics.optionList") ?? []) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
options.add(
|
||||
CategoryComicsOptions(
|
||||
element["label"] ?? "",
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
options.add(CategoryComicsOptions(
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"])));
|
||||
}
|
||||
|
||||
CategoryOptionsLoader? optionLoader;
|
||||
if (_checkExists("categoryComics.optionLoader")) {
|
||||
optionLoader = (category, param) async {
|
||||
try {
|
||||
dynamic res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.optionLoader(
|
||||
${jsonEncode(category)}, ${jsonEncode(param)})
|
||||
""");
|
||||
if (res is Future) {
|
||||
res = await res;
|
||||
}
|
||||
if (res is! List) {
|
||||
return Res.error("Invalid data:\nExpected: List\nGot: ${res.runtimeType}");
|
||||
}
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in res) {
|
||||
if (element is! Map) {
|
||||
return Res.error("Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}");
|
||||
}
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"] ?? []) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(
|
||||
CategoryComicsOptions(
|
||||
element["label"] ?? "",
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Res(options);
|
||||
}
|
||||
catch(e) {
|
||||
Log.error("Data Analysis", "Failed to load category options.\n$e");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
RankingData? rankingData;
|
||||
if (_checkExists("categoryComics.ranking")) {
|
||||
var options = <String, String>{};
|
||||
@@ -532,7 +609,7 @@ class ComicSourceParser {
|
||||
}
|
||||
Future<Res<List<Comic>>> Function(String option, int page)? load;
|
||||
Future<Res<List<Comic>>> Function(String option, String? next)?
|
||||
loadWithNext;
|
||||
loadWithNext;
|
||||
if (_checkExists("categoryComics.ranking.load")) {
|
||||
load = (option, page) async {
|
||||
try {
|
||||
@@ -541,9 +618,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(option)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -557,8 +637,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(option)}, ${jsonEncode(next)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -569,25 +651,38 @@ class ComicSourceParser {
|
||||
}
|
||||
rankingData = RankingData(options, load, loadWithNext);
|
||||
}
|
||||
return CategoryComicsData(options, (category, param, options, page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}, rankingData: rankingData);
|
||||
|
||||
if (options == null && optionLoader == null) {
|
||||
options = [];
|
||||
}
|
||||
|
||||
return CategoryComicsData(
|
||||
options: options,
|
||||
optionsLoader: optionLoader,
|
||||
load: (category, param, options, page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
return Res(
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
},
|
||||
rankingData: rankingData,
|
||||
);
|
||||
}
|
||||
|
||||
SearchPageData? _loadSearchData() {
|
||||
@@ -604,12 +699,14 @@ class ComicSourceParser {
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(SearchOptions(
|
||||
map,
|
||||
element["label"],
|
||||
element['type'] ?? 'select',
|
||||
element['default'] == null ? null : jsonEncode(element['default']),
|
||||
));
|
||||
options.add(
|
||||
SearchOptions(
|
||||
map,
|
||||
element["label"],
|
||||
element['type'] ?? 'select',
|
||||
element['default'] == null ? null : jsonEncode(element['default']),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
SearchFunction? loadPage;
|
||||
@@ -624,9 +721,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -640,8 +740,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -690,8 +792,9 @@ class ComicSourceParser {
|
||||
|
||||
final bool multiFolder = _getValue("favorites.multiFolder");
|
||||
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
|
||||
final bool? singleFolderForSingleComic =
|
||||
_getValue("favorites.singleFolderForSingleComic");
|
||||
final bool? singleFolderForSingleComic = _getValue(
|
||||
"favorites.singleFolderForSingleComic",
|
||||
);
|
||||
|
||||
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
|
||||
if (!ComicSource.find(_key!)!.isLogged) {
|
||||
@@ -744,9 +847,12 @@ class ComicSourceParser {
|
||||
${jsonEncode(page)}, ${jsonEncode(folder)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -766,8 +872,10 @@ class ComicSourceParser {
|
||||
${jsonEncode(next)}, ${jsonEncode(folder)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
List.generate(
|
||||
res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!),
|
||||
),
|
||||
subData: res["next"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
@@ -858,8 +966,9 @@ class ComicSourceParser {
|
||||
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return Res(
|
||||
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
subData: res["maxPage"]);
|
||||
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
@@ -1114,7 +1223,8 @@ class ComicSourceParser {
|
||||
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
|
||||
""");
|
||||
return Res(
|
||||
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList());
|
||||
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList(),
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
@@ -213,12 +214,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
|
||||
late Map<String, int> counts;
|
||||
|
||||
var _hashedIds = <int, int>{};
|
||||
|
||||
int get totalComics {
|
||||
int total = 0;
|
||||
for (var t in counts.values) {
|
||||
total += t;
|
||||
}
|
||||
return total;
|
||||
return _hashedIds.length;
|
||||
}
|
||||
|
||||
int folderComics(String folder) {
|
||||
@@ -280,6 +279,48 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
for (var folder in folderNames) {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
_initHashedIds(folderNames, _db.handle).then((value) {
|
||||
_hashedIds = value;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void refreshHashedIds() {
|
||||
_initHashedIds(folderNames, _db.handle).then((value) {
|
||||
_hashedIds = value;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void reduceHashedId(String id, int type) {
|
||||
var hash = id.hashCode ^ type;
|
||||
if (_hashedIds.containsKey(hash)) {
|
||||
if (_hashedIds[hash]! > 1) {
|
||||
_hashedIds[hash] = _hashedIds[hash]! - 1;
|
||||
} else {
|
||||
_hashedIds.remove(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<int, int>> _initHashedIds(
|
||||
List<String> folders, Pointer<void> p) {
|
||||
return Isolate.run(() {
|
||||
var db = sqlite3.fromPointer(p);
|
||||
var hashedIds = <int, int>{};
|
||||
for (var folder in folders) {
|
||||
var rows = db.select("""
|
||||
select id, type from "$folder";
|
||||
""");
|
||||
for (var row in rows) {
|
||||
var id = row["id"] as String;
|
||||
var type = row["type"] as int;
|
||||
var hash = id.hashCode ^ type;
|
||||
hashedIds[hash] = (hashedIds[hash] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return hashedIds;
|
||||
});
|
||||
}
|
||||
|
||||
List<String> find(String id, ComicType type) {
|
||||
@@ -559,7 +600,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
/// return true if success, false if already exists
|
||||
bool addComic(String folder, FavoriteItem comic,
|
||||
[int? order, String? updateTime]) {
|
||||
_modifiedAfterLastCache = true;
|
||||
if (!existsFolder(folder)) {
|
||||
throw Exception("Folder does not exists");
|
||||
}
|
||||
@@ -614,14 +654,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
} else {
|
||||
counts[folder] = counts[folder]! + 1;
|
||||
}
|
||||
var hash = comic.id.hashCode ^ comic.type.value;
|
||||
_hashedIds[hash] = (_hashedIds[hash] ?? 0) + 1;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void moveFavorite(
|
||||
String sourceFolder, String targetFolder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -655,8 +695,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
|
||||
void batchMoveFavorites(
|
||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -691,25 +729,15 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
_db.execute("COMMIT");
|
||||
|
||||
// Update counts
|
||||
if (counts[targetFolder] == null) {
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
} else {
|
||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||
}
|
||||
|
||||
if (counts[sourceFolder] != null) {
|
||||
counts[sourceFolder] = counts[sourceFolder]! - items.length;
|
||||
} else {
|
||||
counts[sourceFolder] = count(sourceFolder);
|
||||
}
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
counts[sourceFolder] = count(sourceFolder);
|
||||
refreshHashedIds();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchCopyFavorites(
|
||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -740,18 +768,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
_db.execute("COMMIT");
|
||||
|
||||
// Update counts
|
||||
if (counts[targetFolder] == null) {
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
} else {
|
||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||
}
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
refreshHashedIds();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// delete a folder
|
||||
void deleteFolder(String name) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("""
|
||||
drop table "$name";
|
||||
""");
|
||||
@@ -760,11 +784,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
where folder_name == ?;
|
||||
""", [name]);
|
||||
counts.remove(name);
|
||||
refreshHashedIds();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteComicWithId(String folder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
LocalFavoriteImageProvider.delete(id, type.value);
|
||||
_db.execute("""
|
||||
delete from "$folder"
|
||||
@@ -775,11 +799,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
} else {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
reduceHashedId(id, type.value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
try {
|
||||
for (var comic in comics) {
|
||||
@@ -800,11 +824,13 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
_db.execute("COMMIT");
|
||||
for (var comic in comics) {
|
||||
reduceHashedId(comic.id, comic.type.value);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
var folderNames = _getFolderNamesWithDB();
|
||||
try {
|
||||
@@ -824,6 +850,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
}
|
||||
initCounts();
|
||||
_db.execute("COMMIT");
|
||||
for (var comic in comics) {
|
||||
var hash = comic.id.hashCode ^ comic.type.value;
|
||||
_hashedIds.remove(hash);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -908,7 +938,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
markAsRead(id, type);
|
||||
return;
|
||||
}
|
||||
_modifiedAfterLastCache = true;
|
||||
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
|
||||
for (final folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
@@ -1029,28 +1058,9 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final _cachedFavoritedIds = <String, bool>{};
|
||||
|
||||
bool isExist(String id, ComicType type) {
|
||||
if (_modifiedAfterLastCache) {
|
||||
_cacheFavoritedIds();
|
||||
}
|
||||
return _cachedFavoritedIds.containsKey("$id@${type.value}");
|
||||
}
|
||||
|
||||
bool _modifiedAfterLastCache = true;
|
||||
|
||||
void _cacheFavoritedIds() {
|
||||
_modifiedAfterLastCache = false;
|
||||
_cachedFavoritedIds.clear();
|
||||
for (var folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select id, type from "$folder";
|
||||
""");
|
||||
for (var row in rows) {
|
||||
_cachedFavoritedIds["${row["id"]}@${row["type"]}"] = true;
|
||||
}
|
||||
}
|
||||
var hash = id.hashCode ^ type.value;
|
||||
return _hashedIds.containsKey(hash);
|
||||
}
|
||||
|
||||
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
|
||||
|
||||
191
lib/foundation/follow_updates.dart
Normal file
191
lib/foundation/follow_updates.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/channel.dart';
|
||||
|
||||
class ComicUpdateResult {
|
||||
final bool updated;
|
||||
final String? errorMessage;
|
||||
|
||||
ComicUpdateResult(this.updated, this.errorMessage);
|
||||
}
|
||||
|
||||
Future<ComicUpdateResult> updateComic(
|
||||
FavoriteItemWithUpdateInfo c, String folder) async {
|
||||
int retries = 3;
|
||||
while (true) {
|
||||
try {
|
||||
var comicSource = c.type.comicSource;
|
||||
if (comicSource == null) {
|
||||
return ComicUpdateResult(false, "Comic source not found");
|
||||
}
|
||||
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
|
||||
|
||||
var newTags = <String>[];
|
||||
for (var entry in newInfo.tags.entries) {
|
||||
const shouldIgnore = ['author', 'artist', 'time'];
|
||||
var namespace = entry.key;
|
||||
if (shouldIgnore.contains(namespace.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
for (var tag in entry.value) {
|
||||
newTags.add("$namespace:$tag");
|
||||
}
|
||||
}
|
||||
|
||||
var item = FavoriteItem(
|
||||
id: c.id,
|
||||
name: newInfo.title,
|
||||
coverPath: newInfo.cover,
|
||||
author: newInfo.subTitle ??
|
||||
newInfo.tags['author']?.firstOrNull ??
|
||||
c.author,
|
||||
type: c.type,
|
||||
tags: newTags,
|
||||
);
|
||||
|
||||
LocalFavoritesManager().updateInfo(folder, item, false);
|
||||
|
||||
var updated = false;
|
||||
var updateTime = newInfo.findUpdateTime();
|
||||
if (updateTime != null && updateTime != c.updateTime) {
|
||||
LocalFavoritesManager().updateUpdateTime(
|
||||
folder,
|
||||
c.id,
|
||||
c.type,
|
||||
updateTime,
|
||||
);
|
||||
updated = true;
|
||||
} else {
|
||||
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
|
||||
}
|
||||
return ComicUpdateResult(updated, null);
|
||||
} catch (e, s) {
|
||||
Log.error("Check Updates", e, s);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
retries--;
|
||||
if (retries == 0) {
|
||||
return ComicUpdateResult(false, e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateProgress {
|
||||
final int total;
|
||||
final int current;
|
||||
final int errors;
|
||||
final int updated;
|
||||
final FavoriteItemWithUpdateInfo? comic;
|
||||
final String? errorMessage;
|
||||
|
||||
UpdateProgress(this.total, this.current, this.errors, this.updated,
|
||||
[this.comic, this.errorMessage]);
|
||||
}
|
||||
|
||||
void updateFolderBase(
|
||||
String folder,
|
||||
StreamController<UpdateProgress> stream,
|
||||
bool ignoreCheckTime,
|
||||
) async {
|
||||
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
|
||||
int total = comics.length;
|
||||
int current = 0;
|
||||
int errors = 0;
|
||||
int updated = 0;
|
||||
|
||||
stream.add(UpdateProgress(total, current, errors, updated));
|
||||
|
||||
var comicsToUpdate = <FavoriteItemWithUpdateInfo>[];
|
||||
|
||||
for (var comic in comics) {
|
||||
if (!ignoreCheckTime) {
|
||||
var lastCheckTime = comic.lastCheckTime;
|
||||
if (lastCheckTime != null &&
|
||||
DateTime.now().difference(lastCheckTime).inDays < 1) {
|
||||
current++;
|
||||
stream.add(UpdateProgress(total, current, errors, updated));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
comicsToUpdate.add(comic);
|
||||
}
|
||||
|
||||
total = comicsToUpdate.length;
|
||||
current = 0;
|
||||
stream.add(UpdateProgress(total, current, errors, updated));
|
||||
|
||||
var channel = Channel<FavoriteItemWithUpdateInfo>(10);
|
||||
|
||||
// Producer
|
||||
() async {
|
||||
var c = 0;
|
||||
for (var comic in comicsToUpdate) {
|
||||
await channel.push(comic);
|
||||
c++;
|
||||
// Throttle
|
||||
if (c % 5 == 0) {
|
||||
var delay = c % 100 + 1;
|
||||
if (delay > 10) {
|
||||
delay = 10;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: delay));
|
||||
}
|
||||
}
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// Consumers
|
||||
var updateFutures = <Future>[];
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var f = () async {
|
||||
while (true) {
|
||||
var comic = await channel.pop();
|
||||
if (comic == null) {
|
||||
break;
|
||||
}
|
||||
var result = await updateComic(comic, folder);
|
||||
current++;
|
||||
if (result.updated) {
|
||||
updated++;
|
||||
}
|
||||
if (result.errorMessage != null) {
|
||||
errors++;
|
||||
}
|
||||
stream.add(UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
|
||||
}
|
||||
}();
|
||||
updateFutures.add(f);
|
||||
}
|
||||
|
||||
await Future.wait(updateFutures);
|
||||
|
||||
if (updated > 0) {
|
||||
LocalFavoritesManager().notifyChanges();
|
||||
}
|
||||
|
||||
stream.close();
|
||||
}
|
||||
|
||||
|
||||
Stream<UpdateProgress> updateFolder(String folder, bool ignoreCheckTime) {
|
||||
var stream = StreamController<UpdateProgress>();
|
||||
updateFolderBase(folder, stream, ignoreCheckTime);
|
||||
return stream.stream;
|
||||
}
|
||||
|
||||
Future<String> getUpdatedComicsAsJson(String folder) async {
|
||||
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
|
||||
var updatedComics = comics.where((c) => c.hasNewUpdate).toList();
|
||||
var jsonList = updatedComics.map((c) => {
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'coverUrl': c.coverPath,
|
||||
'author': c.author,
|
||||
'type': c.type.sourceKey,
|
||||
'updateTime': c.updateTime,
|
||||
'tags': c.tags,
|
||||
}).toList();
|
||||
return jsonEncode(jsonList);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async' show Future;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'base_image_provider.dart';
|
||||
@@ -11,7 +13,12 @@ class CachedImageProvider
|
||||
/// Image provider for normal image.
|
||||
///
|
||||
/// [url] is the url of the image. Local file path is also supported.
|
||||
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
|
||||
const CachedImageProvider(this.url, {
|
||||
this.headers,
|
||||
this.sourceKey,
|
||||
this.cid,
|
||||
this.fallbackToLocalCover = false,
|
||||
});
|
||||
|
||||
final String url;
|
||||
|
||||
@@ -21,6 +28,9 @@ class CachedImageProvider
|
||||
|
||||
final String? cid;
|
||||
|
||||
// Use local cover if network image fails to load.
|
||||
final bool fallbackToLocalCover;
|
||||
|
||||
static int loadingCount = 0;
|
||||
|
||||
static const _kMaxLoadingCount = 8;
|
||||
@@ -49,6 +59,24 @@ class CachedImageProvider
|
||||
}
|
||||
throw "Error: Empty response body.";
|
||||
}
|
||||
catch(e) {
|
||||
if (fallbackToLocalCover && sourceKey != null && cid != null) {
|
||||
final localComic = LocalManager().find(
|
||||
cid!,
|
||||
ComicType.fromKey(sourceKey!),
|
||||
);
|
||||
if (localComic != null) {
|
||||
var file = localComic.coverFile;
|
||||
if (await file.exists()) {
|
||||
var data = await file.readAsBytes();
|
||||
if (data.isNotEmpty) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
finally {
|
||||
loadingCount--;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import 'package:pointycastle/block/modes/ofb.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:venera/components/js_ui.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/js_pool.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/network/proxy.dart';
|
||||
@@ -68,6 +69,12 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
}
|
||||
|
||||
static Uint8List? _jsInitCache;
|
||||
|
||||
static void cacheJsInit(Uint8List jsInit) {
|
||||
_jsInitCache = jsInit;
|
||||
}
|
||||
|
||||
@override
|
||||
@protected
|
||||
Future<void> doInit() async {
|
||||
@@ -75,9 +82,11 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (App.isInitialized) {
|
||||
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
|
||||
}
|
||||
_dio ??= AppDio(BaseOptions(
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
_cookieJar ??= SingleInstanceCookieJar.instance!;
|
||||
_closed = false;
|
||||
_engine = FlutterQjs();
|
||||
_engine!.dispatch();
|
||||
@@ -86,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
|
||||
setGlobalFunc(["appVersion", App.version]);
|
||||
setGlobalFunc.free();
|
||||
var jsInit = await rootBundle.load("assets/init.js");
|
||||
Uint8List jsInit;
|
||||
if (_jsInitCache != null) {
|
||||
jsInit = _jsInitCache!;
|
||||
} else {
|
||||
var buffer = await rootBundle.load("assets/init.js");
|
||||
jsInit = buffer.buffer.asUint8List();
|
||||
}
|
||||
_engine!
|
||||
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>");
|
||||
.evaluate(utf8.decode(jsInit), name: "<init>");
|
||||
} catch (e, s) {
|
||||
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
|
||||
}
|
||||
@@ -97,6 +112,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
Object? _messageReceiver(dynamic message) {
|
||||
try {
|
||||
if (message is Map<dynamic, dynamic>) {
|
||||
if (message["method"] == null) return null;
|
||||
String method = message["method"] as String;
|
||||
switch (method) {
|
||||
case "log":
|
||||
@@ -172,6 +188,20 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
var res = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return res?.text;
|
||||
});
|
||||
case "compute":
|
||||
final func = message["function"];
|
||||
final args = message["args"];
|
||||
if (func is JSInvokable) {
|
||||
func.free();
|
||||
throw "Function must be a string";
|
||||
}
|
||||
if (func is! String) {
|
||||
throw "Function must be a string";
|
||||
}
|
||||
if (args != null && args is! List) {
|
||||
throw "Args must be a list";
|
||||
}
|
||||
return JSPool().execute(func, args ?? []);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
163
lib/foundation/js_pool.dart
Normal file
163
lib/foundation/js_pool.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
|
||||
class JSPool {
|
||||
static final int _maxInstances = 4;
|
||||
final List<IsolateJsEngine> _instances = [];
|
||||
bool _isInitializing = false;
|
||||
|
||||
static final JSPool _singleton = JSPool._internal();
|
||||
factory JSPool() {
|
||||
return _singleton;
|
||||
}
|
||||
JSPool._internal();
|
||||
|
||||
Future<void> init() async {
|
||||
if (_isInitializing) return;
|
||||
_isInitializing = true;
|
||||
var jsInitBuffer = await rootBundle.load("assets/init.js");
|
||||
var jsInit = jsInitBuffer.buffer.asUint8List();
|
||||
for (int i = 0; i < _maxInstances; i++) {
|
||||
_instances.add(IsolateJsEngine(jsInit));
|
||||
}
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
|
||||
await init();
|
||||
var selectedInstance = _instances[0];
|
||||
for (var instance in _instances) {
|
||||
if (instance.pendingTasks < selectedInstance.pendingTasks) {
|
||||
selectedInstance = instance;
|
||||
}
|
||||
}
|
||||
return selectedInstance.execute(jsFunction, args);
|
||||
}
|
||||
}
|
||||
|
||||
class _IsolateJsEngineInitParam {
|
||||
final SendPort sendPort;
|
||||
|
||||
final Uint8List jsInit;
|
||||
|
||||
_IsolateJsEngineInitParam(this.sendPort, this.jsInit);
|
||||
}
|
||||
|
||||
class IsolateJsEngine {
|
||||
Isolate? _isolate;
|
||||
|
||||
SendPort? _sendPort;
|
||||
ReceivePort? _receivePort;
|
||||
|
||||
int _counter = 0;
|
||||
final Map<int, Completer<dynamic>> _tasks = {};
|
||||
|
||||
bool _isClosed = false;
|
||||
|
||||
int get pendingTasks => _tasks.length;
|
||||
|
||||
IsolateJsEngine(Uint8List jsInit) {
|
||||
_receivePort = ReceivePort();
|
||||
_receivePort!.listen(_onMessage);
|
||||
Isolate.spawn(_run, _IsolateJsEngineInitParam(_receivePort!.sendPort, jsInit));
|
||||
}
|
||||
|
||||
void _onMessage(dynamic message) {
|
||||
if (message is SendPort) {
|
||||
_sendPort = message;
|
||||
} else if (message is TaskResult) {
|
||||
final completer = _tasks.remove(message.id);
|
||||
if (completer != null) {
|
||||
if (message.error != null) {
|
||||
completer.completeError(message.error!);
|
||||
} else {
|
||||
completer.complete(message.result);
|
||||
}
|
||||
}
|
||||
} else if (message is Exception) {
|
||||
Log.error("IsolateJsEngine", message.toString());
|
||||
for (var completer in _tasks.values) {
|
||||
completer.completeError(message);
|
||||
}
|
||||
_tasks.clear();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
static void _run(_IsolateJsEngineInitParam params) async {
|
||||
var sendPort = params.sendPort;
|
||||
final port = ReceivePort();
|
||||
sendPort.send(port.sendPort);
|
||||
final engine = JsEngine();
|
||||
try {
|
||||
JsEngine.cacheJsInit(params.jsInit);
|
||||
await engine.init();
|
||||
}
|
||||
catch(e, s) {
|
||||
sendPort.send(Exception("Failed to initialize JS engine: $e\n$s"));
|
||||
return;
|
||||
}
|
||||
await for (final message in port) {
|
||||
if (message is Task) {
|
||||
try {
|
||||
final jsFunc = engine.runCode(message.jsFunction);
|
||||
if (jsFunc is! JSInvokable) {
|
||||
throw Exception("The provided code does not evaluate to a function.");
|
||||
}
|
||||
final result = jsFunc.invoke(message.args);
|
||||
jsFunc.free();
|
||||
sendPort.send(TaskResult(message.id, result, null));
|
||||
} catch (e) {
|
||||
sendPort.send(TaskResult(message.id, null, e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
|
||||
if (_isClosed) {
|
||||
throw Exception("IsolateJsEngine is closed.");
|
||||
}
|
||||
while (_sendPort == null) {
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
final completer = Completer<dynamic>();
|
||||
final taskId = _counter++;
|
||||
_tasks[taskId] = completer;
|
||||
final task = Task(taskId, jsFunction, args);
|
||||
_sendPort?.send(task);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void close() async {
|
||||
if (!_isClosed) {
|
||||
_isClosed = true;
|
||||
while (_tasks.isNotEmpty) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
_receivePort?.close();
|
||||
_isolate?.kill(priority: Isolate.immediate);
|
||||
_isolate = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Task {
|
||||
final int id;
|
||||
final String jsFunction;
|
||||
final List<dynamic> args;
|
||||
|
||||
const Task(this.id, this.jsFunction, this.args);
|
||||
}
|
||||
|
||||
class TaskResult {
|
||||
final int id;
|
||||
final Object? result;
|
||||
final String? error;
|
||||
|
||||
const TaskResult(this.id, this.result, this.error);
|
||||
}
|
||||
@@ -423,6 +423,7 @@ class LocalManager with ChangeNotifier {
|
||||
if (comic.hasChapters) {
|
||||
var cid =
|
||||
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
|
||||
cid = getChapterDirectoryName(cid);
|
||||
directory = Directory(FilePath.join(directory.path, cid));
|
||||
}
|
||||
var files = <File>[];
|
||||
@@ -600,7 +601,10 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
var shouldRemovedDirs = <Directory>[];
|
||||
for (var chapter in chapters) {
|
||||
var dir = Directory(FilePath.join(c.baseDir, chapter));
|
||||
var dir = Directory(FilePath.join(
|
||||
c.baseDir,
|
||||
getChapterDirectoryName(chapter),
|
||||
));
|
||||
if (dir.existsSync()) {
|
||||
shouldRemovedDirs.add(dir);
|
||||
}
|
||||
@@ -668,6 +672,21 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static String getChapterDirectoryName(String name) {
|
||||
var builder = StringBuffer();
|
||||
for (var i = 0; i < name.length; i++) {
|
||||
var char = name[i];
|
||||
if (char == '/' || char == '\\' || char == ':' || char == '*' ||
|
||||
char == '?'
|
||||
|| char == '"' || char == '<' || char == '>' || char == '|') {
|
||||
builder.write('_');
|
||||
} else {
|
||||
builder.write(char);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalSortType {
|
||||
|
||||
@@ -28,6 +28,8 @@ class Log {
|
||||
|
||||
static bool ignoreLimitation = false;
|
||||
|
||||
static bool isMuted = false;
|
||||
|
||||
static void printWarning(String text) {
|
||||
debugPrint('\x1B[33m$text\x1B[0m');
|
||||
}
|
||||
@@ -39,7 +41,8 @@ class Log {
|
||||
static IOSink? _file;
|
||||
|
||||
static void addLog(LogLevel level, String title, String content) {
|
||||
if (_file == null) {
|
||||
if (isMuted) return;
|
||||
if (_file == null && App.isInitialized) {
|
||||
Directory dir;
|
||||
if (App.isAndroid) {
|
||||
dir = Directory(App.externalStoragePath!);
|
||||
|
||||
244
lib/headless.dart
Normal file
244
lib/headless.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/comic_source_page.dart';
|
||||
import 'package:venera/init.dart';
|
||||
import 'package:venera/foundation/follow_updates.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
|
||||
void cliPrint(Map<String, dynamic> data) {
|
||||
print('[CLI PRINT] ${jsonEncode(data)}');
|
||||
}
|
||||
|
||||
Future<void> runHeadlessMode(List<String> args) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if (args.contains('--ignore-disheadless-log')) {
|
||||
Log.isMuted = true;
|
||||
}
|
||||
if(Platform.isLinux || Platform.isMacOS){
|
||||
Directory.current = Platform.environment['HOME']!;
|
||||
}
|
||||
// The first arg is '--headless', so we look at the next ones.
|
||||
var commandIndex = args.indexOf('--headless') + 1;
|
||||
if (commandIndex >= args.length) {
|
||||
cliPrint({'status': 'error', 'message': 'No command provided for headless mode.'});
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Need to initialize the app for some features to work
|
||||
await init();
|
||||
|
||||
var command = args[commandIndex];
|
||||
var subCommand = (commandIndex + 1 < args.length) ? args[commandIndex + 1] : null;
|
||||
|
||||
switch (command) {
|
||||
case 'webdav':
|
||||
if (subCommand == 'up') {
|
||||
cliPrint({'status': 'running', 'message': 'Uploading WebDAV data...'});
|
||||
await DataSync().uploadData();
|
||||
cliPrint({'status': 'success', 'message': 'Upload complete.'});
|
||||
} else if (subCommand == 'down') {
|
||||
cliPrint({'status': 'running', 'message': 'Downloading WebDAV data...'});
|
||||
await DataSync().downloadData();
|
||||
cliPrint({'status': 'success', 'message': 'Download complete.'});
|
||||
} else {
|
||||
cliPrint({'status': 'error', 'message': 'Invalid webdav command. Use "up" or "down".'});
|
||||
exit(1);
|
||||
}
|
||||
break;
|
||||
case 'updatescript':
|
||||
if (subCommand == 'all') {
|
||||
cliPrint({'status': 'running', 'message': 'Checking for comic source script updates...'});
|
||||
await ComicSourcePage.checkComicSourceUpdate();
|
||||
var updates = ComicSourceManager().availableUpdates;
|
||||
if (updates.isEmpty) {
|
||||
cliPrint({'status': 'success', 'message': 'No updates found.'});
|
||||
} else {
|
||||
var total = updates.length;
|
||||
var current = 0;
|
||||
var errors = 0;
|
||||
var updated = 0;
|
||||
cliPrint({
|
||||
'status': 'running',
|
||||
'message': 'Updating all comic source scripts...',
|
||||
'data': {
|
||||
'total': total,
|
||||
'current': 0,
|
||||
'updated': 0,
|
||||
'errors': 0,
|
||||
}
|
||||
});
|
||||
for (var key in updates.keys) {
|
||||
var source = ComicSource.find(key);
|
||||
if (source != null) {
|
||||
current++;
|
||||
var data = {
|
||||
'current': current,
|
||||
'total': total,
|
||||
'source': {
|
||||
'key': source.key,
|
||||
'name': source.name,
|
||||
'version': source.version,
|
||||
'url': source.url,
|
||||
}
|
||||
};
|
||||
try {
|
||||
await ComicSourcePage.update(source, false);
|
||||
updated++;
|
||||
cliPrint({
|
||||
'status': 'running',
|
||||
'message': 'Progress',
|
||||
'data': data,
|
||||
});
|
||||
} catch (e) {
|
||||
errors++;
|
||||
cliPrint({
|
||||
'status': 'running',
|
||||
'message': 'ProgressError',
|
||||
'data': {
|
||||
...data,
|
||||
'error': e.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
cliPrint({
|
||||
'status': 'success',
|
||||
'message': 'All scripts updated.',
|
||||
'data': {
|
||||
'total': total,
|
||||
'updated': updated,
|
||||
'errors': errors,
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
cliPrint({'status': 'error', 'message': 'Invalid updatescript command. Use "all".'});
|
||||
exit(1);
|
||||
}
|
||||
break;
|
||||
case 'updatesubscribe':
|
||||
cliPrint({'status': 'running', 'message': 'Updating subscribed comics...'});
|
||||
var folder = appdata.settings["followUpdatesFolder"];
|
||||
if (folder == null) {
|
||||
cliPrint({'status': 'error', 'message': 'Follow updates folder is not configured.'});
|
||||
exit(1);
|
||||
}
|
||||
|
||||
var updateIndex = args.indexOf('--update-comic-by-id-type');
|
||||
if (updateIndex != -1) {
|
||||
var id = args[updateIndex + 1];
|
||||
var type = args[updateIndex + 2];
|
||||
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
|
||||
var comic = comics.firstWhere((c) => c.id == id && c.type.sourceKey == type);
|
||||
|
||||
var result = await updateComic(comic, folder);
|
||||
|
||||
Map<String, dynamic> data = {
|
||||
'current': 1,
|
||||
'total': 1,
|
||||
'comic': {
|
||||
'id': comic.id,
|
||||
'name': comic.name,
|
||||
'coverUrl': comic.coverPath,
|
||||
'author': comic.author,
|
||||
'type': comic.type.sourceKey,
|
||||
'updateTime': comic.updateTime,
|
||||
'tags': comic.tags,
|
||||
}
|
||||
};
|
||||
|
||||
var message = 'Progress';
|
||||
if (result.errorMessage != null) {
|
||||
message = 'ProgressError';
|
||||
data['error'] = result.errorMessage;
|
||||
}
|
||||
|
||||
cliPrint({
|
||||
'status': 'running',
|
||||
'message': message,
|
||||
'data': data,
|
||||
});
|
||||
|
||||
cliPrint({
|
||||
'status': 'running',
|
||||
'message': 'Update check complete.',
|
||||
'data': {
|
||||
'total': 1,
|
||||
'updated': result.updated ? 1 : 0,
|
||||
'errors': result.errorMessage != null ? 1 : 0,
|
||||
}
|
||||
});
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
var json = await getUpdatedComicsAsJson(folder);
|
||||
cliPrint({
|
||||
'status': result.errorMessage != null ? 'error' : 'success',
|
||||
'message': 'Updated comics list.',
|
||||
'data': jsonDecode(json),
|
||||
});
|
||||
} else {
|
||||
int total = 0;
|
||||
int updated = 0;
|
||||
int errors = 0;
|
||||
await for (var progress in updateFolder(folder, true)) {
|
||||
total = progress.total;
|
||||
updated = progress.updated;
|
||||
errors = progress.errors;
|
||||
Map<String, dynamic> data = {
|
||||
'current': progress.current,
|
||||
'total': progress.total,
|
||||
};
|
||||
if (progress.comic != null) {
|
||||
data['comic'] = {
|
||||
'id': progress.comic!.id,
|
||||
'name': progress.comic!.name,
|
||||
'coverUrl': progress.comic!.coverPath,
|
||||
'author': progress.comic!.author,
|
||||
'type': progress.comic!.type.sourceKey,
|
||||
'updateTime': progress.comic!.updateTime,
|
||||
'tags': progress.comic!.tags,
|
||||
};
|
||||
}
|
||||
var message = 'Progress';
|
||||
if (progress.errorMessage != null) {
|
||||
message = 'ProgressError';
|
||||
data['error'] = progress.errorMessage;
|
||||
}
|
||||
cliPrint({
|
||||
'status': 'running',
|
||||
'message': message,
|
||||
'data': data,
|
||||
});
|
||||
}
|
||||
cliPrint({
|
||||
'status': 'running',
|
||||
'message': 'Update check complete.',
|
||||
'data': {
|
||||
'total': total,
|
||||
'updated': updated,
|
||||
'errors': errors,
|
||||
}
|
||||
});
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
var json = await getUpdatedComicsAsJson(folder);
|
||||
cliPrint({
|
||||
'status': errors > 0 ? 'error' : 'success',
|
||||
'message': 'Updated comics list.',
|
||||
'data': jsonDecode(json),
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
cliPrint({'status': 'error', 'message': 'Unknown command: $command'});
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Exit after command execution
|
||||
exit(0);
|
||||
}
|
||||
@@ -37,17 +37,21 @@ extension _FutureInit<T> on Future<T> {
|
||||
Future<void> init() async {
|
||||
await App.init().wait();
|
||||
await SingleInstanceCookieJar.createInstance();
|
||||
var futures = [
|
||||
Rhttp.init(),
|
||||
App.initComponents(),
|
||||
SAFTaskWorker().init().wait(),
|
||||
AppTranslation.init().wait(),
|
||||
TagsTranslation.readData().wait(),
|
||||
JsEngine().init().wait(),
|
||||
ComicSourceManager().init().wait(),
|
||||
OpenCC.init(),
|
||||
];
|
||||
await Future.wait(futures);
|
||||
try {
|
||||
var futures = [
|
||||
Rhttp.init(),
|
||||
App.initComponents(),
|
||||
SAFTaskWorker().init().wait(),
|
||||
AppTranslation.init().wait(),
|
||||
TagsTranslation.readData().wait(),
|
||||
JsEngine().init().wait(),
|
||||
ComicSourceManager().init().wait(),
|
||||
OpenCC.init(),
|
||||
];
|
||||
await Future.wait(futures);
|
||||
} catch (e, s) {
|
||||
Log.error("init", "$e\n$s");
|
||||
}
|
||||
CacheManager().setLimitSize(appdata.settings['cacheSize']);
|
||||
_checkOldConfigs();
|
||||
if (App.isAndroid) {
|
||||
|
||||
@@ -14,9 +14,14 @@ import 'components/components.dart';
|
||||
import 'components/window_frame.dart';
|
||||
import 'foundation/app.dart';
|
||||
import 'foundation/appdata.dart';
|
||||
import 'headless.dart';
|
||||
import 'init.dart';
|
||||
|
||||
void main(List<String> args) {
|
||||
if (args.contains('--headless')) {
|
||||
runHeadlessMode(args);
|
||||
return;
|
||||
}
|
||||
if (runWebViewTitleBarWidget(args)) return;
|
||||
overrideIO(() {
|
||||
runZonedGuarded(() async {
|
||||
@@ -194,6 +199,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
tertiary = light.tertiary;
|
||||
}
|
||||
return MaterialApp(
|
||||
title: "venera",
|
||||
home: home,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: getTheme(primary, secondary, tertiary, Brightness.light),
|
||||
@@ -237,6 +243,27 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
);
|
||||
};
|
||||
if (widget != null) {
|
||||
/// 如果无法检测到状态栏高度设定指定高度
|
||||
/// https://github.com/flutter/flutter/issues/161086
|
||||
var isPaddingCheckError =
|
||||
MediaQuery.of(context).viewPadding.top <= 0 ||
|
||||
MediaQuery.of(context).viewPadding.top > 200;
|
||||
|
||||
if (isPaddingCheckError && Platform.isAndroid) {
|
||||
widget = MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
viewPadding: const EdgeInsets.only(
|
||||
top: 15,
|
||||
bottom: 15,
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
top: 15,
|
||||
bottom: 15,
|
||||
),
|
||||
),
|
||||
child: widget);
|
||||
}
|
||||
|
||||
widget = OverlayWidget(widget);
|
||||
if (App.isDesktop) {
|
||||
widget = Shortcuts(
|
||||
|
||||
@@ -112,10 +112,12 @@ class AppDio with DioMixin {
|
||||
AppDio([BaseOptions? options]) {
|
||||
this.options = options ?? BaseOptions();
|
||||
httpClientAdapter = RHttpAdapter();
|
||||
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
interceptors.add(NetworkCacheManager());
|
||||
interceptors.add(CloudflareInterceptor());
|
||||
interceptors.add(MyLogInterceptor());
|
||||
if (App.isInitialized) {
|
||||
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
interceptors.add(NetworkCacheManager());
|
||||
interceptors.add(CloudflareInterceptor());
|
||||
interceptors.add(MyLogInterceptor());
|
||||
}
|
||||
}
|
||||
|
||||
static final Map<String, bool> _requests = {};
|
||||
@@ -173,6 +175,7 @@ class RHttpAdapter implements HttpClientAdapter {
|
||||
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
|
||||
tlsSettings: rhttp.TlsSettings(
|
||||
sni: appdata.settings['sni'] != false,
|
||||
verifyCertificates: appdata.settings['ignoreBadCertificate'] != true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,9 +202,13 @@ class SingleInstanceCookieJar extends CookieJarSql {
|
||||
|
||||
static SingleInstanceCookieJar? instance;
|
||||
|
||||
static Future<void> createInstance() async {
|
||||
static Future<SingleInstanceCookieJar> createInstance() async {
|
||||
if (instance != null) {
|
||||
return instance!;
|
||||
}
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
|
||||
return instance!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,21 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
var local = LocalManager().find(id, comicType);
|
||||
if (path != null) {
|
||||
if (local == null) {
|
||||
Directory(path!).deleteIgnoreError(recursive: true);
|
||||
Future.sync(() async {
|
||||
var tasks = this.tasks.values.toList();
|
||||
for (var i = 0; i < tasks.length; i++) {
|
||||
if (!tasks[i].isComplete) {
|
||||
tasks[i].cancel();
|
||||
await tasks[i].wait();
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Directory(path!).delete(recursive: true);
|
||||
}
|
||||
catch(e) {
|
||||
Log.error("Download", "Failed to delete directory: $e");
|
||||
}
|
||||
});
|
||||
} else if (chapters != null) {
|
||||
for (var c in chapters!) {
|
||||
var dir = Directory(FilePath.join(path!, c));
|
||||
@@ -197,7 +211,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
if (comic!.chapters != null) {
|
||||
saveTo = Directory(FilePath.join(
|
||||
path!,
|
||||
_images!.keys.elementAt(_chapter),
|
||||
LocalManager.getChapterDirectoryName(
|
||||
_images!.keys.elementAt(_chapter),
|
||||
),
|
||||
));
|
||||
if (!saveTo.existsSync()) {
|
||||
saveTo.createSync(recursive: true);
|
||||
|
||||
@@ -71,7 +71,8 @@ abstract class ImageDownloader {
|
||||
}
|
||||
|
||||
if (configs['onResponse'] is JSInvokable) {
|
||||
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
|
||||
final uint8List = Uint8List.fromList(buffer);
|
||||
buffer = (configs['onResponse'] as JSInvokable)([uint8List]);
|
||||
(configs['onResponse'] as JSInvokable).free();
|
||||
}
|
||||
|
||||
@@ -180,12 +181,25 @@ abstract class ImageDownloader {
|
||||
}
|
||||
|
||||
if (configs['onResponse'] is JSInvokable) {
|
||||
buffer = (configs['onResponse'] as JSInvokable)([buffer]);
|
||||
dynamic result = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
|
||||
if (result is Future) {
|
||||
result = await result;
|
||||
}
|
||||
if (result is List<int>) {
|
||||
buffer = result;
|
||||
} else {
|
||||
throw "Error: Invalid onResponse result.";
|
||||
}
|
||||
(configs['onResponse'] as JSInvokable).free();
|
||||
}
|
||||
|
||||
var data = Uint8List.fromList(buffer);
|
||||
buffer.clear();
|
||||
Uint8List data;
|
||||
if (buffer is Uint8List) {
|
||||
data = buffer;
|
||||
} else {
|
||||
data = Uint8List.fromList(buffer);
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
if (configs['modifyImage'] != null) {
|
||||
var newData = await modifyImageWithScript(
|
||||
|
||||
@@ -17,39 +17,50 @@ class CategoriesPage extends StatefulWidget {
|
||||
State<CategoriesPage> createState() => _CategoriesPageState();
|
||||
}
|
||||
|
||||
class _CategoriesPageState extends State<CategoriesPage> {
|
||||
class _CategoriesPageState extends State<CategoriesPage>
|
||||
with
|
||||
TickerProviderStateMixin,
|
||||
AutomaticKeepAliveClientMixin<CategoriesPage> {
|
||||
var categories = <String>[];
|
||||
|
||||
late TabController controller;
|
||||
|
||||
void onSettingsChanged() {
|
||||
var categories =
|
||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||
var categories = List.from(
|
||||
appdata.settings["categories"],
|
||||
).whereType<String>().toList();
|
||||
var allCategories = ComicSource.all()
|
||||
.map((e) => e.categoryData?.key)
|
||||
.where((element) => element != null)
|
||||
.map((e) => e!)
|
||||
.toList();
|
||||
categories =
|
||||
categories.where((element) => allCategories.contains(element)).toList();
|
||||
categories = categories
|
||||
.where((element) => allCategories.contains(element))
|
||||
.toList();
|
||||
if (!categories.isEqualTo(this.categories)) {
|
||||
setState(() {
|
||||
this.categories = categories;
|
||||
});
|
||||
controller = TabController(length: categories.length, vsync: this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
var categories =
|
||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||
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();
|
||||
this.categories = categories
|
||||
.where((element) => allCategories.contains(element))
|
||||
.toList();
|
||||
appdata.settings.addListener(onSettingsChanged);
|
||||
controller = TabController(length: categories.length, vsync: this);
|
||||
}
|
||||
|
||||
void addPage() {
|
||||
@@ -59,6 +70,7 @@ class _CategoriesPageState extends State<CategoriesPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
controller.dispose();
|
||||
appdata.settings.removeListener(onSettingsChanged);
|
||||
}
|
||||
|
||||
@@ -85,46 +97,45 @@ class _CategoriesPageState extends State<CategoriesPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
if (categories.isEmpty) {
|
||||
return buildEmpty();
|
||||
}
|
||||
|
||||
return Material(
|
||||
child: DefaultTabController(
|
||||
length: categories.length,
|
||||
key: Key(categories.toString()),
|
||||
child: Column(
|
||||
children: [
|
||||
AppTabBar(
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
try {
|
||||
title = getCategoryDataWithKey(e).title;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return Tab(
|
||||
text: title,
|
||||
key: Key(e),
|
||||
);
|
||||
}).toList(),
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
AppTabBar(
|
||||
controller: controller,
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
try {
|
||||
title = getCategoryDataWithKey(e).title;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return Tab(text: title, key: Key(e));
|
||||
}).toList(),
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: controller,
|
||||
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
typedef ClickTagCallback = void Function(String, String?);
|
||||
@@ -150,38 +161,42 @@ class _CategoryPage extends StatelessWidget {
|
||||
var children = <Widget>[];
|
||||
if (data.enableRankingPage || data.buttons.isNotEmpty) {
|
||||
children.add(buildTitle(data.title));
|
||||
children.add(Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
children: [
|
||||
if (data.enableRankingPage)
|
||||
buildTag("Ranking".tl, () {
|
||||
context.to(() => RankingPage(categoryKey: data.key));
|
||||
}),
|
||||
for (var buttonData in data.buttons)
|
||||
buildTag(buttonData.label.tl, buttonData.onTap)
|
||||
],
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
children: [
|
||||
if (data.enableRankingPage)
|
||||
buildTag("Ranking".tl, () {
|
||||
context.to(() => RankingPage(categoryKey: data.key));
|
||||
}),
|
||||
for (var buttonData in data.buttons)
|
||||
buildTag(buttonData.label.tl, buttonData.onTap),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
for (var part in data.categories) {
|
||||
if (part.enableRandom) {
|
||||
children.add(StatefulBuilder(builder: (context, updater) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitleWithRefresh(part.title, () => updater(() {})),
|
||||
buildTags(part.categories)
|
||||
],
|
||||
);
|
||||
}));
|
||||
children.add(
|
||||
StatefulBuilder(
|
||||
builder: (context, updater) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitleWithRefresh(part.title, () => updater(() {})),
|
||||
buildTags(part.categories),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
children.add(buildTitle(part.title));
|
||||
children.add(
|
||||
buildTags(part.categories),
|
||||
);
|
||||
children.add(buildTags(part.categories));
|
||||
}
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
@@ -195,8 +210,10 @@ class _CategoryPage extends StatelessWidget {
|
||||
Widget buildTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 5, 10),
|
||||
child: Text(title.tl,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500)),
|
||||
child: Text(
|
||||
title.tl,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,21 +224,16 @@ class _CategoryPage extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title.tl,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh))
|
||||
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTags(
|
||||
List<CategoryItem> categories,
|
||||
) {
|
||||
Widget buildTags(List<CategoryItem> categories) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
|
||||
@@ -27,9 +27,11 @@ class CategoryComicsPage extends StatefulWidget {
|
||||
|
||||
class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
late final CategoryComicsData data;
|
||||
late final List<CategoryComicsOptions> options;
|
||||
late List<CategoryComicsOptions>? options;
|
||||
late final CategoryOptionsLoader? optionsLoader;
|
||||
late List<String> optionsValue;
|
||||
late String sourceKey;
|
||||
String? error;
|
||||
|
||||
void findData() {
|
||||
for (final source in ComicSource.all()) {
|
||||
@@ -38,24 +40,23 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
throw "The comic source ${source.name} does not support category comics";
|
||||
}
|
||||
data = source.categoryComicsData!;
|
||||
options = data.options.where((element) {
|
||||
if (element.notShowWhen.contains(widget.category)) {
|
||||
return false;
|
||||
} else if (element.showWhen != null) {
|
||||
return element.showWhen!.contains(widget.category);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
var defaultOptionsValue =
|
||||
options.map((e) => e.options.keys.first).toList();
|
||||
if (optionsValue.length != options.length) {
|
||||
var newOptionsValue = List<String>.filled(options.length, "");
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
newOptionsValue[i] =
|
||||
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
|
||||
}
|
||||
optionsValue = newOptionsValue;
|
||||
if (data.options != null) {
|
||||
options = data.options!.where((element) {
|
||||
if (element.notShowWhen.contains(widget.category)) {
|
||||
return false;
|
||||
} else if (element.showWhen != null) {
|
||||
return element.showWhen!.contains(widget.category);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
} else {
|
||||
options = null;
|
||||
}
|
||||
if (data.optionsLoader != null) {
|
||||
optionsLoader = data.optionsLoader;
|
||||
loadOptions();
|
||||
}
|
||||
resetOptionsValue();
|
||||
sourceKey = source.key;
|
||||
return;
|
||||
}
|
||||
@@ -63,6 +64,36 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
throw "${widget.categoryKey} Not found";
|
||||
}
|
||||
|
||||
void resetOptionsValue() {
|
||||
if (options == null) return;
|
||||
var defaultOptionsValue = options!
|
||||
.map((e) => e.options.keys.first)
|
||||
.toList();
|
||||
if (optionsValue.length != options!.length) {
|
||||
var newOptionsValue = List<String>.filled(options!.length, "");
|
||||
for (var i = 0; i < options!.length; i++) {
|
||||
newOptionsValue[i] =
|
||||
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
|
||||
}
|
||||
optionsValue = newOptionsValue;
|
||||
}
|
||||
}
|
||||
|
||||
void loadOptions() async {
|
||||
final res = await optionsLoader!(widget.category, widget.param);
|
||||
if (res.error) {
|
||||
setState(() {
|
||||
error = res.errorMessage;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
options = res.data;
|
||||
resetOptionsValue();
|
||||
error = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.options != null) {
|
||||
@@ -77,27 +108,44 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var topPadding = context.padding.top + 56.0;
|
||||
|
||||
Widget body;
|
||||
|
||||
if (options == null) {
|
||||
body = Center(child: CircularProgressIndicator());
|
||||
} else if (error != null) {
|
||||
body = NetworkError(
|
||||
message: error!,
|
||||
retry: () {
|
||||
setState(() {
|
||||
error = null;
|
||||
});
|
||||
loadOptions();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
body = ComicList(
|
||||
key: Key(widget.category + optionsValue.toString()),
|
||||
errorLeading: buildOptions().paddingTop(topPadding),
|
||||
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
|
||||
loadPage: (i) =>
|
||||
data.load(widget.category, widget.param, optionsValue, i),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: Appbar(
|
||||
title: Text(widget.category),
|
||||
),
|
||||
body: ComicList(
|
||||
key: Key(widget.category + optionsValue.toString()),
|
||||
errorLeading: SizedBox(height: topPadding),
|
||||
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
|
||||
loadPage: (i) => data.load(
|
||||
widget.category,
|
||||
widget.param,
|
||||
optionsValue,
|
||||
i,
|
||||
),
|
||||
),
|
||||
appBar: Appbar(title: Text(widget.category)),
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildOptionItem(
|
||||
String text, String value, int group, BuildContext context) {
|
||||
String text,
|
||||
String value,
|
||||
int group,
|
||||
BuildContext context,
|
||||
) {
|
||||
return OptionChip(
|
||||
text: text.ts(sourceKey),
|
||||
isSelected: value == optionsValue[group],
|
||||
@@ -112,23 +160,57 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
|
||||
Widget buildOptions() {
|
||||
List<Widget> children = [];
|
||||
for (var optionList in options) {
|
||||
children.add(Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var option in optionList.options.entries)
|
||||
buildOptionItem(
|
||||
option.value.tl,
|
||||
option.key,
|
||||
options.indexOf(optionList),
|
||||
context,
|
||||
)
|
||||
],
|
||||
));
|
||||
if (options.last != optionList) {
|
||||
var group = 0;
|
||||
for (var optionList in options!) {
|
||||
if (optionList.label.isNotEmpty) {
|
||||
children.add(Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8.0,
|
||||
left: 4.0,
|
||||
),
|
||||
child: Text(
|
||||
optionList.label.ts(sourceKey),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
if (optionList.options.length <= 8) {
|
||||
children.add(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var option in optionList.options.entries)
|
||||
buildOptionItem(
|
||||
option.value.tl,
|
||||
option.key,
|
||||
group,
|
||||
context,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
var g = group;
|
||||
children.add(Select(
|
||||
current: optionList.options[optionsValue[g]],
|
||||
values: optionList.options.values.toList(),
|
||||
onTap: (i) {
|
||||
var key = optionList.options.keys.elementAt(i);
|
||||
if (key == optionsValue[g]) return;
|
||||
setState(() {
|
||||
optionsValue[g] = key;
|
||||
});
|
||||
},
|
||||
));
|
||||
}
|
||||
if (options!.last != optionList) {
|
||||
children.add(const SizedBox(height: 8));
|
||||
}
|
||||
group++;
|
||||
}
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -56,8 +56,12 @@ abstract mixin class _ComicPageActions {
|
||||
type: comic.comicType,
|
||||
isFavorite: isFavorite,
|
||||
onFavorite: (local, network) {
|
||||
isFavorite = network ?? isFavorite;
|
||||
isAddToLocalFav = local ?? isAddToLocalFav;
|
||||
if (network != null) {
|
||||
isFavorite = network;
|
||||
}
|
||||
if (local != null) {
|
||||
isAddToLocalFav = local;
|
||||
}
|
||||
update();
|
||||
},
|
||||
favoriteItem: _toFavoriteItem(),
|
||||
@@ -151,64 +155,60 @@ abstract mixin class _ComicPageActions {
|
||||
builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Download".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<int>(
|
||||
value: -1,
|
||||
groupValue: selected,
|
||||
title: Text("Normal".tl),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
title: Text("Archive".tl),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
content: RadioGroup<int>(
|
||||
groupValue: selected,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v ?? selected;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<int>(
|
||||
value: -1,
|
||||
title: Text("Normal".tl),
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
onExpansionChanged: (b) {
|
||||
if (!isLoading && b && archives == null) {
|
||||
isLoading = true;
|
||||
comicSource.archiveDownloader!
|
||||
.getArchives(comic.id)
|
||||
.then((value) {
|
||||
if (value.success) {
|
||||
archives = value.data;
|
||||
} else {
|
||||
App.rootContext
|
||||
.showMessage(message: value.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
ExpansionTile(
|
||||
title: Text("Archive".tl),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
onExpansionChanged: (b) {
|
||||
if (!isLoading && b && archives == null) {
|
||||
isLoading = true;
|
||||
comicSource.archiveDownloader!
|
||||
.getArchives(comic.id)
|
||||
.then((value) {
|
||||
if (value.success) {
|
||||
archives = value.data;
|
||||
} else {
|
||||
App.rootContext
|
||||
.showMessage(message: value.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
children: [
|
||||
if (archives == null)
|
||||
const ListLoadingIndicator().toCenter()
|
||||
else
|
||||
for (int i = 0; i < archives!.length; i++)
|
||||
RadioListTile<int>(
|
||||
value: i,
|
||||
groupValue: selected,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v!;
|
||||
});
|
||||
},
|
||||
title: Text(archives![i].title),
|
||||
subtitle: Text(archives![i].description),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
},
|
||||
children: [
|
||||
if (archives == null)
|
||||
const ListLoadingIndicator().toCenter()
|
||||
else
|
||||
for (int i = 0; i < archives!.length; i++)
|
||||
RadioListTile<int>(
|
||||
value: i,
|
||||
title: Text(archives![i].title),
|
||||
subtitle: Text(archives![i].description),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Button.filled(
|
||||
@@ -233,10 +233,12 @@ abstract mixin class _ComicPageActions {
|
||||
isGettingLink = false;
|
||||
});
|
||||
} else if (context.mounted) {
|
||||
LocalManager()
|
||||
if (res.data.isNotEmpty) {
|
||||
LocalManager()
|
||||
.addTask(ArchiveDownloadTask(res.data, comic));
|
||||
App.rootContext
|
||||
App.rootContext
|
||||
.showMessage(message: "Download started".tl);
|
||||
}
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:shimmer_animation/shimmer_animation.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -17,10 +20,12 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/network/cache.dart';
|
||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/file_type.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/tags_translation.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
@@ -38,6 +43,8 @@ part 'comments_preview.dart';
|
||||
|
||||
part 'actions.dart';
|
||||
|
||||
part 'cover_viewer.dart';
|
||||
|
||||
class ComicPage extends StatefulWidget {
|
||||
const ComicPage({
|
||||
super.key,
|
||||
@@ -77,8 +84,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
|
||||
@override
|
||||
void onReadEnd() {
|
||||
history ??=
|
||||
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
|
||||
history ??= HistoryManager().find(
|
||||
widget.id,
|
||||
ComicType(widget.sourceKey.hashCode),
|
||||
);
|
||||
update();
|
||||
}
|
||||
|
||||
@@ -93,6 +102,32 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildError() {
|
||||
final isDownloaded = LocalManager().isDownloaded(
|
||||
widget.id,
|
||||
ComicType.fromKey(widget.sourceKey),
|
||||
);
|
||||
Widget? action;
|
||||
if (isDownloaded) {
|
||||
action = FilledButton.tonal(
|
||||
child: Text("Read".tl),
|
||||
onPressed: () {
|
||||
final localComic = LocalManager().find(
|
||||
widget.id,
|
||||
ComicType.fromKey(widget.sourceKey),
|
||||
);
|
||||
if (localComic == null) {
|
||||
context.showMessage(message: "Local comic not found".tl);
|
||||
return;
|
||||
}
|
||||
localComic.read();
|
||||
},
|
||||
);
|
||||
}
|
||||
return NetworkError(message: error!, retry: retry, action: action);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
scrollController.addListener(onScroll);
|
||||
@@ -114,7 +149,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
ComicDetails get comic => data!;
|
||||
|
||||
void onScroll() {
|
||||
var offset = scrollController.position.pixels -
|
||||
var offset =
|
||||
scrollController.position.pixels -
|
||||
scrollController.position.minScrollExtent;
|
||||
var showFAB = offset > 0;
|
||||
if (showFAB != this.showFAB) {
|
||||
@@ -145,9 +181,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
floatingActionButton: showFAB
|
||||
? FloatingActionButton(
|
||||
onPressed: () {
|
||||
scrollController.animateTo(0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease);
|
||||
scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.arrow_upward),
|
||||
)
|
||||
@@ -164,7 +202,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
buildThumbnails(),
|
||||
buildRecommend(),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB
|
||||
padding: EdgeInsets.only(
|
||||
bottom: context.padding.bottom + 80,
|
||||
), // Add additional padding for FAB
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -190,12 +230,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
initialPage: history?.page,
|
||||
initialChapter: history?.ep,
|
||||
initialChapterGroup: history?.group,
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: localComic,
|
||||
ep: 0,
|
||||
page: 0,
|
||||
),
|
||||
history:
|
||||
history ??
|
||||
History.fromModel(model: localComic, ep: 0, page: 0),
|
||||
author: localComic.subTitle ?? '',
|
||||
tags: localComic.tags,
|
||||
);
|
||||
@@ -215,8 +252,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
widget.id,
|
||||
ComicType(widget.sourceKey.hashCode),
|
||||
);
|
||||
history =
|
||||
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
|
||||
history = HistoryManager().find(
|
||||
widget.id,
|
||||
ComicType(widget.sourceKey.hashCode),
|
||||
);
|
||||
return comicSource.loadComicInfo!(widget.id);
|
||||
}
|
||||
|
||||
@@ -224,12 +263,20 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
Future<void> onDataLoaded() async {
|
||||
isLiked = comic.isLiked ?? false;
|
||||
isFavorite = comic.isFavorite ?? false;
|
||||
// For sources with multi-folder favorites, prefer querying folders to get accurate favorite status
|
||||
// Some sources may not set isFavorite reliably when multi-folder is enabled
|
||||
if (comicSource.favoriteData?.loadFolders != null && comicSource.isLogged) {
|
||||
var res = await comicSource.favoriteData!.loadFolders!(comic.id);
|
||||
if (!res.error) {
|
||||
if (res.subData is List) {
|
||||
var list = List<String>.from(res.subData);
|
||||
isFavorite = list.isNotEmpty;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (comic.chapters == null) {
|
||||
isDownloaded = LocalManager().isDownloaded(
|
||||
comic.id,
|
||||
comic.comicType,
|
||||
0,
|
||||
);
|
||||
isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +289,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
|
||||
onPressed: showMoreActions,
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -253,31 +302,35 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
Hero(
|
||||
tag: "cover${widget.heroID}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 144,
|
||||
width: 144 * 0.72,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
GestureDetector(
|
||||
onTap: () => _viewCover(context),
|
||||
onLongPress: () => _saveCover(context),
|
||||
child: Hero(
|
||||
tag: "cover${widget.heroID}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 144,
|
||||
width: 144 * 0.72,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -288,8 +341,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
children: [
|
||||
SelectableText(comic.title, style: ts.s18),
|
||||
if (comic.subTitle != null)
|
||||
SelectableText(comic.subTitle!, style: ts.s14)
|
||||
.paddingVertical(4),
|
||||
SelectableText(
|
||||
comic.subTitle!,
|
||||
style: ts.s14,
|
||||
).paddingVertical(4),
|
||||
Text(
|
||||
(ComicSource.find(comic.sourceKey)?.name) ?? '',
|
||||
style: ts.s12,
|
||||
@@ -338,10 +393,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
icon: const Icon(Icons.favorite_border),
|
||||
activeIcon: const Icon(Icons.favorite),
|
||||
isActive: isLiked,
|
||||
text: ((data!.likesCount != null)
|
||||
? (data!.likesCount! + (isLiked ? 1 : 0))
|
||||
: (isLiked ? 'Liked'.tl : 'Like'.tl))
|
||||
.toString(),
|
||||
text:
|
||||
((data!.likesCount != null)
|
||||
? (data!.likesCount! + (isLiked ? 1 : 0))
|
||||
: (isLiked ? 'Liked'.tl : 'Like'.tl))
|
||||
.toString(),
|
||||
isLoading: isLiking,
|
||||
onPressed: likeOrUnlike,
|
||||
iconColor: context.useTextColor(Colors.red),
|
||||
@@ -383,9 +439,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
Expanded(
|
||||
child: hasHistory
|
||||
? FilledButton(
|
||||
onPressed: continueRead, child: Text("Continue".tl))
|
||||
onPressed: continueRead,
|
||||
child: Text("Continue".tl),
|
||||
)
|
||||
: FilledButton(onPressed: read, child: Text("Read".tl)),
|
||||
)
|
||||
),
|
||||
],
|
||||
).paddingHorizontal(16).paddingVertical(8),
|
||||
if (history != null)
|
||||
@@ -412,19 +470,20 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
var epName = "E$ep";
|
||||
String? groupName;
|
||||
try {
|
||||
if (group == null){
|
||||
if (group == null) {
|
||||
epName = comic.chapters!.titles.elementAt(
|
||||
math.min(ep - 1, comic.chapters!.length - 1),
|
||||
);
|
||||
} else {
|
||||
groupName = comic.chapters!.groups.elementAt(group - 1);
|
||||
groupName = comic.chapters!.groups.elementAt(
|
||||
group - 1,
|
||||
);
|
||||
epName = comic.chapters!
|
||||
.getGroupByIndex(group - 1)
|
||||
.values
|
||||
.elementAt(ep - 1);
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
text = groupName == null
|
||||
@@ -453,9 +512,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
return SliverLazyToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Description".tl),
|
||||
),
|
||||
ListTile(title: Text("Description".tl)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SelectableText(comic.description!).fixWidth(double.infinity),
|
||||
@@ -539,10 +596,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
decoration: BoxDecoration(color: color, borderRadius: borderRadius),
|
||||
child: Text(text).padding(padding),
|
||||
);
|
||||
}
|
||||
@@ -552,13 +606,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
if (int.tryParse(time) != null) {
|
||||
var t = int.tryParse(time);
|
||||
if (t! > 1000000000000) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(t)
|
||||
.toString()
|
||||
.substring(0, 19);
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
t,
|
||||
).toString().substring(0, 19);
|
||||
} else {
|
||||
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
|
||||
.toString()
|
||||
.substring(0, 19);
|
||||
return DateTime.fromMillisecondsSinceEpoch(
|
||||
t * 1000,
|
||||
).toString().substring(0, 19);
|
||||
}
|
||||
}
|
||||
if (time.contains('T') || time.contains('Z')) {
|
||||
@@ -583,17 +637,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Information".tl),
|
||||
),
|
||||
ListTile(title: Text("Information".tl)),
|
||||
if (comic.stars != null)
|
||||
Row(
|
||||
children: [
|
||||
StarRating(
|
||||
value: comic.stars!,
|
||||
size: 24,
|
||||
onTap: starRating,
|
||||
),
|
||||
StarRating(value: comic.stars!, size: 24, onTap: starRating),
|
||||
const SizedBox(width: 8),
|
||||
Text(comic.stars!.toStringAsFixed(2)),
|
||||
],
|
||||
@@ -671,24 +719,67 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
if (comic.recommend == null || comic.recommend!.isEmpty) {
|
||||
return const SliverPadding(padding: EdgeInsets.zero);
|
||||
}
|
||||
return SliverMainAxisGroup(slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text("Related".tl),
|
||||
),
|
||||
),
|
||||
SliverGridComics(comics: comic.recommend!),
|
||||
]);
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: ListTile(title: Text("Related".tl))),
|
||||
SliverGridComics(comics: comic.recommend!),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildComments() {
|
||||
if (comic.comments == null || comic.comments!.isEmpty) {
|
||||
return const SliverPadding(padding: EdgeInsets.zero);
|
||||
}
|
||||
return _CommentsPart(
|
||||
comments: comic.comments!,
|
||||
showMore: showComments,
|
||||
return _CommentsPart(comments: comic.comments!, showMore: showComments);
|
||||
}
|
||||
|
||||
void _viewCover(BuildContext context) {
|
||||
final imageProvider = CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
);
|
||||
|
||||
context.to(
|
||||
() => _CoverViewer(
|
||||
imageProvider: imageProvider,
|
||||
title: comic.title,
|
||||
heroTag: "cover${widget.heroID}",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveCover(BuildContext context) async {
|
||||
try {
|
||||
final imageProvider = CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
);
|
||||
|
||||
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||
final completer = Completer<Uint8List>();
|
||||
|
||||
imageStream.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) async {
|
||||
final byteData = await info.image.toByteData(
|
||||
format: ImageByteFormat.png,
|
||||
);
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
final data = await completer.future;
|
||||
final fileType = detectFileType(data);
|
||||
await saveFile(filename: "cover${fileType.ext}", data: data);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showMessage(message: "Error".tl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,20 +883,21 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
|
||||
itemCount: widget.eps.length,
|
||||
itemBuilder: (context, i) {
|
||||
return CheckboxListTile(
|
||||
title: Text(widget.eps[i]),
|
||||
value: selected.contains(i) ||
|
||||
widget.downloadedEps.contains(i),
|
||||
onChanged: widget.downloadedEps.contains(i)
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
if (selected.contains(i)) {
|
||||
selected.remove(i);
|
||||
} else {
|
||||
selected.add(i);
|
||||
}
|
||||
});
|
||||
title: Text(widget.eps[i]),
|
||||
value:
|
||||
selected.contains(i) || widget.downloadedEps.contains(i),
|
||||
onChanged: widget.downloadedEps.contains(i)
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
if (selected.contains(i)) {
|
||||
selected.remove(i);
|
||||
} else {
|
||||
selected.add(i);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -813,9 +905,7 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
),
|
||||
top: BorderSide(color: context.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -880,8 +970,12 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget buildContainer(double? width, double? height,
|
||||
{Color? color, double? radius}) {
|
||||
Widget buildContainer(
|
||||
double? width,
|
||||
double? height, {
|
||||
Color? color,
|
||||
double? radius,
|
||||
}) {
|
||||
return Container(
|
||||
height: height,
|
||||
width: width,
|
||||
@@ -923,13 +1017,9 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
if (context.width < changePoint)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: buildContainer(null, 36, radius: 18),
|
||||
),
|
||||
Expanded(child: buildContainer(null, 36, radius: 18)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: buildContainer(null, 36, radius: 18),
|
||||
),
|
||||
Expanded(child: buildContainer(null, 36, radius: 18)),
|
||||
],
|
||||
).paddingHorizontal(16),
|
||||
const Divider(),
|
||||
@@ -938,7 +1028,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
).fixHeight(24).fixWidth(24),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -948,11 +1038,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
Widget child;
|
||||
if (cover != null) {
|
||||
child = AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
cover!,
|
||||
sourceKey: sourceKey,
|
||||
cid: cid,
|
||||
),
|
||||
image: CachedImageProvider(cover!, sourceKey: sourceKey, cid: cid),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
|
||||
140
lib/pages/comic_details_page/cover_viewer.dart
Normal file
140
lib/pages/comic_details_page/cover_viewer.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
part of 'comic_page.dart';
|
||||
|
||||
class _CoverViewer extends StatefulWidget {
|
||||
const _CoverViewer({
|
||||
required this.imageProvider,
|
||||
required this.title,
|
||||
required this.heroTag,
|
||||
});
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
final String title;
|
||||
final String heroTag;
|
||||
|
||||
@override
|
||||
State<_CoverViewer> createState() => _CoverViewerState();
|
||||
}
|
||||
|
||||
class _CoverViewerState extends State<_CoverViewer> {
|
||||
bool isAppBarShow = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: PhotoView(
|
||||
imageProvider: widget.imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||
maxScale: PhotoViewComputedScale.covered * 3.0,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
),
|
||||
loadingBuilder: (context, event) => Center(
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: CircularProgressIndicator(
|
||||
value: event == null || event.expectedTotalBytes == null
|
||||
? null
|
||||
: event.cumulativeBytesLoaded /
|
||||
event.expectedTotalBytes!,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTapUp: (context, details, controllerValue) {
|
||||
setState(() {
|
||||
isAppBarShow = !isAppBarShow;
|
||||
});
|
||||
},
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: widget.heroTag),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
top: isAppBarShow ? 0 : -(context.padding.top + 52),
|
||||
left: 0,
|
||||
right: 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: _buildAppBar(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar() {
|
||||
return Material(
|
||||
color: context.colorScheme.surface.toOpacity(0.72),
|
||||
child: BlurEffect(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
height: 52,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save_alt),
|
||||
onPressed: _saveCover,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveCover() async {
|
||||
try {
|
||||
final imageStream = widget.imageProvider.resolve(
|
||||
const ImageConfiguration(),
|
||||
);
|
||||
final completer = Completer<Uint8List>();
|
||||
|
||||
imageStream.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) async {
|
||||
final byteData = await info.image.toByteData(
|
||||
format: ImageByteFormat.png,
|
||||
);
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
final data = await completer.future;
|
||||
final fileType = detectFileType(data);
|
||||
await saveFile(filename: "cover_${widget.title}${fileType.ext}", data: data);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
context.showMessage(message: "Error".tl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,198 +33,122 @@ class _FavoritePanelState extends State<_FavoritePanel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late ComicSource comicSource;
|
||||
|
||||
late TabController tabController;
|
||||
|
||||
late bool hasNetwork;
|
||||
|
||||
late List<String> localFolders;
|
||||
|
||||
late List<String> added;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
comicSource = widget.type.comicSource!;
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
added = LocalFavoritesManager().find(widget.cid, widget.type);
|
||||
hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
|
||||
var initIndex = 0;
|
||||
if (appdata.implicitData['favoritePanelIndex'] is int) {
|
||||
initIndex = appdata.implicitData['favoritePanelIndex'];
|
||||
}
|
||||
initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0);
|
||||
tabController = TabController(
|
||||
initialIndex: initIndex,
|
||||
length: hasNetwork ? 2 : 1,
|
||||
vsync: this,
|
||||
);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
var currentIndex = tabController.index;
|
||||
appdata.implicitData['favoritePanelIndex'] = currentIndex;
|
||||
appdata.writeImplicitData();
|
||||
tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
title: Text("Favorite".tl),
|
||||
appBar: Appbar(title: Text("Favorite".tl)),
|
||||
body: _FavoriteList(
|
||||
cid: widget.cid,
|
||||
type: widget.type,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: widget.onFavorite,
|
||||
favoriteItem: widget.favoriteItem,
|
||||
updateTime: widget.updateTime,
|
||||
comicSource: comicSource,
|
||||
hasNetwork: hasNetwork,
|
||||
localFolders: localFolders,
|
||||
added: added,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: [
|
||||
Tab(text: "Local".tl),
|
||||
if (hasNetwork) Tab(text: "Network".tl),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
buildLocal(),
|
||||
if (hasNetwork) buildNetwork(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
late List<String> localFolders;
|
||||
|
||||
late List<String> added;
|
||||
|
||||
var selectedLocalFolders = <String>{};
|
||||
|
||||
Widget buildLocal() {
|
||||
var isRemove = selectedLocalFolders.isNotEmpty &&
|
||||
added.contains(selectedLocalFolders.first);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: localFolders.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == localFolders.length) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
newFolder().then((v) {
|
||||
setState(() {
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text("New Folder".tl)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
var folder = localFolders[index];
|
||||
var disabled = false;
|
||||
if (selectedLocalFolders.isNotEmpty) {
|
||||
if (added.contains(folder) &&
|
||||
!added.contains(selectedLocalFolders.first)) {
|
||||
disabled = true;
|
||||
} else if (!added.contains(folder) &&
|
||||
added.contains(selectedLocalFolders.first)) {
|
||||
disabled = true;
|
||||
}
|
||||
}
|
||||
return CheckboxListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(folder),
|
||||
const SizedBox(width: 8),
|
||||
if (added.contains(folder))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
value: selectedLocalFolders.contains(folder),
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
if (v!) {
|
||||
selectedLocalFolders.add(folder);
|
||||
} else {
|
||||
selectedLocalFolders.remove(folder);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (selectedLocalFolders.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (isRemove) {
|
||||
for (var folder in selectedLocalFolders) {
|
||||
LocalFavoritesManager()
|
||||
.deleteComicWithId(folder, widget.cid, widget.type);
|
||||
}
|
||||
widget.onFavorite(false, null);
|
||||
} else {
|
||||
for (var folder in selectedLocalFolders) {
|
||||
LocalFavoritesManager().addComic(
|
||||
folder,
|
||||
widget.favoriteItem,
|
||||
null,
|
||||
widget.updateTime,
|
||||
);
|
||||
}
|
||||
widget.onFavorite(true, null);
|
||||
}
|
||||
context.pop();
|
||||
},
|
||||
child: isRemove ? Text("Remove".tl) : Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildNetwork() {
|
||||
return _NetworkFavorites(
|
||||
cid: widget.cid,
|
||||
comicSource: comicSource,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: (network) {
|
||||
widget.onFavorite(null, network);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetworkFavorites extends StatefulWidget {
|
||||
const _NetworkFavorites({
|
||||
class _FavoriteList extends StatefulWidget {
|
||||
const _FavoriteList({
|
||||
required this.cid,
|
||||
required this.type,
|
||||
required this.isFavorite,
|
||||
required this.onFavorite,
|
||||
required this.favoriteItem,
|
||||
this.updateTime,
|
||||
required this.comicSource,
|
||||
required this.hasNetwork,
|
||||
required this.localFolders,
|
||||
required this.added,
|
||||
});
|
||||
|
||||
final String cid;
|
||||
final ComicType type;
|
||||
final bool? isFavorite;
|
||||
final void Function(bool?, bool?) onFavorite;
|
||||
final FavoriteItem favoriteItem;
|
||||
final String? updateTime;
|
||||
final ComicSource comicSource;
|
||||
final bool hasNetwork;
|
||||
final List<String> localFolders;
|
||||
final List<String> added;
|
||||
|
||||
@override
|
||||
State<_FavoriteList> createState() => _FavoriteListState();
|
||||
}
|
||||
|
||||
class _FavoriteListState extends State<_FavoriteList> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localFavoritesFirst = appdata.settings['localFavoritesFirst'] ?? true;
|
||||
|
||||
final localSection = _LocalSection(
|
||||
cid: widget.cid,
|
||||
type: widget.type,
|
||||
favoriteItem: widget.favoriteItem,
|
||||
updateTime: widget.updateTime,
|
||||
localFolders: widget.localFolders,
|
||||
added: widget.added,
|
||||
onFavorite: (local) {
|
||||
widget.onFavorite(local, null);
|
||||
},
|
||||
);
|
||||
|
||||
final networkSection = widget.hasNetwork
|
||||
? _NetworkSection(
|
||||
cid: widget.cid,
|
||||
comicSource: widget.comicSource,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: (network) {
|
||||
widget.onFavorite(null, network);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
final divider = widget.hasNetwork
|
||||
? Container(
|
||||
height: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: context.colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
)
|
||||
: null;
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
if (localFavoritesFirst) ...[
|
||||
localSection,
|
||||
if (widget.hasNetwork) ...[divider!, networkSection!],
|
||||
] else ...[
|
||||
if (widget.hasNetwork) ...[networkSection!, divider!],
|
||||
localSection,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetworkSection extends StatefulWidget {
|
||||
const _NetworkSection({
|
||||
required this.cid,
|
||||
required this.comicSource,
|
||||
required this.isFavorite,
|
||||
@@ -232,82 +156,56 @@ class _NetworkFavorites extends StatefulWidget {
|
||||
});
|
||||
|
||||
final String cid;
|
||||
|
||||
final ComicSource comicSource;
|
||||
|
||||
final bool? isFavorite;
|
||||
|
||||
final void Function(bool) onFavorite;
|
||||
|
||||
@override
|
||||
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
|
||||
State<_NetworkSection> createState() => _NetworkSectionState();
|
||||
}
|
||||
|
||||
class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
|
||||
|
||||
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
|
||||
}
|
||||
|
||||
class _NetworkSectionState extends State<_NetworkSection> {
|
||||
bool isLoading = false;
|
||||
|
||||
Widget buildSingleFolder() {
|
||||
var isFavorite = widget.isFavorite ?? false;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget.comicSource.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
|
||||
if (res.success) {
|
||||
widget.onFavorite(!isFavorite);
|
||||
context.pop();
|
||||
App.rootContext.showMessage(
|
||||
message: isFavorite ? "Removed".tl : "Added".tl);
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
},
|
||||
child: isFavorite ? Text("Remove".tl) : Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String>? folders;
|
||||
|
||||
var addedFolders = <String>{};
|
||||
|
||||
var isLoadingFolders = true;
|
||||
bool? localIsFavorite;
|
||||
final Map<String, bool> _itemLoading = {};
|
||||
late List<double> _skeletonWidths;
|
||||
|
||||
// for network favorites, only one selection is allowed
|
||||
String? selected;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
localIsFavorite = widget.isFavorite;
|
||||
_skeletonWidths = List.generate(3, (_) => 0.3 + math.Random().nextDouble() * 0.5);
|
||||
if (widget.comicSource.favoriteData!.loadFolders != null) {
|
||||
loadFolders();
|
||||
} else {
|
||||
isLoadingFolders = false;
|
||||
}
|
||||
}
|
||||
|
||||
void loadFolders() async {
|
||||
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
|
||||
if (res.error) {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
setState(() {
|
||||
isLoadingFolders = false;
|
||||
});
|
||||
} else {
|
||||
folders = res.data;
|
||||
if (res.subData is List) {
|
||||
addedFolders = List<String>.from(res.subData).toSet();
|
||||
final list = List<String>.from(res.subData);
|
||||
if (list.isNotEmpty) {
|
||||
addedFolders = list.toSet();
|
||||
localIsFavorite = true;
|
||||
} else {
|
||||
addedFolders.clear();
|
||||
localIsFavorite = false;
|
||||
}
|
||||
} else {
|
||||
addedFolders.clear();
|
||||
localIsFavorite = false;
|
||||
}
|
||||
setState(() {
|
||||
isLoadingFolders = false;
|
||||
@@ -315,118 +213,414 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildMultiFolder() {
|
||||
if (widget.isFavorite == true &&
|
||||
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text("Added to favorites".tl),
|
||||
Widget _buildLoadingSkeleton() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget.comicSource.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', false, null);
|
||||
if (res.success) {
|
||||
widget.onFavorite(false);
|
||||
context.pop();
|
||||
App.rootContext.showMessage(message: "Removed".tl);
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
},
|
||||
child: Text("Remove".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (isLoadingFolders) {
|
||||
loadFolders();
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: folders!.length,
|
||||
itemBuilder: (context, index) {
|
||||
var name = folders!.values.elementAt(index);
|
||||
var id = folders!.keys.elementAt(index);
|
||||
return CheckboxListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(name),
|
||||
const SizedBox(width: 8),
|
||||
if (addedFolders.contains(id))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
Shimmer(
|
||||
child: Column(
|
||||
children: List.generate(3, (index) {
|
||||
return ListTile(
|
||||
title: Container(
|
||||
height: 20,
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: _skeletonWidths[index],
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
value: selected == id,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = id;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
trailing: Container(
|
||||
height: 28,
|
||||
width: 60 + (index * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
var res =
|
||||
await widget.comicSource.favoriteData!.addOrDelFavorite!(
|
||||
widget.cid,
|
||||
selected!,
|
||||
!addedFolders.contains(selected!),
|
||||
null,
|
||||
);
|
||||
if (res.success) {
|
||||
context.showMessage(message: "Success".tl);
|
||||
context.pop();
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: selected != null && addedFolders.contains(selected!)
|
||||
? Text("Remove".tl)
|
||||
: Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoadingFolders) {
|
||||
return _buildLoadingSkeleton();
|
||||
}
|
||||
|
||||
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
|
||||
|
||||
if (isMultiFolder) {
|
||||
return _buildMultiFolder();
|
||||
} else {
|
||||
return _buildSingleFolder();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSingleFolder() {
|
||||
var isFavorite = localIsFavorite ?? false;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text("Network Favorites".tl),
|
||||
const SizedBox(width: 8),
|
||||
if (isFavorite)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _HoverButton(
|
||||
isFavorite: isFavorite,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget
|
||||
.comicSource
|
||||
.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
|
||||
if (res.success) {
|
||||
setState(() {
|
||||
localIsFavorite = !isFavorite;
|
||||
});
|
||||
widget.onFavorite(!isFavorite);
|
||||
App.rootContext.showMessage(
|
||||
message: isFavorite ? "Removed".tl : "Added".tl,
|
||||
);
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiFolder() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...folders!.entries.map((entry) {
|
||||
var name = entry.value;
|
||||
var id = entry.key;
|
||||
var isAdded = addedFolders.contains(id);
|
||||
// When `singleFolderForSingleComic` is `false`, all add and remove buttons are clickable.
|
||||
// When `singleFolderForSingleComic` is `true`, the remove button is always clickable,
|
||||
// while the add button is only clickable if the comic has not been added to any list.
|
||||
var enabled = !(widget.comicSource.favoriteData!.singleFolderForSingleComic && addedFolders.isNotEmpty && !isAdded);
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(name),
|
||||
const SizedBox(width: 8),
|
||||
if (isAdded)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: (_itemLoading[id] ?? false)
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _HoverButton(
|
||||
isFavorite: isAdded,
|
||||
enabled: enabled,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_itemLoading[id] = true;
|
||||
});
|
||||
var res = await widget
|
||||
.comicSource
|
||||
.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, id, !isAdded, null);
|
||||
if (res.success) {
|
||||
// Invalidate network cache so folders/pages reload with fresh data
|
||||
NetworkCacheManager().clear();
|
||||
setState(() {
|
||||
if (isAdded) {
|
||||
addedFolders.remove(id);
|
||||
} else {
|
||||
addedFolders.add(id);
|
||||
}
|
||||
// sync local flag for single-folder-per-comic logic and parent
|
||||
localIsFavorite = addedFolders.isNotEmpty;
|
||||
});
|
||||
// notify parent so page state updates when closing and reopening panel
|
||||
widget.onFavorite(addedFolders.isNotEmpty);
|
||||
context.showMessage(message: "Success".tl);
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
_itemLoading[id] = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalSection extends StatefulWidget {
|
||||
const _LocalSection({
|
||||
required this.cid,
|
||||
required this.type,
|
||||
required this.favoriteItem,
|
||||
this.updateTime,
|
||||
required this.localFolders,
|
||||
required this.added,
|
||||
required this.onFavorite,
|
||||
});
|
||||
|
||||
final String cid;
|
||||
final ComicType type;
|
||||
final FavoriteItem favoriteItem;
|
||||
final String? updateTime;
|
||||
final List<String> localFolders;
|
||||
final List<String> added;
|
||||
final void Function(bool) onFavorite;
|
||||
|
||||
@override
|
||||
State<_LocalSection> createState() => _LocalSectionState();
|
||||
}
|
||||
|
||||
class _LocalSectionState extends State<_LocalSection> {
|
||||
late List<String> localFolders;
|
||||
late Set<String> localAdded;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
localFolders = widget.localFolders;
|
||||
localAdded = widget.added.toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Local Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...localFolders.map((folder) {
|
||||
var isAdded = localAdded.contains(folder);
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(folder),
|
||||
const SizedBox(width: 8),
|
||||
if (isAdded)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: _HoverButton(
|
||||
isFavorite: isAdded,
|
||||
onTap: () {
|
||||
if (isAdded) {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
folder,
|
||||
widget.cid,
|
||||
widget.type,
|
||||
);
|
||||
setState(() {
|
||||
localAdded.remove(folder);
|
||||
});
|
||||
widget.onFavorite(false);
|
||||
} else {
|
||||
LocalFavoritesManager().addComic(
|
||||
folder,
|
||||
widget.favoriteItem,
|
||||
null,
|
||||
widget.updateTime,
|
||||
);
|
||||
setState(() {
|
||||
localAdded.add(folder);
|
||||
});
|
||||
widget.onFavorite(true);
|
||||
}
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
// New folder button
|
||||
ListTile(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text("New Folder".tl),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
newFolder().then((v) {
|
||||
setState(() {
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverButton extends StatefulWidget {
|
||||
const _HoverButton({
|
||||
required this.isFavorite,
|
||||
required this.onTap,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final bool isFavorite;
|
||||
final VoidCallback onTap;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<_HoverButton> createState() => _HoverButtonState();
|
||||
}
|
||||
|
||||
class _HoverButtonState extends State<_HoverButton> {
|
||||
bool isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final removeColor = context.colorScheme.error;
|
||||
final removeHoverColor = Color.lerp(removeColor, Colors.black, 0.2)!;
|
||||
final addColor = context.colorScheme.primary;
|
||||
final addHoverColor = Color.lerp(addColor, Colors.black, 0.2)!;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: widget.enabled ? (_) => setState(() => isHovered = true) : null,
|
||||
onExit: widget.enabled ? (_) => setState(() => isHovered = false) : null,
|
||||
child: GestureDetector(
|
||||
onTap: widget.enabled ? widget.onTap : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.enabled
|
||||
? (widget.isFavorite
|
||||
? (isHovered ? removeHoverColor : removeColor)
|
||||
: (isHovered ? addHoverColor : addColor))
|
||||
: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.isFavorite ? "Remove".tl : "Add".tl,
|
||||
style: ts.s12.copyWith(
|
||||
color: widget.enabled
|
||||
? context.colorScheme.onPrimary
|
||||
: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,57 @@ import 'package:venera/utils/translations.dart';
|
||||
class ComicSourcePage extends StatelessWidget {
|
||||
const ComicSourcePage({super.key});
|
||||
|
||||
static Future<void> update(
|
||||
ComicSource source, [
|
||||
bool showLoading = true,
|
||||
]) async {
|
||||
if (!source.url.isURL) {
|
||||
if (showLoading) {
|
||||
App.rootContext.showMessage(message: "Invalid url config");
|
||||
return;
|
||||
} else {
|
||||
throw Exception("Invalid url config");
|
||||
}
|
||||
}
|
||||
ComicSourceManager().remove(source.key);
|
||||
bool cancel = false;
|
||||
LoadingDialogController? controller;
|
||||
if (showLoading) {
|
||||
controller = showLoadingDialog(
|
||||
App.rootContext,
|
||||
onCancel: () => cancel = true,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
try {
|
||||
var res = await AppDio().get<String>(
|
||||
source.url,
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {"cache-time": "no"},
|
||||
),
|
||||
);
|
||||
if (cancel) return;
|
||||
controller?.close();
|
||||
await ComicSourceParser().parse(res.data!, source.filePath);
|
||||
await io.File(source.filePath).writeAsString(res.data!);
|
||||
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
|
||||
ComicSourceManager().availableUpdates.remove(source.key);
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancel) return;
|
||||
if (showLoading) {
|
||||
App.rootContext.showMessage(message: e.toString());
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
await ComicSourceManager().reload();
|
||||
if (showLoading) {
|
||||
App.forceRebuild();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<int> checkComicSourceUpdate() async {
|
||||
if (ComicSource.all().isEmpty) {
|
||||
return 0;
|
||||
@@ -152,42 +203,8 @@ class _BodyState extends State<_Body> {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> update(
|
||||
ComicSource source, [
|
||||
bool showLoading = true,
|
||||
]) async {
|
||||
if (!source.url.isURL) {
|
||||
App.rootContext.showMessage(message: "Invalid url config");
|
||||
return;
|
||||
}
|
||||
ComicSourceManager().remove(source.key);
|
||||
bool cancel = false;
|
||||
LoadingDialogController? controller;
|
||||
if (showLoading) {
|
||||
controller = showLoadingDialog(
|
||||
App.rootContext,
|
||||
onCancel: () => cancel = true,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
try {
|
||||
var res = await AppDio().get<String>(
|
||||
source.url,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
if (cancel) return;
|
||||
controller?.close();
|
||||
await ComicSourceParser().parse(res.data!, source.filePath);
|
||||
await File(source.filePath).writeAsString(res.data!);
|
||||
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
|
||||
ComicSourceManager().availableUpdates.remove(source.key);
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancel) return;
|
||||
App.rootContext.showMessage(message: e.toString());
|
||||
}
|
||||
await ComicSourceManager().reload();
|
||||
App.forceRebuild();
|
||||
void update(ComicSource source, [bool showLoading = true]) {
|
||||
ComicSourcePage.update(source, showLoading);
|
||||
}
|
||||
|
||||
Widget buildCard(BuildContext context) {
|
||||
@@ -287,7 +304,10 @@ class _BodyState extends State<_Body> {
|
||||
try {
|
||||
var res = await AppDio().get<String>(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
options: Options(
|
||||
responseType: ResponseType.plain,
|
||||
headers: {"cache-time": "no"},
|
||||
),
|
||||
);
|
||||
if (cancel) return;
|
||||
controller.close();
|
||||
@@ -679,7 +699,7 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
||||
var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
|
||||
for (var key in shouldUpdate) {
|
||||
var source = ComicSource.find(key)!;
|
||||
await _BodyState.update(source, false);
|
||||
await ComicSourcePage.update(source, false);
|
||||
current++;
|
||||
loadingController.setProgress(current / total);
|
||||
}
|
||||
@@ -693,11 +713,13 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilledButton.tonalIcon(
|
||||
icon: isLoading ? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
) : Icon(Icons.update),
|
||||
icon: isLoading
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(Icons.update),
|
||||
label: Text("Check updates".tl),
|
||||
onPressed: check,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/network/cache.dart';
|
||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
|
||||
@@ -512,6 +512,18 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
MenuEntry(
|
||||
icon: Icons.chrome_reader_mode_outlined,
|
||||
text: "Read".tl,
|
||||
onClick: () {
|
||||
final c = selectedComics.keys.first as FavoriteItem;
|
||||
App.rootContext.to(() => ReaderWithLoading(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
));
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -36,6 +36,8 @@ Future<bool> _deleteComic(
|
||||
favId,
|
||||
);
|
||||
if (res.success) {
|
||||
// Invalidate network cache so next loads fetch fresh data
|
||||
NetworkCacheManager().clear();
|
||||
context.showMessage(message: "Deleted".tl);
|
||||
result = true;
|
||||
context.pop();
|
||||
@@ -115,6 +117,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
// Force refresh bypassing cache
|
||||
NetworkCacheManager().clear();
|
||||
comicListKey.currentState!.refresh();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,10 +5,10 @@ import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import '../foundation/global_state.dart';
|
||||
import 'package:venera/foundation/follow_updates.dart';
|
||||
|
||||
class FollowUpdatesWidget extends StatefulWidget {
|
||||
const FollowUpdatesWidget({super.key});
|
||||
@@ -460,7 +460,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
message: "Updating comics...".tl,
|
||||
);
|
||||
|
||||
await for (var progress in _updateFolder(folder, true)) {
|
||||
await for (var progress in updateFolder(folder, true)) {
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
@@ -497,7 +497,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
|
||||
int updated = 0;
|
||||
|
||||
await for (var progress in _updateFolder(folder!, true)) {
|
||||
await for (var progress in updateFolder(folder!, true)) {
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
@@ -532,128 +532,6 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
Object? get key => 'FollowUpdatesPage';
|
||||
}
|
||||
|
||||
class _UpdateProgress {
|
||||
final int total;
|
||||
final int current;
|
||||
final int errors;
|
||||
final int updated;
|
||||
|
||||
_UpdateProgress(this.total, this.current, this.errors, this.updated);
|
||||
}
|
||||
|
||||
void _updateFolderBase(
|
||||
String folder,
|
||||
StreamController<_UpdateProgress> stream,
|
||||
bool ignoreCheckTime,
|
||||
) async {
|
||||
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
|
||||
int current = 0;
|
||||
int errors = 0;
|
||||
int updated = 0;
|
||||
var futures = <Future>[];
|
||||
const maxConcurrent = 5;
|
||||
|
||||
for (int i = 0; i < comics.length; i++) {
|
||||
if (stream.isClosed) {
|
||||
return;
|
||||
}
|
||||
if (!ignoreCheckTime) {
|
||||
var lastCheckTime = comics[i].lastCheckTime;
|
||||
if (lastCheckTime != null &&
|
||||
DateTime.now().difference(lastCheckTime).inDays < 1) {
|
||||
current++;
|
||||
stream.add(_UpdateProgress(comics.length, current, errors, updated));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (futures.length >= maxConcurrent) {
|
||||
await Future.any(futures);
|
||||
}
|
||||
|
||||
var future = () async {
|
||||
int retries = 3;
|
||||
while (true) {
|
||||
try {
|
||||
var c = comics[i];
|
||||
var comicSource = c.type.comicSource;
|
||||
if (comicSource == null) return;
|
||||
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
|
||||
|
||||
var newTags = <String>[];
|
||||
for (var entry in newInfo.tags.entries) {
|
||||
const shouldIgnore = ['author', 'artist', 'time'];
|
||||
var namespace = entry.key;
|
||||
if (shouldIgnore.contains(namespace.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
for (var tag in entry.value) {
|
||||
newTags.add("$namespace:$tag");
|
||||
}
|
||||
}
|
||||
|
||||
var item = FavoriteItem(
|
||||
id: c.id,
|
||||
name: newInfo.title,
|
||||
coverPath: newInfo.cover,
|
||||
author: newInfo.subTitle ??
|
||||
newInfo.tags['author']?.firstOrNull ??
|
||||
c.author,
|
||||
type: c.type,
|
||||
tags: newTags,
|
||||
);
|
||||
|
||||
LocalFavoritesManager().updateInfo(folder, item, false);
|
||||
|
||||
var updateTime = newInfo.findUpdateTime();
|
||||
if (updateTime != null && updateTime != c.updateTime) {
|
||||
LocalFavoritesManager().updateUpdateTime(
|
||||
folder,
|
||||
c.id,
|
||||
c.type,
|
||||
updateTime,
|
||||
);
|
||||
} else {
|
||||
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
|
||||
}
|
||||
updated++;
|
||||
return;
|
||||
} catch (e, s) {
|
||||
Log.error("Check Updates", e, s);
|
||||
retries--;
|
||||
if (retries == 0) {
|
||||
errors++;
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
current++;
|
||||
stream.add(_UpdateProgress(comics.length, current, errors, updated));
|
||||
}
|
||||
}
|
||||
}();
|
||||
|
||||
future.then((_) {
|
||||
futures.remove(future);
|
||||
});
|
||||
|
||||
futures.add(future);
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
|
||||
if (updated > 0) {
|
||||
LocalFavoritesManager().notifyChanges();
|
||||
}
|
||||
|
||||
stream.close();
|
||||
}
|
||||
|
||||
Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) {
|
||||
var stream = StreamController<_UpdateProgress>();
|
||||
_updateFolderBase(folder, stream, ignoreCheckTime);
|
||||
return stream.stream;
|
||||
}
|
||||
|
||||
/// Background service for checking updates
|
||||
abstract class FollowUpdatesService {
|
||||
static bool _isChecking = false;
|
||||
@@ -683,7 +561,7 @@ abstract class FollowUpdatesService {
|
||||
|
||||
int updated = 0;
|
||||
try {
|
||||
await for (var progress in _updateFolder(folder, false)) {
|
||||
await for (var progress in updateFolder(folder, false)) {
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -514,51 +514,53 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
...List.generate(importMethods.length, (index) {
|
||||
return RadioListTile(
|
||||
title: Text(importMethods[index]),
|
||||
value: index,
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value as int;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
if (type != 4)
|
||||
ListTile(
|
||||
title: Text("Add to favorites".tl),
|
||||
trailing: Select(
|
||||
current: selectedFolder,
|
||||
values: folders,
|
||||
minWidth: 112,
|
||||
onTap: (v) {
|
||||
setState(() {
|
||||
selectedFolder = folders[v];
|
||||
});
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
||||
CheckboxListTile(
|
||||
enabled: true,
|
||||
title: Text("Copy to app local path".tl),
|
||||
value: copyToLocalFolder,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
copyToLocalFolder = !copyToLocalFolder;
|
||||
});
|
||||
}).paddingHorizontal(8),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
: RadioGroup<int>(
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value ?? type;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
...List.generate(importMethods.length, (index) {
|
||||
return RadioListTile<int>(
|
||||
title: Text(importMethods[index]),
|
||||
value: index,
|
||||
);
|
||||
}),
|
||||
if (type != 4)
|
||||
ListTile(
|
||||
title: Text("Add to favorites".tl),
|
||||
trailing: Select(
|
||||
current: selectedFolder,
|
||||
values: folders,
|
||||
minWidth: 112,
|
||||
onTap: (v) {
|
||||
setState(() {
|
||||
selectedFolder = folders[v];
|
||||
});
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
||||
CheckboxListTile(
|
||||
enabled: true,
|
||||
title: Text("Copy to app local path".tl),
|
||||
value: copyToLocalFolder,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
copyToLocalFolder = !copyToLocalFolder;
|
||||
});
|
||||
}).paddingHorizontal(8),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
child: Row(
|
||||
|
||||
@@ -404,21 +404,23 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
|
||||
children: [
|
||||
tabBar,
|
||||
TabViewBody(children: [
|
||||
Column(
|
||||
children: ImageFavoriteSortType.values
|
||||
.map(
|
||||
(e) => RadioListTile<ImageFavoriteSortType>(
|
||||
title: Text(e.value.tl),
|
||||
value: e,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
RadioGroup<ImageFavoriteSortType>(
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v ?? sortType;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: ImageFavoriteSortType.values
|
||||
.map(
|
||||
(e) => RadioListTile<ImageFavoriteSortType>(
|
||||
title: Text(e.value.tl),
|
||||
value: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
|
||||
@@ -70,39 +70,29 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Sort".tl,
|
||||
content: Column(
|
||||
children: [
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Name".tl),
|
||||
value: LocalSortType.name,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date".tl),
|
||||
value: LocalSortType.timeAsc,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date Desc".tl),
|
||||
value: LocalSortType.timeDesc,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
content: RadioGroup<LocalSortType>(
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v ?? sortType;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Name".tl),
|
||||
value: LocalSortType.name,
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date".tl),
|
||||
value: LocalSortType.timeAsc,
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date Desc".tl),
|
||||
value: LocalSortType.timeDesc,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
|
||||
@@ -152,7 +152,8 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
||||
|
||||
bool _dragInProgress = false;
|
||||
|
||||
bool get _enableDoubleTapToZoom => appdata.settings["enableDoubleTapToZoom"];
|
||||
bool get _enableDoubleTapToZoom =>
|
||||
appdata.settings.getReaderSetting(reader.cid, reader.type.sourceKey, 'enableDoubleTapToZoom');
|
||||
|
||||
void onTapUp(TapUpDetails event) {
|
||||
if (_longPressInProgress) {
|
||||
@@ -190,7 +191,8 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
||||
} else if (context.readerScaffold.isOpen) {
|
||||
context.readerScaffold.openOrClose();
|
||||
} else {
|
||||
if (appdata.settings['enableTapToTurnPages']) {
|
||||
if (appdata.settings.getReaderSetting(
|
||||
reader.cid, reader.type.sourceKey, 'enableTapToTurnPages')) {
|
||||
bool isLeft = false, isRight = false, isTop = false, isBottom = false;
|
||||
final width = context.width;
|
||||
final height = context.height;
|
||||
@@ -207,11 +209,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
||||
isBottom = true;
|
||||
}
|
||||
bool isCenter = false;
|
||||
var prev = context.reader.toPrevPage;
|
||||
var next = context.reader.toNextPage;
|
||||
if (appdata.settings['reverseTapToTurnPages']) {
|
||||
prev = context.reader.toNextPage;
|
||||
next = context.reader.toPrevPage;
|
||||
var prev = () => context.reader.toPrevPage();
|
||||
var next = () => context.reader.toNextPage();
|
||||
if (appdata.settings.getReaderSetting(
|
||||
reader.cid, reader.type.sourceKey, 'reverseTapToTurnPages')) {
|
||||
prev = () => context.reader.toNextPage();
|
||||
next = () => context.reader.toPrevPage();
|
||||
}
|
||||
switch (context.reader.mode) {
|
||||
case ReaderMode.galleryLeftToRight:
|
||||
|
||||
@@ -32,10 +32,17 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
||||
inProgress = true;
|
||||
if (reader.type == ComicType.local ||
|
||||
(LocalManager().isDownloaded(
|
||||
reader.cid, reader.type, reader.chapter, reader.widget.chapters))) {
|
||||
reader.cid,
|
||||
reader.type,
|
||||
reader.chapter,
|
||||
reader.widget.chapters,
|
||||
))) {
|
||||
try {
|
||||
var images = await LocalManager()
|
||||
.getImages(reader.cid, reader.type, reader.chapter);
|
||||
var images = await LocalManager().getImages(
|
||||
reader.cid,
|
||||
reader.type,
|
||||
reader.chapter,
|
||||
);
|
||||
setState(() {
|
||||
reader.images = images;
|
||||
reader.isLoading = false;
|
||||
@@ -81,9 +88,7 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
||||
Widget build(BuildContext context) {
|
||||
if (reader.isLoading) {
|
||||
load();
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (error != null) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
@@ -104,7 +109,8 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
||||
} else {
|
||||
if (reader.mode.isGallery) {
|
||||
return _GalleryMode(
|
||||
key: Key('${reader.mode.key}_${reader.imagesPerPage}'));
|
||||
key: Key('${reader.mode.key}_${reader.imagesPerPage}'),
|
||||
);
|
||||
} else {
|
||||
return _ContinuousMode(key: Key(reader.mode.key));
|
||||
}
|
||||
@@ -132,11 +138,15 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
/// [totalPages] is the total number of pages in the current chapter.
|
||||
/// More than one images can be displayed on one page.
|
||||
int get totalPages {
|
||||
if (!reader.showSingleImageOnFirstPage) {
|
||||
return (reader.images!.length / reader.imagesPerPage).ceil();
|
||||
if (!reader.showSingleImageOnFirstPage()) {
|
||||
return (reader.images!.length /
|
||||
reader.imagesPerPage)
|
||||
.ceil();
|
||||
} else {
|
||||
return 1 +
|
||||
((reader.images!.length - 1) / reader.imagesPerPage).ceil();
|
||||
((reader.images!.length - 1) /
|
||||
reader.imagesPerPage)
|
||||
.ceil();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,23 +169,38 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
/// Get the range of images for the given page. [page] is 1-based.
|
||||
(int start, int end) getPageImagesRange(int page) {
|
||||
if (reader.showSingleImageOnFirstPage) {
|
||||
var imagesPerPage = reader.imagesPerPage;
|
||||
if (reader.showSingleImageOnFirstPage()) {
|
||||
if (page == 1) {
|
||||
return (0, 1);
|
||||
} else {
|
||||
int startIndex = (page - 2) * reader.imagesPerPage + 1;
|
||||
int startIndex = (page - 2) * imagesPerPage + 1;
|
||||
int endIndex = math.min(
|
||||
startIndex + reader.imagesPerPage, reader.images!.length);
|
||||
startIndex + imagesPerPage,
|
||||
reader.images!.length,
|
||||
);
|
||||
return (startIndex, endIndex);
|
||||
}
|
||||
} else {
|
||||
int startIndex = (page - 1) * reader.imagesPerPage;
|
||||
int startIndex = (page - 1) * imagesPerPage;
|
||||
int endIndex = math.min(
|
||||
startIndex + reader.imagesPerPage, reader.images!.length);
|
||||
startIndex + imagesPerPage,
|
||||
reader.images!.length,
|
||||
);
|
||||
return (startIndex, endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the image indices for current page. Returns null if no images.
|
||||
/// Returns a single index if only one image, or a range if multiple images.
|
||||
(int, int)? getCurrentPageImageRange() {
|
||||
if (reader.images == null || reader.images!.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
var (startIndex, endIndex) = getPageImagesRange(reader.page);
|
||||
return (startIndex, endIndex);
|
||||
}
|
||||
|
||||
/// [cache] is used to cache the images.
|
||||
/// The count of images to cache is determined by the [preCacheCount] setting.
|
||||
/// For previous page and next page, it will do a memory cache.
|
||||
@@ -193,9 +218,9 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
var (startIndex, endIndex) = getPageImagesRange(page);
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
if (shouldPreCache) {
|
||||
_precacheImage(i+1, context);
|
||||
_precacheImage(i + 1, context);
|
||||
} else {
|
||||
_preDownloadImage(i+1, context);
|
||||
_preDownloadImage(i + 1, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,16 +242,12 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
var controller = photoViewControllers[reader.page]!;
|
||||
Offset value = event.delta;
|
||||
if (isLongPressing) {
|
||||
controller.updateMultiple(
|
||||
position: controller.position + value,
|
||||
);
|
||||
controller.updateMultiple(position: controller.position + value);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: PhotoViewGallery.builder(
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
),
|
||||
backgroundDecoration: BoxDecoration(color: context.colorScheme.surface),
|
||||
reverse: reader.mode == ReaderMode.galleryRightToLeft,
|
||||
scrollDirection: reader.mode == ReaderMode.galleryTopToBottom
|
||||
? Axis.vertical
|
||||
@@ -239,14 +260,17 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
);
|
||||
} else {
|
||||
var (startIndex, endIndex) = getPageImagesRange(index);
|
||||
List<String> pageImages =
|
||||
reader.images!.sublist(startIndex, endIndex);
|
||||
List<String> pageImages = reader.images!.sublist(
|
||||
startIndex,
|
||||
endIndex,
|
||||
);
|
||||
|
||||
cache(index);
|
||||
|
||||
photoViewControllers[index] ??= PhotoViewController();
|
||||
|
||||
if (reader.imagesPerPage == 1 || pageImages.length == 1) {
|
||||
if (reader.imagesPerPage == 1 ||
|
||||
pageImages.length == 1) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
filterQuality: FilterQuality.medium,
|
||||
controller: photoViewControllers[index],
|
||||
@@ -356,13 +380,16 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
onInit: (state) => imageStates.add(state),
|
||||
onDispose: (state) => imageStates.remove(state),
|
||||
),
|
||||
)
|
||||
),
|
||||
];
|
||||
} else {
|
||||
imageWidgets = images.map((imageKey) {
|
||||
startIndex++;
|
||||
ImageProvider imageProvider =
|
||||
_createImageProviderFromKey(imageKey, context, startIndex);
|
||||
ImageProvider imageProvider = _createImageProviderFromKey(
|
||||
imageKey,
|
||||
context,
|
||||
startIndex,
|
||||
);
|
||||
return Expanded(
|
||||
child: ComicImage(
|
||||
image: imageProvider,
|
||||
@@ -423,10 +450,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
} else {
|
||||
zoomPosition = Offset(0, 0);
|
||||
}
|
||||
photoViewController.animateScale?.call(
|
||||
target,
|
||||
zoomPosition,
|
||||
);
|
||||
photoViewController.animateScale?.call(target, zoomPosition);
|
||||
isLongPressing = true;
|
||||
}
|
||||
|
||||
@@ -471,14 +495,14 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
keyRepeatTimer = null;
|
||||
}
|
||||
if (forward == true) {
|
||||
reader.toPage(reader.page+1);
|
||||
reader.toPage(reader.page + 1);
|
||||
} else if (forward == false) {
|
||||
reader.toPage(reader.page-1);
|
||||
reader.toPage(reader.page - 1);
|
||||
}
|
||||
}
|
||||
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
|
||||
keyRepeatTimer = Timer.periodic(
|
||||
reader.enablePageAnimation
|
||||
reader.enablePageAnimation(reader.cid, reader.type)
|
||||
? const Duration(milliseconds: 200)
|
||||
: const Duration(milliseconds: 50),
|
||||
(timer) {
|
||||
@@ -486,9 +510,9 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
timer.cancel();
|
||||
return;
|
||||
} else if (forward == true) {
|
||||
reader.toPage(reader.page+1);
|
||||
reader.toPage(reader.page + 1);
|
||||
} else if (forward == false) {
|
||||
reader.toPage(reader.page-1);
|
||||
reader.toPage(reader.page - 1);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -512,24 +536,34 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
return await File(imageKey.substring(7)).readAsBytes();
|
||||
} else {
|
||||
return (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
||||
.readAsBytes();
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||
))!.readAsBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String? getImageKeyByOffset(Offset offset) {
|
||||
String? imageKey;
|
||||
if (reader.imagesPerPage == 1) {
|
||||
imageKey = reader.images![reader.page - 1];
|
||||
} else {
|
||||
for (var imageState in imageStates) {
|
||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||
var range = getCurrentPageImageRange();
|
||||
if (range == null) return null;
|
||||
|
||||
var (startIndex, endIndex) = range;
|
||||
int actualImageCount = endIndex - startIndex;
|
||||
|
||||
if (actualImageCount == 1) {
|
||||
return reader.images![startIndex];
|
||||
}
|
||||
|
||||
for (var imageState in imageStates) {
|
||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||
var imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||
int index = reader.images!.indexOf(imageKey);
|
||||
if (index >= startIndex && index < endIndex) {
|
||||
return imageKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageKey;
|
||||
|
||||
return reader.images![startIndex];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,7 +572,7 @@ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.stylus,
|
||||
PointerDeviceKind.invertedStylus,
|
||||
PointerDeviceKind.unknown
|
||||
PointerDeviceKind.unknown,
|
||||
};
|
||||
|
||||
const double _kChangeChapterOffset = 160;
|
||||
@@ -624,27 +658,52 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
cacheImages(page);
|
||||
}
|
||||
|
||||
double? futurePosition;
|
||||
double? _futurePosition;
|
||||
|
||||
void smoothTo(double offset) {
|
||||
futurePosition ??= scrollController.offset;
|
||||
if (futurePosition! > scrollController.position.maxScrollExtent &&
|
||||
offset > 0) {
|
||||
return;
|
||||
} else if (futurePosition! < scrollController.position.minScrollExtent &&
|
||||
offset < 0) {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
return;
|
||||
}
|
||||
futurePosition = futurePosition! + offset * 1.2;
|
||||
futurePosition = futurePosition!.clamp(
|
||||
var currentLocation = scrollController.position.pixels;
|
||||
var old = _futurePosition;
|
||||
_futurePosition ??= currentLocation;
|
||||
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
|
||||
final customSpeed = appdata.settings.getReaderSetting(
|
||||
context.reader.cid,
|
||||
context.reader.type.sourceKey,
|
||||
"readerScrollSpeed",
|
||||
);
|
||||
if (customSpeed is num) {
|
||||
k *= customSpeed;
|
||||
}
|
||||
_futurePosition = _futurePosition! + offset * k;
|
||||
var beforeOffset = (_futurePosition! - currentLocation).abs();
|
||||
_futurePosition = _futurePosition!.clamp(
|
||||
scrollController.position.minScrollExtent,
|
||||
scrollController.position.maxScrollExtent,
|
||||
);
|
||||
scrollController.animateTo(
|
||||
futurePosition!,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
var afterOffset = (_futurePosition! - currentLocation).abs();
|
||||
if (_futurePosition == old) return;
|
||||
var target = _futurePosition!;
|
||||
var duration = const Duration(milliseconds: 160);
|
||||
if (afterOffset < beforeOffset) {
|
||||
duration = duration * (afterOffset / beforeOffset);
|
||||
if (duration < Duration(milliseconds: 10)) {
|
||||
duration = Duration(milliseconds: 10);
|
||||
}
|
||||
}
|
||||
scrollController
|
||||
.animateTo(
|
||||
_futurePosition!,
|
||||
duration: duration,
|
||||
curve: Curves.linear,
|
||||
);
|
||||
)
|
||||
.then((_) {
|
||||
var current = scrollController.position.pixels;
|
||||
if (current == target && current == _futurePosition) {
|
||||
_futurePosition = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onPointerSignal(PointerSignalEvent event) {
|
||||
@@ -673,10 +732,12 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
void onScroll() {
|
||||
if (prepareToPrevChapter) {
|
||||
jumpToNextChapter = false;
|
||||
jumpToPrevChapter = scrollController.offset <
|
||||
jumpToPrevChapter =
|
||||
scrollController.offset <
|
||||
scrollController.position.minScrollExtent - _kChangeChapterOffset;
|
||||
} else if (prepareToNextChapter) {
|
||||
jumpToNextChapter = scrollController.offset >
|
||||
jumpToNextChapter =
|
||||
scrollController.offset >
|
||||
scrollController.position.maxScrollExtent + _kChangeChapterOffset;
|
||||
jumpToPrevChapter = false;
|
||||
}
|
||||
@@ -721,8 +782,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
physics: isCTRLPressed || _isMouseScrolling || disableScroll
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: isZoomedIn
|
||||
? const ClampingScrollPhysics()
|
||||
: const BouncingScrollPhysics(),
|
||||
? const ClampingScrollPhysics()
|
||||
: const BouncingScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0 || index == reader.maxPage + 1) {
|
||||
return const SizedBox();
|
||||
@@ -750,8 +811,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
),
|
||||
);
|
||||
},
|
||||
scrollBehavior: const MaterialScrollBehavior()
|
||||
.copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes),
|
||||
scrollBehavior: const MaterialScrollBehavior().copyWith(
|
||||
scrollbars: false,
|
||||
dragDevices: _kTouchLikeDeviceTypes,
|
||||
),
|
||||
);
|
||||
|
||||
widget = Stack(
|
||||
@@ -769,7 +832,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
disableScroll = true;
|
||||
});
|
||||
}
|
||||
futurePosition = null;
|
||||
_futurePosition = null;
|
||||
if (_isMouseScrolling) {
|
||||
setState(() {
|
||||
_isMouseScrolling = false;
|
||||
@@ -895,20 +958,14 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
}
|
||||
|
||||
return PhotoView.customChild(
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
),
|
||||
backgroundDecoration: BoxDecoration(color: context.colorScheme.surface),
|
||||
childSize: Size(width, height),
|
||||
minScale: 1.0,
|
||||
maxScale: 2.5,
|
||||
strictScale: true,
|
||||
controller: photoViewController,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: widget,
|
||||
),
|
||||
child: SizedBox(width: width, height: height, child: widget),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -978,10 +1035,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
} else {
|
||||
zoomPosition = Offset(0, 0);
|
||||
}
|
||||
photoViewController.animateScale?.call(
|
||||
target,
|
||||
zoomPosition,
|
||||
);
|
||||
photoViewController.animateScale?.call(target, zoomPosition);
|
||||
onScaleUpdate(target);
|
||||
isLongPressing = true;
|
||||
}
|
||||
@@ -1000,7 +1054,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
@override
|
||||
void toPage(int page) {
|
||||
itemScrollController.jumpTo(index: page);
|
||||
futurePosition = null;
|
||||
_futurePosition = null;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1069,8 +1123,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
return await File(imageKey.substring(7)).readAsBytes();
|
||||
} else {
|
||||
return (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
||||
.readAsBytes();
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||
))!.readAsBytes();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1114,10 +1168,7 @@ void _precacheImage(int page, BuildContext context) {
|
||||
if (page <= 0 || page > context.reader.images!.length) {
|
||||
return;
|
||||
}
|
||||
precacheImage(
|
||||
_createImageProvider(page, context),
|
||||
context,
|
||||
);
|
||||
precacheImage(_createImageProvider(page, context), context);
|
||||
}
|
||||
|
||||
/// [_preDownloadImage] is used to download the image for the given page.
|
||||
@@ -1138,10 +1189,7 @@ void _preDownloadImage(int page, BuildContext context) {
|
||||
}
|
||||
|
||||
class _SwipeChangeChapterProgress extends StatefulWidget {
|
||||
const _SwipeChangeChapterProgress({
|
||||
this.controller,
|
||||
required this.isPrev,
|
||||
});
|
||||
const _SwipeChangeChapterProgress({this.controller, required this.isPrev});
|
||||
|
||||
final ScrollController? controller;
|
||||
|
||||
@@ -1258,7 +1306,12 @@ class _ProgressPainter extends CustomPainter {
|
||||
paint.color = color;
|
||||
canvas.drawRRect(
|
||||
RRect.fromLTRBR(
|
||||
0, 0, size.width * value, size.height, Radius.circular(16)),
|
||||
0,
|
||||
0,
|
||||
size.width * value,
|
||||
size.height,
|
||||
Radius.circular(16),
|
||||
),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,15 +115,17 @@ class _ReaderState extends State<Reader>
|
||||
if (images == null) {
|
||||
return 1;
|
||||
}
|
||||
if (!showSingleImageOnFirstPage) {
|
||||
if (!showSingleImageOnFirstPage()) {
|
||||
return (images!.length / imagesPerPage).ceil();
|
||||
} else {
|
||||
return 1 + ((images!.length - 1) / imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
ComicType get type => widget.type;
|
||||
|
||||
@override
|
||||
String get cid => widget.cid;
|
||||
|
||||
String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
|
||||
@@ -162,12 +164,13 @@ class _ReaderState extends State<Reader>
|
||||
if (widget.initialPage != null) {
|
||||
page = widget.initialPage!;
|
||||
}
|
||||
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||
// mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||
mode = ReaderMode.fromKey(appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'));
|
||||
history = widget.history;
|
||||
if (!appdata.settings['showSystemStatusBar']) {
|
||||
if (!appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSystemStatusBar')) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
if (appdata.settings['enableTurnPageByVolumeKey']) {
|
||||
if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) {
|
||||
handleVolumeEvent();
|
||||
}
|
||||
setImageCacheSize();
|
||||
@@ -274,7 +277,7 @@ class _ReaderState extends State<Reader>
|
||||
history!.page = images?.length ?? 1;
|
||||
} else {
|
||||
/// Record the first image of the page
|
||||
if (!showSingleImageOnFirstPage || imagesPerPage == 1) {
|
||||
if (!showSingleImageOnFirstPage() || imagesPerPage == 1) {
|
||||
history!.page = (page - 1) * imagesPerPage + 1;
|
||||
} else {
|
||||
if (page == 1) {
|
||||
@@ -363,11 +366,15 @@ abstract mixin class _ImagePerPageHandler {
|
||||
|
||||
ReaderMode get mode;
|
||||
|
||||
String get cid;
|
||||
|
||||
ComicType get type;
|
||||
|
||||
void initImagesPerPage(int initialPage) {
|
||||
_lastImagesPerPage = imagesPerPage;
|
||||
_lastOrientation = isPortrait;
|
||||
if (imagesPerPage != 1) {
|
||||
if (showSingleImageOnFirstPage) {
|
||||
if (showSingleImageOnFirstPage()) {
|
||||
page = ((initialPage - 1) / imagesPerPage).ceil() + 1;
|
||||
} else {
|
||||
page = (initialPage / imagesPerPage).ceil();
|
||||
@@ -375,16 +382,16 @@ abstract mixin class _ImagePerPageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
bool get showSingleImageOnFirstPage =>
|
||||
appdata.settings["showSingleImageOnFirstPage"];
|
||||
bool showSingleImageOnFirstPage() =>
|
||||
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
|
||||
|
||||
/// The number of images displayed on one screen
|
||||
int get imagesPerPage {
|
||||
if (mode.isContinuous) return 1;
|
||||
if (isPortrait) {
|
||||
return appdata.settings['readerScreenPicNumberForPortrait'] ?? 1;
|
||||
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
|
||||
} else {
|
||||
return appdata.settings['readerScreenPicNumberForLandscape'] ?? 1;
|
||||
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForLandscape') ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,18 +401,16 @@ abstract mixin class _ImagePerPageHandler {
|
||||
bool currentOrientation = isPortrait;
|
||||
|
||||
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
|
||||
_adjustPageForImagesPerPageChange(
|
||||
_lastImagesPerPage, currentImagesPerPage);
|
||||
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage);
|
||||
_lastImagesPerPage = currentImagesPerPage;
|
||||
_lastOrientation = currentOrientation;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust the page number when the number of images per page changes
|
||||
void _adjustPageForImagesPerPageChange(
|
||||
int oldImagesPerPage, int newImagesPerPage) {
|
||||
void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) {
|
||||
int previousImageIndex = 1;
|
||||
if (!showSingleImageOnFirstPage || oldImagesPerPage == 1) {
|
||||
if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) {
|
||||
previousImageIndex = (page - 1) * oldImagesPerPage + 1;
|
||||
} else {
|
||||
if (page == 1) {
|
||||
@@ -417,7 +422,7 @@ abstract mixin class _ImagePerPageHandler {
|
||||
|
||||
int newPage;
|
||||
if (newImagesPerPage != 1) {
|
||||
if (showSingleImageOnFirstPage) {
|
||||
if (showSingleImageOnFirstPage()) {
|
||||
newPage = ((previousImageIndex - 1) / newImagesPerPage).ceil() + 1;
|
||||
} else {
|
||||
newPage = (previousImageIndex / newImagesPerPage).ceil();
|
||||
@@ -493,9 +498,13 @@ abstract mixin class _ReaderLocation {
|
||||
|
||||
bool get isLoading;
|
||||
|
||||
String get cid;
|
||||
|
||||
ComicType get type;
|
||||
|
||||
void update();
|
||||
|
||||
bool get enablePageAnimation => appdata.settings['enablePageAnimation'];
|
||||
bool enablePageAnimation(String cid, ComicType type) => appdata.settings.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
|
||||
|
||||
_ImageViewController? _imageViewController;
|
||||
|
||||
@@ -532,7 +541,7 @@ abstract mixin class _ReaderLocation {
|
||||
}
|
||||
this.page = page;
|
||||
update();
|
||||
if (enablePageAnimation) {
|
||||
if (enablePageAnimation(cid, type)) {
|
||||
_animationCount++;
|
||||
_imageViewController!.animateToPage(page).then((_) {
|
||||
_animationCount--;
|
||||
@@ -571,12 +580,12 @@ abstract mixin class _ReaderLocation {
|
||||
|
||||
Timer? autoPageTurningTimer;
|
||||
|
||||
void autoPageTurning() {
|
||||
void autoPageTurning(String cid, ComicType type) {
|
||||
if (autoPageTurningTimer != null) {
|
||||
autoPageTurningTimer!.cancel();
|
||||
autoPageTurningTimer = null;
|
||||
} else {
|
||||
int interval = appdata.settings['autoPageTurningInterval'];
|
||||
int interval = appdata.settings.getReaderSetting(cid, type.sourceKey, 'autoPageTurningInterval');
|
||||
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
|
||||
if (page == maxPage) {
|
||||
autoPageTurningTimer!.cancel();
|
||||
|
||||
@@ -128,9 +128,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: widget.child,
|
||||
),
|
||||
Positioned.fill(child: widget.child),
|
||||
if (appdata.settings['showPageNumberInReader'] == true)
|
||||
buildPageInfoText(),
|
||||
buildStatusInfo(),
|
||||
@@ -168,10 +166,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface.toOpacity(0.92),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.toOpacity(0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
bottom: BorderSide(color: Colors.grey.toOpacity(0.5), width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -216,8 +211,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
try {
|
||||
if (context.reader.images![0].contains('file://')) {
|
||||
showToast(
|
||||
message: "Local comic collection is not supported at present".tl,
|
||||
context: context);
|
||||
message: "Local comic collection is not supported at present".tl,
|
||||
context: context,
|
||||
);
|
||||
return;
|
||||
}
|
||||
String id = context.reader.cid;
|
||||
@@ -234,8 +230,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
List<String> tags = context.reader.widget.tags;
|
||||
String author = context.reader.widget.author;
|
||||
|
||||
var epName = context.reader.widget.chapters?.titles
|
||||
.elementAtOrNull(context.reader.chapter - 1) ??
|
||||
var epName =
|
||||
context.reader.widget.chapters?.titles.elementAtOrNull(
|
||||
context.reader.chapter - 1,
|
||||
) ??
|
||||
"E${context.reader.chapter}";
|
||||
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
|
||||
|
||||
@@ -248,7 +246,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
return;
|
||||
}
|
||||
ImageFavoriteManager().deleteImageFavorite([
|
||||
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName)
|
||||
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName),
|
||||
]);
|
||||
showToast(
|
||||
message: "Uncollected the image".tl,
|
||||
@@ -256,7 +254,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
seconds: 1,
|
||||
);
|
||||
} else {
|
||||
var imageFavoritesComic = ImageFavoriteManager().find(id, sourceKey) ??
|
||||
var imageFavoritesComic =
|
||||
ImageFavoriteManager().find(id, sourceKey) ??
|
||||
ImageFavoritesComic(
|
||||
id,
|
||||
[],
|
||||
@@ -270,12 +269,21 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
subTitle,
|
||||
maxPage,
|
||||
);
|
||||
ImageFavorite imageFavorite =
|
||||
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName);
|
||||
ImageFavoritesEp? imageFavoritesEp =
|
||||
imageFavoritesComic.imageFavoritesEp.firstWhereOrNull((e) {
|
||||
return e.ep == ep;
|
||||
});
|
||||
ImageFavorite imageFavorite = ImageFavorite(
|
||||
page,
|
||||
imageKey,
|
||||
null,
|
||||
eid,
|
||||
id,
|
||||
ep,
|
||||
sourceKey,
|
||||
epName,
|
||||
);
|
||||
ImageFavoritesEp? imageFavoritesEp = imageFavoritesComic
|
||||
.imageFavoritesEp
|
||||
.firstWhereOrNull((e) {
|
||||
return e.ep == ep;
|
||||
});
|
||||
if (imageFavoritesEp == null) {
|
||||
if (page != firstPage) {
|
||||
var copy = imageFavorite.copyWith(
|
||||
@@ -285,10 +293,20 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
);
|
||||
// 不是第一页的话, 自动塞一个封面进去
|
||||
imageFavoritesEp = ImageFavoritesEp(
|
||||
eid, ep, [copy, imageFavorite], epName, maxPage);
|
||||
eid,
|
||||
ep,
|
||||
[copy, imageFavorite],
|
||||
epName,
|
||||
maxPage,
|
||||
);
|
||||
} else {
|
||||
imageFavoritesEp =
|
||||
ImageFavoritesEp(eid, ep, [imageFavorite], epName, maxPage);
|
||||
imageFavoritesEp = ImageFavoritesEp(
|
||||
eid,
|
||||
ep,
|
||||
[imageFavorite],
|
||||
epName,
|
||||
maxPage,
|
||||
);
|
||||
}
|
||||
imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp);
|
||||
} else {
|
||||
@@ -312,7 +330,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
|
||||
ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic);
|
||||
showToast(
|
||||
message: "Successfully collected".tl, context: context, seconds: 1);
|
||||
message: "Successfully collected".tl,
|
||||
context: context,
|
||||
seconds: 1,
|
||||
);
|
||||
}
|
||||
update();
|
||||
} catch (e, stackTrace) {
|
||||
@@ -327,155 +348,152 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
text = "P${context.reader.page}";
|
||||
}
|
||||
|
||||
final buttons = [
|
||||
Tooltip(
|
||||
message: "Collect the image".tl,
|
||||
child: IconButton(
|
||||
icon: Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
|
||||
onPressed: addImageFavorite,
|
||||
),
|
||||
),
|
||||
if (App.isDesktop)
|
||||
Tooltip(
|
||||
message: "${"Full Screen".tl}(F12)",
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.fullscreen),
|
||||
onPressed: () {
|
||||
context.reader.fullscreen();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (App.isAndroid)
|
||||
Tooltip(
|
||||
message: "Screen Rotation".tl,
|
||||
child: IconButton(
|
||||
icon: () {
|
||||
if (rotation == null) {
|
||||
return const Icon(Icons.screen_rotation);
|
||||
} else if (rotation == false) {
|
||||
return const Icon(Icons.screen_lock_portrait);
|
||||
} else {
|
||||
return const Icon(Icons.screen_lock_landscape);
|
||||
}
|
||||
}.call(),
|
||||
onPressed: () {
|
||||
if (rotation == null) {
|
||||
setState(() {
|
||||
rotation = false;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
} else if (rotation == false) {
|
||||
setState(() {
|
||||
rotation = true;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
} else {
|
||||
setState(() {
|
||||
rotation = null;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Auto Page Turning".tl,
|
||||
child: IconButton(
|
||||
icon: context.reader.autoPageTurningTimer != null
|
||||
? const Icon(Icons.timer)
|
||||
: const Icon(Icons.timer_sharp),
|
||||
onPressed: () {
|
||||
context.reader.autoPageTurning(
|
||||
context.reader.cid,
|
||||
context.reader.type,
|
||||
);
|
||||
update();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (context.reader.widget.chapters != null)
|
||||
Tooltip(
|
||||
message: "Chapters".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.library_books),
|
||||
onPressed: openChapterDrawer,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Save Image".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: saveCurrentImage,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Share".tl,
|
||||
child: IconButton(icon: const Icon(Icons.share), onPressed: share),
|
||||
),
|
||||
];
|
||||
|
||||
Widget child = SizedBox(
|
||||
height: kBottomBarHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => !isReversed
|
||||
? context.reader.chapter > 1
|
||||
? context.reader.toPrevChapter()
|
||||
: context.reader.toPage(1)
|
||||
? context.reader.toPrevChapter()
|
||||
: context.reader.toPage(1)
|
||||
: context.reader.chapter < context.reader.maxChapter
|
||||
? context.reader.toNextChapter()
|
||||
: context.reader.toPage(context.reader.maxPage),
|
||||
? context.reader.toNextChapter()
|
||||
: context.reader.toPage(context.reader.maxPage),
|
||||
icon: const Icon(Icons.first_page),
|
||||
),
|
||||
Expanded(
|
||||
child: buildSlider(),
|
||||
),
|
||||
Expanded(child: buildSlider()),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () => !isReversed
|
||||
? context.reader.chapter < context.reader.maxChapter
|
||||
onPressed: () => !isReversed
|
||||
? context.reader.chapter < context.reader.maxChapter
|
||||
? context.reader.toNextChapter()
|
||||
: context.reader.toPage(context.reader.maxPage)
|
||||
: context.reader.chapter > 1
|
||||
? context.reader.toPrevChapter()
|
||||
: context.reader.toPage(1),
|
||||
icon: const Icon(Icons.last_page)),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
: context.reader.chapter > 1
|
||||
? context.reader.toPrevChapter()
|
||||
: context.reader.toPage(1),
|
||||
icon: const Icon(Icons.last_page),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(text),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Tooltip(
|
||||
message: "Collect the image".tl,
|
||||
child: IconButton(
|
||||
icon:
|
||||
Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
|
||||
onPressed: addImageFavorite,
|
||||
),
|
||||
),
|
||||
if (App.isDesktop)
|
||||
Tooltip(
|
||||
message: "${"Full Screen".tl}(F12)",
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.fullscreen),
|
||||
onPressed: () {
|
||||
context.reader.fullscreen();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (App.isAndroid)
|
||||
Tooltip(
|
||||
message: "Screen Rotation".tl,
|
||||
child: IconButton(
|
||||
icon: () {
|
||||
if (rotation == null) {
|
||||
return const Icon(Icons.screen_rotation);
|
||||
} else if (rotation == false) {
|
||||
return const Icon(Icons.screen_lock_portrait);
|
||||
} else {
|
||||
return const Icon(Icons.screen_lock_landscape);
|
||||
}
|
||||
}.call(),
|
||||
onPressed: () {
|
||||
if (rotation == null) {
|
||||
setState(() {
|
||||
rotation = false;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
]);
|
||||
} else if (rotation == false) {
|
||||
setState(() {
|
||||
rotation = true;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight
|
||||
]);
|
||||
} else {
|
||||
setState(() {
|
||||
rotation = null;
|
||||
});
|
||||
SystemChrome.setPreferredOrientations(
|
||||
DeviceOrientation.values);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Auto Page Turning".tl,
|
||||
child: IconButton(
|
||||
icon: context.reader.autoPageTurningTimer != null
|
||||
? const Icon(Icons.timer)
|
||||
: const Icon(Icons.timer_sharp),
|
||||
onPressed: () {
|
||||
context.reader.autoPageTurning();
|
||||
update();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (context.reader.widget.chapters != null)
|
||||
Tooltip(
|
||||
message: "Chapters".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.library_books),
|
||||
onPressed: openChapterDrawer,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Save Image".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
onPressed: saveCurrentImage,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Share".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: share,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4)
|
||||
],
|
||||
)
|
||||
LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
return Row(
|
||||
children: [
|
||||
if ((constrains.maxWidth - buttons.length * 42) > 80)
|
||||
Container(
|
||||
height: 24,
|
||||
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(child: Text(text)),
|
||||
).paddingLeft(16),
|
||||
const Spacer(),
|
||||
...buttons,
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -506,8 +524,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
focusNode: sliderFocus,
|
||||
value: context.reader.page.toDouble(),
|
||||
min: 1,
|
||||
max:
|
||||
context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
|
||||
max: context.reader.maxPage
|
||||
.clamp(context.reader.page, 1 << 16)
|
||||
.toDouble(),
|
||||
reversed: isReversed,
|
||||
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
|
||||
onChanged: (i) {
|
||||
@@ -517,8 +536,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
|
||||
Widget buildPageInfoText() {
|
||||
var epName = context.reader.widget.chapters?.titles
|
||||
.elementAtOrNull(context.reader.chapter - 1) ??
|
||||
var epName =
|
||||
context.reader.widget.chapters?.titles.elementAtOrNull(
|
||||
context.reader.chapter - 1,
|
||||
) ??
|
||||
"E${context.reader.chapter}";
|
||||
if (epName.length > 8) {
|
||||
epName = "${epName.substring(0, 8)}...";
|
||||
@@ -578,39 +599,49 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
|
||||
void saveCurrentImage() async {
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
var result = await selectImageToData();
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
var (imageIndex, data) = result;
|
||||
var fileType = detectFileType(data);
|
||||
var filename = "${context.reader.page}${fileType.ext}";
|
||||
var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
saveFile(data: data, filename: filename);
|
||||
}
|
||||
|
||||
void share() async {
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
var result = await selectImageToData();
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
var (imageIndex, data) = result;
|
||||
var fileType = detectFileType(data);
|
||||
var filename = "${context.reader.page}${fileType.ext}";
|
||||
Share.shareFile(
|
||||
data: data,
|
||||
filename: filename,
|
||||
mime: fileType.mime,
|
||||
);
|
||||
var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
Share.shareFile(data: data, filename: filename, mime: fileType.mime);
|
||||
}
|
||||
|
||||
void openSetting() {
|
||||
showSideBar(
|
||||
context,
|
||||
ReaderSettings(
|
||||
comicId: context.reader.cid,
|
||||
comicSource: context.reader.type.sourceKey,
|
||||
onChanged: (key) {
|
||||
if (key == "readerMode") {
|
||||
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
|
||||
context.reader.mode = ReaderMode.fromKey(
|
||||
appdata.settings.getReaderSetting(
|
||||
context.reader.cid,
|
||||
context.reader.type.sourceKey,
|
||||
key,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (key == "enableTurnPageByVolumeKey") {
|
||||
if (appdata.settings[key]) {
|
||||
if (appdata.settings.getReaderSetting(
|
||||
context.reader.cid,
|
||||
context.reader.type.sourceKey,
|
||||
key,
|
||||
)) {
|
||||
context.reader.handleVolumeEvent();
|
||||
} else {
|
||||
context.reader.stopVolumeEvent();
|
||||
@@ -690,8 +721,29 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
Future<int?> selectImage() async {
|
||||
var reader = context.reader;
|
||||
var imageViewController = context.reader._imageViewController;
|
||||
if (imageViewController is _GalleryModeState && reader.imagesPerPage == 1) {
|
||||
return reader.page - 1;
|
||||
|
||||
bool needsSelection = false;
|
||||
int? singleImageIndex;
|
||||
|
||||
if (imageViewController is _GalleryModeState) {
|
||||
var range = imageViewController.getCurrentPageImageRange();
|
||||
if (range != null) {
|
||||
var (startIndex, endIndex) = range;
|
||||
int actualImageCount = endIndex - startIndex;
|
||||
if (actualImageCount == 1) {
|
||||
needsSelection = false;
|
||||
singleImageIndex = startIndex;
|
||||
} else {
|
||||
needsSelection = true;
|
||||
}
|
||||
}
|
||||
} else if (imageViewController is _ContinuousModeState) {
|
||||
needsSelection = false;
|
||||
singleImageIndex = reader.page - 1;
|
||||
}
|
||||
|
||||
if (!needsSelection && singleImageIndex != null) {
|
||||
return singleImageIndex;
|
||||
} else {
|
||||
var location = await _showSelectImageOverlay();
|
||||
if (location == null) {
|
||||
@@ -705,20 +757,23 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [selectImage], but return the image data.
|
||||
Future<Uint8List?> selectImageToData() async {
|
||||
/// Same as [selectImage], but return the image data with its index.
|
||||
/// Returns (imageIndex, imageData) or null if cancelled.
|
||||
Future<(int, Uint8List)?> selectImageToData() async {
|
||||
var i = await selectImage();
|
||||
if (i == null) {
|
||||
return null;
|
||||
}
|
||||
var imageKey = context.reader.images![i];
|
||||
Uint8List data;
|
||||
if (imageKey.startsWith("file://")) {
|
||||
return await File(imageKey.substring(7)).readAsBytes();
|
||||
data = await File(imageKey.substring(7)).readAsBytes();
|
||||
} else {
|
||||
return (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
||||
.readAsBytes();
|
||||
data = await (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||
))!.readAsBytes();
|
||||
}
|
||||
return (i, data);
|
||||
}
|
||||
|
||||
Future<Offset?> _showSelectImageOverlay() {
|
||||
@@ -733,14 +788,17 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
entry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return Positioned.fill(
|
||||
child: _SelectImageOverlayContent(onTap: (offset) {
|
||||
completer.complete(offset);
|
||||
entry!.remove();
|
||||
}, onDispose: () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
}),
|
||||
child: _SelectImageOverlayContent(
|
||||
onTap: (offset) {
|
||||
completer.complete(offset);
|
||||
entry!.remove();
|
||||
},
|
||||
onDispose: () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -840,20 +898,17 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
|
||||
size: 16,
|
||||
color: batteryColor,
|
||||
// Stroke
|
||||
shadows: List.generate(
|
||||
9,
|
||||
(index) {
|
||||
if (index == 4) {
|
||||
return null;
|
||||
}
|
||||
double offsetX = (index % 3 - 1) * 0.8;
|
||||
double offsetY = ((index / 3).floor() - 1) * 0.8;
|
||||
return Shadow(
|
||||
color: context.colorScheme.onInverseSurface,
|
||||
offset: Offset(offsetX, offsetY),
|
||||
);
|
||||
},
|
||||
).whereType<Shadow>().toList(),
|
||||
shadows: List.generate(9, (index) {
|
||||
if (index == 4) {
|
||||
return null;
|
||||
}
|
||||
double offsetX = (index % 3 - 1) * 0.8;
|
||||
double offsetY = ((index / 3).floor() - 1) * 0.8;
|
||||
return Shadow(
|
||||
color: context.colorScheme.onInverseSurface,
|
||||
offset: Offset(offsetX, offsetY),
|
||||
);
|
||||
}).whereType<Shadow>().toList(),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
@@ -940,10 +995,12 @@ class _SelectImageOverlayContent extends StatefulWidget {
|
||||
final void Function() onDispose;
|
||||
|
||||
@override
|
||||
State<_SelectImageOverlayContent> createState() => _SelectImageOverlayContentState();
|
||||
State<_SelectImageOverlayContent> createState() =>
|
||||
_SelectImageOverlayContentState();
|
||||
}
|
||||
|
||||
class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent> {
|
||||
class _SelectImageOverlayContentState
|
||||
extends State<_SelectImageOverlayContent> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.onDispose();
|
||||
@@ -960,19 +1017,14 @@ class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent>
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(50),
|
||||
child: Align(
|
||||
alignment: Alignment(
|
||||
0,
|
||||
-0.8,
|
||||
),
|
||||
alignment: Alignment(0, -0.8),
|
||||
child: Container(
|
||||
width: 232,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
),
|
||||
border: Border.all(color: context.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -428,30 +428,26 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text("Operation".tl),
|
||||
Radio<bool>(
|
||||
groupValue: upload,
|
||||
value: true,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text("Upload".tl),
|
||||
Radio<bool>(
|
||||
groupValue: upload,
|
||||
value: false,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text("Download".tl),
|
||||
],
|
||||
RadioGroup<bool>(
|
||||
groupValue: upload,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value ?? upload;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Text("Operation".tl),
|
||||
Radio<bool>(
|
||||
value: true,
|
||||
),
|
||||
Text("Upload".tl),
|
||||
Radio<bool>(
|
||||
value: false,
|
||||
),
|
||||
Text("Download".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSize(
|
||||
|
||||
@@ -31,6 +31,10 @@ class DebugPageState extends State<DebugPage> {
|
||||
},
|
||||
actionTitle: 'Open'.tl,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Ignore Certificate Errors".tl,
|
||||
settingKey: "ignoreBadCertificate",
|
||||
).toSliver(),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -58,7 +62,7 @@ class DebugPageState extends State<DebugPage> {
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
try {
|
||||
var res = JsEngine().runCode(controller.text);
|
||||
var res = JsEngine().runCode(controller.text, "<debug>");
|
||||
setState(() {
|
||||
result = res.toString();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,14 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(title: Text("Local Favorites".tl)),
|
||||
_SwitchSetting(
|
||||
title: "Show local favorites before network favorites".tl,
|
||||
settingKey: "localFavoritesFirst",
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Auto close favorite panel after operation".tl,
|
||||
settingKey: "autoCloseFavoritePanel",
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Add new favorite to".tl,
|
||||
settingKey: "newFavoriteAddTo",
|
||||
|
||||
@@ -111,44 +111,34 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
|
||||
return PopUpWidgetScaffold(
|
||||
title: "Proxy".tl,
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: Text("Direct".tl),
|
||||
value: 'direct',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
},
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: Text("System".tl),
|
||||
value: 'system',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text("Manual".tl),
|
||||
value: 'manual',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (type == 'manual') buildManualProxy(),
|
||||
],
|
||||
child: RadioGroup<String>(
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v ?? type;
|
||||
});
|
||||
if (type != 'manual') {
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: Text("Direct".tl),
|
||||
value: 'direct',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: Text("System".tl),
|
||||
value: 'system',
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text("Manual".tl),
|
||||
value: 'manual',
|
||||
),
|
||||
if (type == 'manual') buildManualProxy(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
part of 'settings_page.dart';
|
||||
|
||||
class ReaderSettings extends StatefulWidget {
|
||||
const ReaderSettings({super.key, this.onChanged});
|
||||
const ReaderSettings({
|
||||
super.key,
|
||||
this.onChanged,
|
||||
this.comicId,
|
||||
this.comicSource,
|
||||
});
|
||||
|
||||
final void Function(String key)? onChanged;
|
||||
final String? comicId;
|
||||
final String? comicSource;
|
||||
|
||||
@override
|
||||
State<ReaderSettings> createState() => _ReaderSettingsState();
|
||||
@@ -12,15 +19,57 @@ class ReaderSettings extends StatefulWidget {
|
||||
class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final comicId = widget.comicId;
|
||||
final sourceKey = widget.comicSource;
|
||||
final key = "$comicId@$sourceKey";
|
||||
|
||||
bool isEnabledSpecificSettings =
|
||||
comicId != null &&
|
||||
appdata.settings.isComicSpecificSettingsEnabled(comicId, sourceKey);
|
||||
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(title: Text("Reading".tl)),
|
||||
if (comicId != null && sourceKey != null)
|
||||
SliverMainAxisGroup(
|
||||
slivers: [
|
||||
SwitchListTile(
|
||||
title: Text("Enable comic specific settings".tl),
|
||||
value: isEnabledSpecificSettings,
|
||||
onChanged: (b) {
|
||||
setState(() {
|
||||
appdata.settings.setEnabledComicSpecificSettings(
|
||||
comicId,
|
||||
sourceKey,
|
||||
b,
|
||||
);
|
||||
});
|
||||
},
|
||||
).toSliver(),
|
||||
if (isEnabledSpecificSettings)
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
appdata.settings.resetComicReaderSettings(key);
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
"Clear specific reader settings for this comic".tl,
|
||||
),
|
||||
),
|
||||
).toSliver(),
|
||||
Divider().toSliver(),
|
||||
],
|
||||
),
|
||||
_SwitchSetting(
|
||||
title: "Tap to turn Pages".tl,
|
||||
settingKey: "enableTapToTurnPages",
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("enableTapToTurnPages");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Reverse tap to turn Pages".tl,
|
||||
@@ -28,6 +77,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("reverseTapToTurnPages");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Page animation".tl,
|
||||
@@ -35,6 +86,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("enablePageAnimation");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Reading mode".tl,
|
||||
@@ -58,6 +111,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
}
|
||||
widget.onChanged?.call("readerMode");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SliderSetting(
|
||||
title: "Auto page turning interval".tl,
|
||||
@@ -69,6 +124,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
setState(() {});
|
||||
widget.onChanged?.call("autoPageTurningInterval");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['readerMode']!.startsWith('gallery'),
|
||||
@@ -84,6 +141,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
setState(() {});
|
||||
widget.onChanged?.call("readerScreenPicNumberForLandscape");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
),
|
||||
),
|
||||
SliverAnimatedVisibility(
|
||||
@@ -99,10 +158,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("readerScreenPicNumberForPortrait");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
),
|
||||
),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['readerMode']!.startsWith('gallery') &&
|
||||
visible:
|
||||
appdata.settings['readerMode']!.startsWith('gallery') &&
|
||||
(appdata.settings['readerScreenPicNumberForLandscape'] > 1 ||
|
||||
appdata.settings['readerScreenPicNumberForPortrait'] > 1),
|
||||
child: _SwitchSetting(
|
||||
@@ -111,6 +173,23 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("showSingleImageOnFirstPage");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
),
|
||||
),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['readerMode']!.startsWith('continuous'),
|
||||
child: _SliderSetting(
|
||||
title: "Mouse scroll speed".tl,
|
||||
settingsIndex: "readerScrollSpeed",
|
||||
interval: 0.1,
|
||||
min: 0.5,
|
||||
max: 3,
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("readerScrollSpeed");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
),
|
||||
),
|
||||
_SwitchSetting(
|
||||
@@ -120,6 +199,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
setState(() {});
|
||||
widget.onChanged?.call('enableDoubleTapToZoom');
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: 'Long press to zoom'.tl,
|
||||
@@ -128,6 +209,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
setState(() {});
|
||||
widget.onChanged?.call('enableLongPressToZoom');
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['enableLongPressToZoom'] == true,
|
||||
@@ -138,6 +221,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
"press": "Press position".tl,
|
||||
"center": "Screen center".tl,
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
),
|
||||
),
|
||||
_SwitchSetting(
|
||||
@@ -147,6 +232,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call('limitImageWidth');
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
if (App.isAndroid)
|
||||
_SwitchSetting(
|
||||
@@ -155,6 +242,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call('enableTurnPageByVolumeKey');
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Display time & battery info in reader".tl,
|
||||
@@ -162,6 +251,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Show system status bar".tl,
|
||||
@@ -169,6 +260,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("showSystemStatusBar");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Quick collect image".tl,
|
||||
@@ -184,6 +277,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
help:
|
||||
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
|
||||
.tl,
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_CallbackSetting(
|
||||
title: "Custom Image Processing".tl,
|
||||
@@ -196,6 +291,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
interval: 1,
|
||||
min: 1,
|
||||
max: 16,
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Show Page Number".tl,
|
||||
@@ -203,6 +300,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("showPageNumberInReader");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
@@ -248,7 +347,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
|
||||
setState(() {});
|
||||
},
|
||||
child: Text("Reset".tl),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
@@ -274,7 +373,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ class _SwitchSetting extends StatefulWidget {
|
||||
required this.settingKey,
|
||||
this.onChanged,
|
||||
this.subtitle,
|
||||
this.comicId,
|
||||
this.comicSource,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -16,6 +18,10 @@ class _SwitchSetting extends StatefulWidget {
|
||||
|
||||
final String? subtitle;
|
||||
|
||||
final String? comicId;
|
||||
|
||||
final String? comicSource;
|
||||
|
||||
@override
|
||||
State<_SwitchSetting> createState() => _SwitchSettingState();
|
||||
}
|
||||
@@ -23,16 +29,33 @@ class _SwitchSetting extends StatefulWidget {
|
||||
class _SwitchSettingState extends State<_SwitchSetting> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(appdata.settings[widget.settingKey] is bool);
|
||||
var value = widget.comicId == null
|
||||
? appdata.settings[widget.settingKey]
|
||||
: appdata.settings.getReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingKey,
|
||||
);
|
||||
|
||||
assert(value is bool);
|
||||
|
||||
return ListTile(
|
||||
title: Text(widget.title),
|
||||
subtitle: widget.subtitle == null ? null : Text(widget.subtitle!),
|
||||
trailing: Switch(
|
||||
value: appdata.settings[widget.settingKey],
|
||||
value: value,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
appdata.settings[widget.settingKey] = value;
|
||||
if (widget.comicId == null) {
|
||||
appdata.settings[widget.settingKey] = value;
|
||||
} else {
|
||||
appdata.settings.setReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingKey,
|
||||
value,
|
||||
);
|
||||
}
|
||||
});
|
||||
appdata.saveData().then((_) {
|
||||
widget.onChanged?.call();
|
||||
@@ -51,6 +74,8 @@ class SelectSetting extends StatelessWidget {
|
||||
required this.optionTranslation,
|
||||
this.onChanged,
|
||||
this.help,
|
||||
this.comicId,
|
||||
this.comicSource,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -63,6 +88,10 @@ class SelectSetting extends StatelessWidget {
|
||||
|
||||
final String? help;
|
||||
|
||||
final String? comicId;
|
||||
|
||||
final String? comicSource;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
@@ -76,6 +105,8 @@ class SelectSetting extends StatelessWidget {
|
||||
optionTranslation: optionTranslation,
|
||||
onChanged: onChanged,
|
||||
help: help,
|
||||
comicId: comicId,
|
||||
comicSource: comicSource,
|
||||
);
|
||||
} else {
|
||||
return _EndSelectorSelectSetting(
|
||||
@@ -84,6 +115,8 @@ class SelectSetting extends StatelessWidget {
|
||||
optionTranslation: optionTranslation,
|
||||
onChanged: onChanged,
|
||||
help: help,
|
||||
comicId: comicId,
|
||||
comicSource: comicSource,
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -99,6 +132,8 @@ class _DoubleLineSelectSettings extends StatefulWidget {
|
||||
required this.optionTranslation,
|
||||
this.onChanged,
|
||||
this.help,
|
||||
this.comicId,
|
||||
this.comicSource,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -111,6 +146,10 @@ class _DoubleLineSelectSettings extends StatefulWidget {
|
||||
|
||||
final String? help;
|
||||
|
||||
final String? comicId;
|
||||
|
||||
final String? comicSource;
|
||||
|
||||
@override
|
||||
State<_DoubleLineSelectSettings> createState() =>
|
||||
_DoubleLineSelectSettingsState();
|
||||
@@ -119,6 +158,14 @@ class _DoubleLineSelectSettings extends StatefulWidget {
|
||||
class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var value = widget.comicId == null
|
||||
? appdata.settings[widget.settingKey]
|
||||
: appdata.settings.getReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingKey,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
@@ -134,9 +181,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "Help".tl,
|
||||
content: Text(widget.help!)
|
||||
.paddingHorizontal(16)
|
||||
.fixWidth(double.infinity),
|
||||
content: Text(
|
||||
widget.help!,
|
||||
).paddingHorizontal(16).fixWidth(double.infinity),
|
||||
actions: [
|
||||
Button.filled(
|
||||
onPressed: context.pop,
|
||||
@@ -150,9 +197,7 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
widget.optionTranslation[appdata.settings[widget.settingKey]] ??
|
||||
"None"),
|
||||
subtitle: Text(widget.optionTranslation[value] ?? "None"),
|
||||
trailing: const Icon(Icons.arrow_drop_down),
|
||||
onTap: () {
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
@@ -170,16 +215,27 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
||||
Offset.zero & MediaQuery.of(context).size,
|
||||
),
|
||||
items: widget.optionTranslation.keys
|
||||
.map((key) => PopupMenuItem(
|
||||
value: key,
|
||||
height: App.isMobile ? 46 : 40,
|
||||
child: Text(widget.optionTranslation[key]!),
|
||||
))
|
||||
.map(
|
||||
(key) => PopupMenuItem(
|
||||
value: key,
|
||||
height: App.isMobile ? 46 : 40,
|
||||
child: Text(widget.optionTranslation[key]!),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
appdata.settings[widget.settingKey] = value;
|
||||
if (widget.comicId == null) {
|
||||
appdata.settings[widget.settingKey] = value;
|
||||
} else {
|
||||
appdata.settings.setReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingKey,
|
||||
value,
|
||||
);
|
||||
}
|
||||
});
|
||||
appdata.saveData();
|
||||
widget.onChanged?.call();
|
||||
@@ -197,6 +253,8 @@ class _EndSelectorSelectSetting extends StatefulWidget {
|
||||
required this.optionTranslation,
|
||||
this.onChanged,
|
||||
this.help,
|
||||
this.comicId,
|
||||
this.comicSource,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -209,6 +267,10 @@ class _EndSelectorSelectSetting extends StatefulWidget {
|
||||
|
||||
final String? help;
|
||||
|
||||
final String? comicId;
|
||||
|
||||
final String? comicSource;
|
||||
|
||||
@override
|
||||
State<_EndSelectorSelectSetting> createState() =>
|
||||
_EndSelectorSelectSettingState();
|
||||
@@ -218,6 +280,13 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var options = widget.optionTranslation;
|
||||
var value = widget.comicId == null
|
||||
? appdata.settings[widget.settingKey]
|
||||
: appdata.settings.getReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingKey,
|
||||
);
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
@@ -233,9 +302,9 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "Help".tl,
|
||||
content: Text(widget.help!)
|
||||
.paddingHorizontal(16)
|
||||
.fixWidth(double.infinity),
|
||||
content: Text(
|
||||
widget.help!,
|
||||
).paddingHorizontal(16).fixWidth(double.infinity),
|
||||
actions: [
|
||||
Button.filled(
|
||||
onPressed: context.pop,
|
||||
@@ -250,12 +319,22 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
|
||||
],
|
||||
),
|
||||
trailing: Select(
|
||||
current: options[appdata.settings[widget.settingKey]],
|
||||
current: options[value],
|
||||
values: options.values.toList(),
|
||||
minWidth: 64,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
appdata.settings[widget.settingKey] = options.keys.elementAt(index);
|
||||
var value = options.keys.elementAt(index);
|
||||
if (widget.comicId == null) {
|
||||
appdata.settings[widget.settingKey] = value;
|
||||
} else {
|
||||
appdata.settings.setReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingKey,
|
||||
value,
|
||||
);
|
||||
}
|
||||
});
|
||||
appdata.saveData();
|
||||
widget.onChanged?.call();
|
||||
@@ -273,6 +352,8 @@ class _SliderSetting extends StatefulWidget {
|
||||
required this.min,
|
||||
required this.max,
|
||||
this.onChanged,
|
||||
this.comicId,
|
||||
this.comicSource,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@@ -287,6 +368,10 @@ class _SliderSetting extends StatefulWidget {
|
||||
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
final String? comicId;
|
||||
|
||||
final String? comicSource;
|
||||
|
||||
@override
|
||||
State<_SliderSetting> createState() => _SliderSettingState();
|
||||
}
|
||||
@@ -294,28 +379,52 @@ class _SliderSetting extends StatefulWidget {
|
||||
class _SliderSettingState extends State<_SliderSetting> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var value =
|
||||
(widget.comicId == null
|
||||
? appdata.settings[widget.settingsIndex]
|
||||
: appdata.settings.getReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingsIndex,
|
||||
))
|
||||
.toDouble();
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(widget.title),
|
||||
const Spacer(),
|
||||
Text(
|
||||
appdata.settings[widget.settingsIndex].toString(),
|
||||
style: ts.s12,
|
||||
),
|
||||
Text(value.toString(), style: ts.s12),
|
||||
],
|
||||
),
|
||||
subtitle: Slider(
|
||||
value: appdata.settings[widget.settingsIndex].toDouble(),
|
||||
value: value,
|
||||
onChanged: (value) {
|
||||
if (value.toInt() == value) {
|
||||
setState(() {
|
||||
appdata.settings[widget.settingsIndex] = value.toInt();
|
||||
if (widget.comicId == null) {
|
||||
appdata.settings[widget.settingsIndex] = value.toInt();
|
||||
} else {
|
||||
appdata.settings.setReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingsIndex,
|
||||
value.toInt(),
|
||||
);
|
||||
}
|
||||
appdata.saveData();
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
appdata.settings[widget.settingsIndex] = value;
|
||||
if (widget.comicId == null) {
|
||||
appdata.settings[widget.settingsIndex] = value;
|
||||
} else {
|
||||
appdata.settings.setReaderSetting(
|
||||
widget.comicId!,
|
||||
widget.comicSource!,
|
||||
widget.settingsIndex,
|
||||
value,
|
||||
);
|
||||
}
|
||||
appdata.saveData();
|
||||
});
|
||||
}
|
||||
@@ -402,10 +511,11 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 5,
|
||||
offset: Offset(0, 2),
|
||||
spreadRadius: 2)
|
||||
color: Colors.black12,
|
||||
blurRadius: 5,
|
||||
offset: Offset(0, 2),
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
onReorder: (reorderFunc) {
|
||||
@@ -435,7 +545,7 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
label: Text("Add".tl),
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: showAddDialog,
|
||||
)
|
||||
),
|
||||
],
|
||||
body: view,
|
||||
);
|
||||
@@ -445,12 +555,13 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
Widget removeButton = Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
keys.remove(key);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline)),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
keys.remove(key);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
@@ -458,10 +569,7 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
key: Key(key),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
removeButton,
|
||||
const Icon(Icons.drag_handle),
|
||||
],
|
||||
children: [removeButton, const Icon(Icons.drag_handle)],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -477,64 +585,66 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Add".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: canAdd.entries
|
||||
.map(
|
||||
(e) => CheckboxListTile(
|
||||
value: selected.contains(e.key),
|
||||
title: Text(e.value),
|
||||
key: Key(e.key),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value!) {
|
||||
selected.add(e.key);
|
||||
} else {
|
||||
selected.remove(e.key);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
actions: [
|
||||
if (selected.length < canAdd.length)
|
||||
TextButton(
|
||||
child: Text("Select All".tl),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
selected = canAdd.keys.toList();
|
||||
});
|
||||
},
|
||||
)
|
||||
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),
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Add".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: canAdd.entries
|
||||
.map(
|
||||
(e) => CheckboxListTile(
|
||||
value: selected.contains(e.key),
|
||||
title: Text(e.value),
|
||||
key: Key(e.key),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value!) {
|
||||
selected.add(e.key);
|
||||
} else {
|
||||
selected.remove(e.key);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
actions: [
|
||||
if (selected.length < canAdd.length)
|
||||
TextButton(
|
||||
child: Text("Select All".tl),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
selected = canAdd.keys.toList();
|
||||
});
|
||||
},
|
||||
)
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
58
lib/utils/channel.dart
Normal file
58
lib/utils/channel.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
class Channel<T> {
|
||||
final Queue<T> _queue;
|
||||
|
||||
final int size;
|
||||
|
||||
Channel(this.size) : _queue = Queue<T>();
|
||||
|
||||
Completer? _releaseCompleter;
|
||||
|
||||
Completer? _pushCompleter;
|
||||
|
||||
var currentSize = 0;
|
||||
|
||||
var isClosed = false;
|
||||
|
||||
Future<void> push(T item) async {
|
||||
if (currentSize >= size) {
|
||||
_releaseCompleter ??= Completer();
|
||||
return _releaseCompleter!.future.then((_) {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
_queue.addLast(item);
|
||||
currentSize++;
|
||||
});
|
||||
}
|
||||
_queue.addLast(item);
|
||||
currentSize++;
|
||||
_pushCompleter?.complete();
|
||||
_pushCompleter = null;
|
||||
}
|
||||
|
||||
Future<T?> pop() async {
|
||||
while (_queue.isEmpty) {
|
||||
if (isClosed) {
|
||||
return null;
|
||||
}
|
||||
_pushCompleter ??= Completer();
|
||||
await _pushCompleter!.future;
|
||||
}
|
||||
var item = _queue.removeFirst();
|
||||
currentSize--;
|
||||
if (_releaseCompleter != null && currentSize < size) {
|
||||
_releaseCompleter!.complete();
|
||||
_releaseCompleter = null;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
void close() {
|
||||
isClosed = true;
|
||||
_pushCompleter?.complete();
|
||||
_releaseCompleter?.complete();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ final _resolver = MimeTypeResolver()
|
||||
..addMagicNumber([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed')
|
||||
// rar
|
||||
..addMagicNumber([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar')
|
||||
// avif
|
||||
..addMagicNumber([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 'image/avif')
|
||||
;
|
||||
|
||||
FileType detectFileType(List<int> data) {
|
||||
|
||||
28
patch/font.dart
Normal file
28
patch/font.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
void main() async {
|
||||
const harmonySansLink = "https://developer.huawei.com/images/download/general/HarmonyOS-Sans.zip";
|
||||
|
||||
var dio = Dio();
|
||||
await dio.download(harmonySansLink, "HarmonyOS-Sans.zip");
|
||||
await extractFileToDisk("HarmonyOS-Sans.zip", "./assets/");
|
||||
File("HarmonyOS-Sans.zip").deleteSync();
|
||||
|
||||
var pubspec = await File("pubspec.yaml").readAsString();
|
||||
pubspec = pubspec.replaceFirst("# fonts:",
|
||||
""" fonts:
|
||||
- family: HarmonyOS Sans
|
||||
fonts:
|
||||
- asset: assets/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf
|
||||
""");
|
||||
await File("pubspec.yaml").writeAsString(pubspec);
|
||||
|
||||
var mainDart = await File("lib/main.dart").readAsString();
|
||||
mainDart = mainDart.replaceFirst("Noto Sans CJK", "HarmonyOS Sans");
|
||||
await File("lib/main.dart").writeAsString(mainDart);
|
||||
|
||||
print("Successfully patched font.");
|
||||
}
|
||||
42
pubspec.lock
42
pubspec.lock
@@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -408,10 +416,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_memory_info
|
||||
sha256: "1f112f1d7503aa1681fc8e923f6cd0e847bb2fbeec3753ed021cf1e5f7e9cd74"
|
||||
sha256: eacfd0dd01ff596b4e5bf022442769a1807a73f2af43d62802436f0a5de99137
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.1"
|
||||
version: "0.0.3"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -556,26 +564,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.9"
|
||||
version: "11.0.1"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -770,6 +778,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
rhttp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -941,10 +957,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
version: "0.7.6"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1037,10 +1053,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1116,4 +1132,4 @@ packages:
|
||||
version: "0.0.12"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.32.6"
|
||||
flutter: ">=3.35.5"
|
||||
|
||||
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.4.6+146
|
||||
version: 1.5.3+153
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.32.6
|
||||
flutter: 3.35.5
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -75,7 +75,7 @@ dependencies:
|
||||
ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
|
||||
dynamic_color: ^1.7.0
|
||||
shimmer_animation: ^2.1.0
|
||||
flutter_memory_info: ^0.0.1
|
||||
flutter_memory_info: ^0.0.3
|
||||
syntax_highlight: ^0.4.0
|
||||
flutter_7zip:
|
||||
git:
|
||||
@@ -94,6 +94,7 @@ dev_dependencies:
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_to_arch: ^1.0.1
|
||||
flutter_to_debian: ^2.0.2
|
||||
archive: any
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -104,6 +105,7 @@ flutter:
|
||||
- assets/tags.json
|
||||
- assets/tags_tw.json
|
||||
- assets/opencc.txt
|
||||
# fonts:
|
||||
|
||||
flutter_to_arch:
|
||||
name: Venera
|
||||
|
||||
115
test/channel_test.dart
Normal file
115
test/channel_test.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:venera/utils/channel.dart';
|
||||
|
||||
void main() {
|
||||
test("1-1-1", () async {
|
||||
var channel = Channel<int>(1);
|
||||
await channel.push(1);
|
||||
var item = await channel.pop();
|
||||
expect(item, 1);
|
||||
});
|
||||
|
||||
test("1-3-1", () async {
|
||||
var channel = Channel<int>(1);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(2);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(3);
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var item = await channel.pop();
|
||||
if (item != null) {
|
||||
results.add(item);
|
||||
}
|
||||
}
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("2-3-1", () async {
|
||||
var channel = Channel<int>(2);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(2);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(3);
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var item = await channel.pop();
|
||||
if (item != null) {
|
||||
results.add(item);
|
||||
}
|
||||
}
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("1-1-3", () async {
|
||||
var channel = Channel<int>(1);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
print("push 1");
|
||||
await channel.push(1);
|
||||
print("push 2");
|
||||
await channel.push(2);
|
||||
print("push 3");
|
||||
await channel.push(3);
|
||||
print("push done");
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var consumers = <Future>[];
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
consumers.add(() async {
|
||||
while (true) {
|
||||
var item = await channel.pop();
|
||||
if (item == null) {
|
||||
break;
|
||||
}
|
||||
print("pop $item");
|
||||
results.add(item);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
await Future.wait(consumers);
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("close", () async {
|
||||
var channel = Channel<int>(2);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
await channel.push(2);
|
||||
await channel.push(3);
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// consumer
|
||||
await channel.pop();
|
||||
await channel.pop();
|
||||
await channel.pop();
|
||||
var item4 = await channel.pop();
|
||||
expect(item4, null);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user