mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Compare commits
166 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
dfe2a0db6a | ||
c6714f79b6 | |||
552a42fb27 | |||
af456c52f1 | |||
f38129133a | |||
17e2696ca4 | |||
9d6999af33 | |||
ae5548918c | |||
92d22c977c | |||
8cc3702e1a | |||
3131ce52a7 | |||
62e4056f4a | |||
a29a7cbaf3 | |||
7bdab7ade7 | |||
ea99e87afb | |||
0d3fde9457 | |||
aa9f4dae82 | |||
6877aa120f | |||
d25d72a5f7 | |||
![]() |
97768b4945 | ||
2481780ab3 | |||
![]() |
49481bfa6a | ||
211850d73e | |||
fcf0334d55 | |||
aa8eec5792 | |||
6eb0060dd6 | |||
c096f5a2d8 | |||
554b9f2a77 | |||
f87afbe397 | |||
6ff30f8ac3 | |||
118941f239 | |||
d91bca6913 | |||
463ad5b5bc | |||
971fc1da92 | |||
37af7e266a | |||
276e23354d | |||
3da00595b7 | |||
![]() |
d3c115ee0c | ||
dcc94c5b3d | |||
a116b5b615 | |||
05fcb23a4d | |||
daa6e8ce18 | |||
8665994572 | |||
90441af989 | |||
7631fab86b | |||
cd9b07bb3e | |||
6c179ceb95 | |||
ec48dbef57 | |||
cd1cc1229e | |||
![]() |
bda299e1f8 | ||
![]() |
78ea129564 | ||
![]() |
f3b4598bb6 | ||
![]() |
7bc4c69a32 | ||
![]() |
a8e55e0151 | ||
![]() |
fddd959545 | ||
![]() |
ebf6846bf1 | ||
0f2d0bb9f9 | |||
48338e4ef7 | |||
8d8e345d82 | |||
![]() |
fcbf6a6277 | ||
d83d679eb9 | |||
d6087e5f59 | |||
37371bee6c | |||
45fe5f503a | |||
d440ed6424 | |||
d812332613 | |||
dee8d17b1e | |||
![]() |
c0d461ebd9 | ||
![]() |
45e2a1142a | ||
![]() |
533c2b2507 | ||
![]() |
29b7e0d646 | ||
b1870b65d6 | |||
1103076009 | |||
51739355c8 | |||
1b4f67b314 | |||
d9b23dadf0 | |||
ba8831caa6 | |||
2b1684b0fc | |||
cd3f09efae | |||
d05eaf8c7e | |||
03628f2afa | |||
![]() |
9dae28e366 | ||
![]() |
11e66328c4 | ||
![]() |
73d4e28ed0 | ||
![]() |
169676fd9e | ||
332497cf90 | |||
5f15c08eef | |||
3f6b3152b2 | |||
f5b3b36acb | |||
fd8607777e | |||
fa951cac95 | |||
55ad652191 | |||
533497ead1 | |||
![]() |
00cdc18ddd | ||
![]() |
474d9aa6f1 | ||
ffa0c8f887 | |||
0f3f3ea270 | |||
![]() |
b752caa079 | ||
309df2143b | |||
8e964468ea | |||
ca8f09807b | |||
68b214e295 | |||
00c0a64de0 | |||
![]() |
dbc2c27db0 | ||
fffb3dc973 | |||
0ca8a28639 | |||
6426ebaf16 | |||
316f61394d | |||
04ab75cf92 | |||
4828a57e1a | |||
d089163220 | |||
7b5c13200d | |||
0f6874f8d7 | |||
4af15b9139 | |||
9fe49217dc | |||
76c56964a5 | |||
e8afbca7b2 | |||
5843d7c919 | |||
![]() |
de98dfaa1b | ||
![]() |
30cbfb54ef | ||
c633021963 | |||
![]() |
4640831e69 | ||
af7a7c220e | |||
fd19f6bf7d | |||
96b4125613 | |||
![]() |
587c5d8040 | ||
![]() |
72730361c8 | ||
38d5563534 | |||
5a886f7504 | |||
1464b7d5e5 | |||
5645d805f5 | |||
7fe81ae418 | |||
be0daddd82 | |||
![]() |
3efc4794d0 | ||
![]() |
4eff50dbed | ||
f3c191f7f3 | |||
a014587a94 | |||
bf51cd5cee | |||
3f10473fb6 | |||
fba49233c8 | |||
8adf61b54f | |||
![]() |
e829f567e5 | ||
![]() |
701573ee19 | ||
![]() |
7b601058eb | ||
![]() |
24b7319bb5 | ||
![]() |
26adfc6c4f | ||
6db00eaf71 | |||
![]() |
bbf31a4bbe | ||
36ab104c81 | |||
a63d458707 | |||
011619340f | |||
40b9b5b329 | |||
edc2cb066b | |||
bd5d10e919 | |||
2b3c7a8564 | |||
![]() |
a630771f0b | ||
ee0da9a26a | |||
a471e79ef2 | |||
26a1d68913 | |||
![]() |
d0d27206cd | ||
![]() |
90f0c9dab3 | ||
![]() |
0c54a9be11 | ||
5fb0d2327d | |||
d73e152cec | |||
bd53416968 | |||
![]() |
c28f4d40c2 |
24
.github/ISSUE_TEMPLATE/bug.yaml
vendored
24
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -8,9 +8,31 @@ body:
|
||||
value: |
|
||||
Thank you for reporting a problem, please complete the title and fill in the following information.
|
||||
|
||||
感谢您的反馈,请填写完整标题并填写以下信息。
|
||||
|
||||
**Please do not report any issues related to config files.**
|
||||
|
||||
To report a bug related to the config file, please send it to the [config repository](https://github.com/venera-app/venera-configs).
|
||||
**请不要报告与配置文件相关的任何问题。**
|
||||
|
||||
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
|
||||
|
||||
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
|
||||
- type: dropdown
|
||||
id: bugType
|
||||
attributes:
|
||||
label: Bug type
|
||||
description: What type of bug are you reporting?
|
||||
options:
|
||||
- Crash
|
||||
- UI
|
||||
- Performance
|
||||
- Security
|
||||
- Reader
|
||||
- JS Engine
|
||||
- Comic Source
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
|
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
10
.github/ISSUE_TEMPLATE/enhancement.yaml
vendored
10
.github/ISSUE_TEMPLATE/enhancement.yaml
vendored
@@ -7,6 +7,16 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Welcome to make a feature request, please fill in the following information after completing the title.
|
||||
|
||||
欢迎提出功能建议,请填写完整标题后填写以下信息。
|
||||
|
||||
**Please do not report any issues related to config files.**
|
||||
|
||||
**请不要报告与配置文件相关的任何问题。**
|
||||
|
||||
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
|
||||
|
||||
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
|
9
.github/ISSUE_TEMPLATE/other.yaml
vendored
9
.github/ISSUE_TEMPLATE/other.yaml
vendored
@@ -1,9 +0,0 @@
|
||||
name: other
|
||||
description: Other contents
|
||||
body:
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Content
|
||||
validations:
|
||||
required: true
|
29
.github/workflows/issue_check.yml
vendored
Normal file
29
.github/workflows/issue_check.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Check Issue
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check Issue
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
id: checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check Issue
|
||||
id: check
|
||||
uses: wgh136/gpt_issue_checker@v1.0.2
|
||||
with:
|
||||
api-url: ${{ secrets.API_URL }}
|
||||
api-key: ${{ secrets.API_KEY }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
prompt: "You are a repository issue checker. The project is a comic app that supports view local or network comics using config files. To view a comic source, user must add a config file. User should not report any issue related to config file to the project repository because there is another repository for managing config files. You are given an issue content and you need to decide whether to close the issue. If you decide to close the issue, you should also provide a comment explaining why you are closing the issue. If you decide not to close the issue, you should provide a comment which is a summary of the issue. You should response with a JSON object with the following keys: should_close, should_comment, comment."
|
||||
model: "gpt-4o"
|
96
.github/workflows/main.yml
vendored
96
.github/workflows/main.yml
vendored
@@ -26,6 +26,9 @@ jobs:
|
||||
echo "$CERTIFICATE" | base64 --decode > signing_certificate.p12
|
||||
security import signing_certificate.p12 -k ~/Library/Keychains/login.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
|
||||
- name: Check rust-toolchain.toml
|
||||
run: rustup show
|
||||
|
||||
# Step 2: Build the Flutter macOS app
|
||||
- name: Build Flutter macOS App
|
||||
run: flutter build macos --release
|
||||
@@ -97,10 +100,8 @@ jobs:
|
||||
with:
|
||||
distribution: 'oracle'
|
||||
java-version: '17'
|
||||
- name: Setup Rust
|
||||
run: |
|
||||
rustup update
|
||||
rustup default stable
|
||||
- name: Check rust-toolchain.toml
|
||||
run: rustup show
|
||||
- run: flutter pub get
|
||||
- run: flutter build apk --release
|
||||
- uses: actions/upload-artifact@v4
|
||||
@@ -148,6 +149,45 @@ jobs:
|
||||
sudo rm -rf build/linux/arch/pkg
|
||||
sudo rm -rf build/linux/arch/src
|
||||
sudo rm -rf build/linux/arch/PKGBUILD
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
sudo apt-get install -y libfuse2
|
||||
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
|
||||
chmod +x appimagetool
|
||||
|
||||
mkdir -p Venera.AppDir
|
||||
cp -r build/linux/x64/release/bundle/* Venera.AppDir/
|
||||
|
||||
cat > Venera.AppDir/venera.desktop << EOF
|
||||
[Desktop Entry]
|
||||
Name=Venera
|
||||
Exec=venera
|
||||
Icon=venera
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
EOF
|
||||
|
||||
cp assets/app_icon.png Venera.AppDir/venera.png
|
||||
|
||||
cat > Venera.AppDir/AppRun << EOF
|
||||
#!/bin/sh
|
||||
HERE=\$(dirname \$(readlink -f "\${0}"))
|
||||
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
|
||||
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
|
||||
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
|
||||
exec "\${HERE}"/venera "\$@"
|
||||
EOF
|
||||
chmod +x Venera.AppDir/AppRun
|
||||
|
||||
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
|
||||
./appimagetool Venera.AppDir Venera-${APP_VERSION}-x86_64.AppImage
|
||||
|
||||
mkdir -p build/linux/appimage
|
||||
mv Venera-${APP_VERSION}-x86_64.AppImage build/linux/appimage/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: appimage_build
|
||||
path: build/linux/appimage
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb_build
|
||||
@@ -170,6 +210,45 @@ jobs:
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
- run: python3 debian/build.py arm64
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
sudo apt-get install -y libfuse2
|
||||
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage"
|
||||
chmod +x appimagetool
|
||||
|
||||
mkdir -p Venera.AppDir
|
||||
cp -r build/linux/arm64/release/bundle/* Venera.AppDir/
|
||||
|
||||
cat > Venera.AppDir/venera.desktop << EOF
|
||||
[Desktop Entry]
|
||||
Name=Venera
|
||||
Exec=venera
|
||||
Icon=venera
|
||||
Type=Application
|
||||
Categories=Utility;
|
||||
EOF
|
||||
|
||||
cp assets/app_icon.png Venera.AppDir/venera.png
|
||||
|
||||
cat > Venera.AppDir/AppRun << EOF
|
||||
#!/bin/sh
|
||||
HERE=\$(dirname \$(readlink -f "\${0}"))
|
||||
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
|
||||
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
|
||||
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
|
||||
exec "\${HERE}"/venera "\$@"
|
||||
EOF
|
||||
chmod +x Venera.AppDir/AppRun
|
||||
|
||||
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
|
||||
./appimagetool Venera.AppDir Venera-${APP_VERSION}-aarch64.AppImage
|
||||
|
||||
mkdir -p build/linux/appimage
|
||||
mv Venera-${APP_VERSION}-aarch64.AppImage build/linux/appimage/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: appimage_arm64_build
|
||||
path: build/linux/appimage
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb_arm64_build
|
||||
@@ -208,6 +287,14 @@ jobs:
|
||||
with:
|
||||
name: deb_arm64_build
|
||||
path: outputs
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: appimage_build
|
||||
path: outputs
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: appimage_arm64_build
|
||||
path: outputs
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -219,5 +306,6 @@ jobs:
|
||||
outputs/*.exe
|
||||
outputs/*.deb
|
||||
outputs/*.zst
|
||||
outputs/*.AppImage
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACTION_GITHUB_TOKEN }}
|
||||
|
@@ -32,7 +32,7 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
android {
|
||||
namespace = "com.github.wgh136.venera"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion "25.1.8937393"
|
||||
ndkVersion "28.0.13004108"
|
||||
|
||||
splits{
|
||||
abi {
|
||||
@@ -67,7 +67,6 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.github.wgh136.venera"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
|
||||
@@ -125,6 +124,6 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.activity:activity-ktx:1.9.2"
|
||||
implementation "androidx.activity:activity-ktx:1.10.1"
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
}
|
||||
|
@@ -47,6 +47,11 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="exhentai.org" android:pathPrefix="/g" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/share_text">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
@@ -7,6 +7,7 @@ import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
@@ -40,6 +41,41 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
private val nextLocalRequestCode = AtomicInteger()
|
||||
|
||||
private val sharedTexts = ArrayList<String>()
|
||||
|
||||
private var textShareHandler: ((String) -> Unit)? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (intent?.action == Intent.ACTION_SEND) {
|
||||
if (intent.type == "text/plain") {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (text != null)
|
||||
handleSharedText(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
if (intent.type == "text/plain") {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (text != null)
|
||||
handleSharedText(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSharedText(text: String) {
|
||||
if (textShareHandler != null) {
|
||||
textShareHandler?.invoke(text)
|
||||
} else {
|
||||
sharedTexts.add(text)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <I, O> startContractForResult(
|
||||
contract: ActivityResultContract<I, O>,
|
||||
input: I,
|
||||
@@ -134,6 +170,26 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
val mimeType = req.arguments<String>()
|
||||
openFile(res, mimeType!!)
|
||||
}
|
||||
|
||||
val shareTextChannel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/text_share")
|
||||
shareTextChannel.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
textShareHandler = {text ->
|
||||
events.success(text)
|
||||
}
|
||||
if (sharedTexts.isNotEmpty()) {
|
||||
for (text in sharedTexts) {
|
||||
events.success(text)
|
||||
}
|
||||
sharedTexts.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
textShareHandler = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun getProxy(): String {
|
||||
|
4
android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
4
android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="share_text">搜索</string>
|
||||
</resources>
|
4
android/app/src/main/res/values-zh/strings.xml
Normal file
4
android/app/src/main/res/values-zh/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="share_text">搜尋</string>
|
||||
</resources>
|
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="share_text">Search</string>
|
||||
</resources>
|
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version '8.3.2' apply false
|
||||
id "com.android.application" version '8.9.0' apply false
|
||||
id "org.jetbrains.kotlin.android" version "1.8.10" apply false
|
||||
}
|
||||
|
||||
|
@@ -1357,4 +1357,30 @@ let APP = {
|
||||
method: 'getPlatform'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set clipboard text
|
||||
* @param text {string}
|
||||
* @returns {Promise<void>}
|
||||
*
|
||||
* @since 1.3.4
|
||||
*/
|
||||
function setClipboard(text) {
|
||||
return sendMessage({
|
||||
method: 'setClipboard',
|
||||
text: text
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clipboard text
|
||||
* @returns {Promise<string>}
|
||||
*
|
||||
* @since 1.3.4
|
||||
*/
|
||||
function getClipboard() {
|
||||
return sendMessage({
|
||||
method: 'getClipboard'
|
||||
})
|
||||
}
|
@@ -106,7 +106,8 @@
|
||||
"Continuous (Right to Left)": "连续(从右到左)",
|
||||
"Continuous (Top to Bottom)": "连续(从上到下)",
|
||||
"Auto page turning interval": "自动翻页间隔",
|
||||
"The number of pic in screen (Only Gallery Mode)": "同屏幕图片数量(仅画廊模式)",
|
||||
"The number of pic in screen for landscape (Only Gallery Mode)": "横屏同屏幕图片数量(仅画廊模式)",
|
||||
"The number of pic in screen for portrait (Only Gallery Mode)": "竖屏同屏幕图片数量(仅画廊模式)",
|
||||
"Theme Mode": "主题模式",
|
||||
"System": "系统",
|
||||
"Light": "浅色",
|
||||
@@ -139,18 +140,18 @@
|
||||
"Block": "屏蔽",
|
||||
"Add new favorite to": "添加新收藏到",
|
||||
"Move favorite after reading": "阅读后移动收藏",
|
||||
"Delete folder?" : "删除文件夹?",
|
||||
"Delete folder '@f' ?" : "删除文件夹 '@f' ?",
|
||||
"Delete folder?": "删除文件夹?",
|
||||
"Delete folder '@f' ?": "删除文件夹 '@f' ?",
|
||||
"Import from file": "从文件导入",
|
||||
"Failed to import": "导入失败",
|
||||
"Cache Limit": "缓存限制",
|
||||
"Set Cache Limit": "设置缓存限制",
|
||||
"Size in MB": "大小(MB)",
|
||||
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录",
|
||||
"Select a directory which contains the comic directories.": "选择一个包含漫画文件夹的目录",
|
||||
"Help": "帮助",
|
||||
"Export as cbz": "导出为cbz",
|
||||
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)",
|
||||
"An archive file" : "一个归档文件",
|
||||
"Select an archive file (cbz, zip, 7z, cb7)": "选择一个归档文件 (cbz, zip, 7z, cb7)",
|
||||
"An archive file": "一个归档文件",
|
||||
"Fullscreen": "全屏",
|
||||
"Exit": "退出",
|
||||
"View more": "查看更多",
|
||||
@@ -189,6 +190,7 @@
|
||||
"Operation": "操作",
|
||||
"Upload": "上传",
|
||||
"Saved": "已保存",
|
||||
"Saved Failed": "保存失败",
|
||||
"Sync Data": "同步数据",
|
||||
"Syncing Data": "正在同步数据",
|
||||
"Data Sync": "数据同步",
|
||||
@@ -196,9 +198,9 @@
|
||||
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
|
||||
"Added": "已添加",
|
||||
"Turn page by volume keys": "使用音量键翻页",
|
||||
"Display time & battery info in reader":"在阅读器中显示时间和电量信息",
|
||||
"EhViewer downloads":"EhViewer下载",
|
||||
"Select an EhViewer database and a download folder.":"选择EhViewer的下载数据(导出的db文件)与存放下载内容的目录",
|
||||
"Display time & battery info in reader": "在阅读器中显示时间和电量信息",
|
||||
"EhViewer downloads": "EhViewer下载",
|
||||
"Select an EhViewer database and a download folder.": "选择EhViewer的下载数据(导出的db文件)与存放下载内容的目录",
|
||||
"(EhViewer)Default": "(EhViewer)默认",
|
||||
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若通过EhViewer数据库导入漫画,程序将会按其中的下载标签自动创建收藏文件夹。",
|
||||
"Multi-Select": "进入多选模式",
|
||||
@@ -239,7 +241,7 @@
|
||||
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
|
||||
"Deleted @a favorite items.": "已删除 @a 条无效收藏",
|
||||
"New version available": "有新版本可用",
|
||||
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
|
||||
"A new version is available. Do you want to update now?": "有新版本可用。您要现在更新吗?",
|
||||
"No new version available": "没有新版本可用",
|
||||
"Export as pdf": "导出为pdf",
|
||||
"Export as epub": "导出为epub",
|
||||
@@ -286,15 +288,15 @@
|
||||
"Copy the title successfully": "复制标题成功",
|
||||
"The comic is invalid, please long press to delete, you can double click the title to copy": "该漫画已失效, 请长按删除, 可以双击标题进行复制",
|
||||
"No search results found": "未找到搜索结果",
|
||||
"Added @c comics to download queue." : "已添加 @c 本漫画到下载队列",
|
||||
"Added @c comics to download queue.": "已添加 @c 本漫画到下载队列",
|
||||
"Download started": "下载已开始",
|
||||
"Click favorite": "点击收藏",
|
||||
"End": "末尾",
|
||||
"None": "无",
|
||||
"View Detail": "查看详情",
|
||||
"Select a directory which contains multiple archive files." : "选择一个包含多个归档文件的目录",
|
||||
"Multiple archive files" : "多个归档文件",
|
||||
"No valid comics found" : "未找到有效的漫画",
|
||||
"Select a directory which contains multiple archive files.": "选择一个包含多个归档文件的目录",
|
||||
"Multiple archive files": "多个归档文件",
|
||||
"No valid comics found": "未找到有效的漫画",
|
||||
"Enable DNS Overrides": "启用DNS覆写",
|
||||
"DNS Overrides": "DNS覆写",
|
||||
"Custom Image Processing": "自定义图片处理",
|
||||
@@ -336,24 +338,59 @@
|
||||
"Number of images preloaded": "预加载图片数量",
|
||||
"Ascending": "升序",
|
||||
"Descending": "降序",
|
||||
"Last Reading: Chapter @ep Page @page": "上次阅读: 第 @ep 章 第 @page 页",
|
||||
"Last Reading: Page @page": "上次阅读: 第 @page 页",
|
||||
"Last Reading": "上次阅读",
|
||||
"Replies": "回复",
|
||||
"Follow Updates": "追更",
|
||||
"Not Configured": "未配置",
|
||||
"Choose a folder to follow updates." : "选择一个文件夹以追更",
|
||||
"Choose a folder to follow updates.": "选择一个文件夹以追更",
|
||||
"Choose Folder": "选择文件夹",
|
||||
"No folders available": "没有可用的文件夹",
|
||||
"Updating comics...": "更新漫画中...",
|
||||
"Automatic update checking enabled." : "已启用自动更新检查",
|
||||
"The app will check for updates at most once a day." : "APP将每天最多检查一次更新",
|
||||
"Automatic update checking enabled.": "已启用自动更新检查",
|
||||
"The app will check for updates at most once a day.": "APP将每天最多检查一次更新",
|
||||
"Change Folder": "更改文件夹",
|
||||
"Check Now": "立即检查",
|
||||
"Updates": "更新",
|
||||
"No updates found": "未找到更新",
|
||||
"All Comics": "全部漫画",
|
||||
"The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新",
|
||||
"Disable": "禁用"
|
||||
"Disable": "禁用",
|
||||
"Once the operation is successful, app will automatically sync data with the server.": "操作成功后, APP将自动与服务器同步数据",
|
||||
"Cache cleared": "缓存已清除",
|
||||
"Disabled": "已禁用",
|
||||
"Auto Sync Data": "自动同步数据",
|
||||
"Mark all as read": "全部标记为已读",
|
||||
"Do you want to mark all as read?": "您要全部标记为已读吗?",
|
||||
"Swipe down for previous chapter": "向下滑动查看上一章",
|
||||
"Swipe up for next chapter": "向上滑动查看下一章",
|
||||
"Initial Page": "初始页面",
|
||||
"Home Page": "主页",
|
||||
"Favorites Page": "收藏页面",
|
||||
"Explore Page": "探索页面",
|
||||
"Categories Page": "分类页面",
|
||||
"Convert to local": "转换为本地",
|
||||
"Refresh": "刷新",
|
||||
"Paging": "分页",
|
||||
"Continuous": "连续",
|
||||
"Display mode of comic list": "漫画列表的显示模式",
|
||||
"Show Page Number": "显示页码",
|
||||
"Jump to page": "跳转到页面",
|
||||
"Page": "页面",
|
||||
"Jump": "跳转",
|
||||
"Copy Image": "复制图片",
|
||||
"A valid WebDav directory URL": "有效的WebDav目录URL",
|
||||
"Shut Down": "关闭",
|
||||
"Uploading data...": "正在上传数据...",
|
||||
"Pages": "页数",
|
||||
"Long press zoom position": "长按缩放位置",
|
||||
"Press position": "按压位置",
|
||||
"Screen center": "屏幕中心",
|
||||
"Suggestions": "建议",
|
||||
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
|
||||
"Show single image on first page": "在首页显示单张图片",
|
||||
"Click to select an image": "点击选择一张图片",
|
||||
"Source URL": "源地址",
|
||||
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件"
|
||||
},
|
||||
"zh_TW": {
|
||||
"Home": "首頁",
|
||||
@@ -363,7 +400,7 @@
|
||||
"Settings": "設定",
|
||||
"Search": "搜尋",
|
||||
"History": "歷史",
|
||||
"Local": "本地",
|
||||
"Local": "本機",
|
||||
"Import": "匯入",
|
||||
"Comic Source": "漫畫源",
|
||||
"Accounts": "帳戶",
|
||||
@@ -375,14 +412,14 @@
|
||||
"help": "幫助",
|
||||
"Select": "選擇",
|
||||
"Selected @a comics": "已選擇 @a 部漫畫",
|
||||
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 加載 @b 頁, 接收到 @c 部漫畫",
|
||||
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 載入 @b 頁, 接收到 @c 部漫畫",
|
||||
"Downloading": "下載中",
|
||||
"Back": "後退",
|
||||
"Delete": "刪除",
|
||||
"Full Screen": "全螢幕",
|
||||
"Auto Page Turning": "自動翻頁",
|
||||
"Chapters": "章節",
|
||||
"Save Image": "保存圖片",
|
||||
"Save Image": "儲存圖片",
|
||||
"Share": "分享",
|
||||
"Details": "詳情",
|
||||
"Description": "描述",
|
||||
@@ -390,19 +427,19 @@
|
||||
"Add to favorites": "加入收藏",
|
||||
"Error": "錯誤",
|
||||
"Retry": "重試",
|
||||
"Folders": "文件夾",
|
||||
"Delete Folder": "刪除文件夾",
|
||||
"Folders": "資料夾",
|
||||
"Delete Folder": "刪除資料夾",
|
||||
"Rename": "重新命名",
|
||||
"Reorder": "重新排序",
|
||||
"Network": "網路",
|
||||
"more": "更多",
|
||||
"Select a folder": "選擇一個文件夾",
|
||||
"Folder": "文件夾",
|
||||
"Select a folder": "選擇一個資料夾",
|
||||
"Folder": "資料夾",
|
||||
"Confirm": "確認",
|
||||
"Remove comic from favorite?": "從收藏中移除漫畫?",
|
||||
"Move": "移動",
|
||||
"Move to folder": "移動到文件夾",
|
||||
"Copy to folder": "複製到文件夾",
|
||||
"Move to folder": "移動到資料夾",
|
||||
"Copy to folder": "複製到資料夾",
|
||||
"Delete Comic": "刪除漫畫",
|
||||
"Delete @c comics?": "刪除 @c 本漫畫?",
|
||||
"Add comic source": "添加漫畫源",
|
||||
@@ -414,43 +451,43 @@
|
||||
"Check updates": "檢查更新",
|
||||
"Edit": "編輯",
|
||||
"Update": "更新",
|
||||
"Log in": "登錄",
|
||||
"Log in": "登入",
|
||||
"Log out": "登出",
|
||||
"Re-login": "重新登錄",
|
||||
"Click if login expired": "點擊此處如果登錄已過期",
|
||||
"Login": "登錄",
|
||||
"Username": "用戶名",
|
||||
"Re-login": "重新登入",
|
||||
"Click if login expired": "點擊此處如果登入已過期",
|
||||
"Login": "登入",
|
||||
"Username": "使用者名稱",
|
||||
"Password": "密碼",
|
||||
"Continue": "繼續",
|
||||
"Create Account": "創建帳戶",
|
||||
"Create Account": "建立帳戶",
|
||||
"Next": "前進",
|
||||
"Login with webview": "通過網頁登錄",
|
||||
"Login with webview": "透過網頁登入",
|
||||
"Read": "閱讀",
|
||||
"Download": "下載",
|
||||
"Favorite": "收藏",
|
||||
"Comments": "評論",
|
||||
"Information": "信息",
|
||||
"Information": "資訊",
|
||||
"Uploader": "上傳者",
|
||||
"Upload Time": "上傳時間",
|
||||
"Preview": "預覽",
|
||||
"Comment": "評論",
|
||||
"Submit": "提交",
|
||||
"Add": "添加",
|
||||
"New Folder": "新建文件夾",
|
||||
"New Folder": "建立資料夾",
|
||||
"Reading": "閱讀中",
|
||||
"Appearance": "外觀",
|
||||
"Local Favorites": "本地收藏",
|
||||
"Local Favorites": "本機收藏",
|
||||
"APP": "應用",
|
||||
"About": "關於",
|
||||
"Display mode of comic tile": "漫畫縮略圖的顯示模式",
|
||||
"Display mode of comic tile": "漫畫縮圖的顯示模式",
|
||||
"Detailed": "詳細",
|
||||
"Brief": "簡潔",
|
||||
"Size of comic tile": "漫畫縮略圖的大小",
|
||||
"Size of comic tile": "漫畫縮圖的大小",
|
||||
"Explore Pages": "探索頁面",
|
||||
"Category Pages": "分類頁面",
|
||||
"Show favorite status on comic tile": "在漫畫縮略圖上顯示收藏狀態",
|
||||
"Show history on comic tile": "在漫畫縮略圖上顯示歷史記錄",
|
||||
"Keyword blocking": "關鍵詞屏蔽",
|
||||
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
|
||||
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
|
||||
"Keyword blocking": "關鍵字封鎖",
|
||||
"Tap to turn Pages": "點擊翻頁",
|
||||
"Page animation": "頁面動畫",
|
||||
"Reading mode": "閱讀模式",
|
||||
@@ -461,10 +498,11 @@
|
||||
"Continuous (Right to Left)": "連續(從右到左)",
|
||||
"Continuous (Top to Bottom)": "連續(從上到下)",
|
||||
"Auto page turning interval": "自動翻頁間隔",
|
||||
"The number of pic in screen (Only Gallery Mode)": "同螢幕圖片數量(僅畫廊模式)",
|
||||
"The number of pic in screen for landscape (Only Gallery Mode)": "橫向同螢幕圖片數量(僅畫廊模式)",
|
||||
"The number of pic in screen for portrait (Only Gallery Mode)": "直向同螢幕圖片數量(僅畫廊模式)",
|
||||
"Theme Mode": "主題模式",
|
||||
"System": "系統",
|
||||
"Light": "浅色",
|
||||
"Light": "淺色",
|
||||
"Dark": "深色",
|
||||
"Theme Color": "主題顏色",
|
||||
"Red": "紅色",
|
||||
@@ -474,38 +512,38 @@
|
||||
"Orange": "橙色",
|
||||
"Blue": "藍色",
|
||||
"App": "應用",
|
||||
"Data": "數據",
|
||||
"Storage Path for local comics": "本地漫畫的存儲路徑",
|
||||
"Set New Storage Path": "設置新的存儲路徑",
|
||||
"Set": "設置",
|
||||
"Cache Size": "緩存大小",
|
||||
"Clear Cache": "清除緩存",
|
||||
"Data": "資料",
|
||||
"Storage Path for local comics": "本機漫畫的儲存路徑",
|
||||
"Set New Storage Path": "設定新的儲存路徑",
|
||||
"Set": "設定",
|
||||
"Cache Size": "快取大小",
|
||||
"Clear Cache": "清除快取",
|
||||
"Clear": "清除",
|
||||
"Log": "日誌",
|
||||
"Open Log": "打開日誌",
|
||||
"Open": "打開",
|
||||
"User": "用戶",
|
||||
"User": "使用者",
|
||||
"Language": "語言",
|
||||
"Proxy": "代理",
|
||||
"Venera is a free and open-source app for comic reading.": "Venera是一個免費的開源漫畫閱讀應用。",
|
||||
"Check for updates": "檢查更新",
|
||||
"Check": "檢查",
|
||||
"Network Favorite Pages": "網路收藏頁面",
|
||||
"Block": "屏蔽",
|
||||
"Block": "封鎖",
|
||||
"Add new favorite to": "添加新收藏到",
|
||||
"Move favorite after reading": "閱讀後移動收藏",
|
||||
"Delete folder?" : "刪除文件夾?",
|
||||
"Delete folder '@f' ?" : "刪除文件夾 '@f' ?",
|
||||
"Delete folder?": "刪除資料夾?",
|
||||
"Delete folder '@f' ?": "刪除資料夾 '@f' ?",
|
||||
"Import from file": "從文件匯入",
|
||||
"Failed to import": "匯入失敗",
|
||||
"Cache Limit": "緩存限制",
|
||||
"Set Cache Limit": "設置緩存限制",
|
||||
"Cache Limit": "快取限制",
|
||||
"Set Cache Limit": "設定快取限制",
|
||||
"Size in MB": "大小(MB)",
|
||||
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
|
||||
"Select a directory which contains the comic directories.": "選擇一個包含漫畫資料夾的目錄",
|
||||
"Help": "幫助",
|
||||
"Export as cbz": "匯出為cbz",
|
||||
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
|
||||
"An archive file" : "一個歸檔文件",
|
||||
"Select an archive file (cbz, zip, 7z, cb7)": "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
|
||||
"An archive file": "一個歸檔文件",
|
||||
"Fullscreen": "全螢幕",
|
||||
"Exit": "退出",
|
||||
"View more": "查看更多",
|
||||
@@ -515,15 +553,15 @@
|
||||
"Date Desc": "日期降序",
|
||||
"Start": "開始",
|
||||
"Reversed successfully": "反轉成功",
|
||||
"Export App Data": "匯出應用數據",
|
||||
"Import App Data": "匯入應用數據",
|
||||
"Export App Data": "匯出應用資料",
|
||||
"Import App Data": "匯入應用資料",
|
||||
"Export": "匯出",
|
||||
"Download Threads": "下載線程數",
|
||||
"Download Threads": "下載執行緒數",
|
||||
"Update Time": "更新時間",
|
||||
"Copy ID": "複製ID",
|
||||
"Copy URL": "複製URL",
|
||||
"Create": "創建",
|
||||
"Folder Name": "文件夾名稱",
|
||||
"Create": "建立",
|
||||
"Folder Name": "資料夾名稱",
|
||||
"Ranking": "排行",
|
||||
"Download Selected": "下載選中",
|
||||
"Download All": "下載全部",
|
||||
@@ -534,9 +572,9 @@
|
||||
"Updates Available": "更新可用",
|
||||
"Unselected": "未選擇",
|
||||
"Long press and drag to reorder.": "長按並拖動以重新排序。",
|
||||
"Limit image width": "限制圖片寬度",
|
||||
"Limit image width": "限製圖片寬度",
|
||||
"When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式",
|
||||
"Open link": "打開鏈接",
|
||||
"Open link": "打開連結",
|
||||
"Open comic": "打開漫畫",
|
||||
"Move To First": "移動到最前",
|
||||
"Cancel": "取消",
|
||||
@@ -544,15 +582,16 @@
|
||||
"Pause": "暫停",
|
||||
"Operation": "操作",
|
||||
"Upload": "上傳",
|
||||
"Saved": "已保存",
|
||||
"Sync Data": "同步數據",
|
||||
"Syncing Data": "正在同步數據",
|
||||
"Data Sync": "數據同步",
|
||||
"Saved": "已儲存",
|
||||
"Saved Failed": "儲存失敗",
|
||||
"Sync Data": "同步資料",
|
||||
"Syncing Data": "正在同步資料",
|
||||
"Data Sync": "資料同步",
|
||||
"Quick Favorite": "快速收藏",
|
||||
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個文件夾",
|
||||
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個資料夾",
|
||||
"Added": "已添加",
|
||||
"Turn page by volume keys": "使用音量鍵翻頁",
|
||||
"Display time & battery info in reader": "在閱讀器中顯示時間和電量信息",
|
||||
"Display time & battery info in reader": "在閱讀器中顯示時間和電量資訊",
|
||||
"EhViewer downloads": "EhViewer下載",
|
||||
"Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料(匯出的db檔案)與存放下載內容的目錄",
|
||||
"(EhViewer)Default": "(EhViewer)預設",
|
||||
@@ -566,22 +605,22 @@
|
||||
"Select in range": "區間選擇",
|
||||
"Finished": "已完成",
|
||||
"Updating": "更新中",
|
||||
"Update Comics Info": "更新漫畫信息",
|
||||
"Create Folder": "新建文件夾",
|
||||
"Select an image on screen": "選擇屏幕上的圖片",
|
||||
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載隊列",
|
||||
"Update Comics Info": "更新漫畫資訊",
|
||||
"Create Folder": "建立資料夾",
|
||||
"Select an image on screen": "選擇螢幕上的圖片",
|
||||
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載佇列",
|
||||
"Authorization Required": "需要身份驗證",
|
||||
"Sync": "同步",
|
||||
"The folder is Linked to @source": "文件夾已關聯到 @source",
|
||||
"Source Folder": "源文件夾",
|
||||
"Use a config file": "使用配置文件",
|
||||
"The folder is Linked to @source": "資料夾已關聯到 @source",
|
||||
"Source Folder": "來源資料夾",
|
||||
"Use a config file": "使用設定檔",
|
||||
"Comic Source list": "漫畫源列表",
|
||||
"View": "查看",
|
||||
"Copy": "複製",
|
||||
"Copied": "已複製",
|
||||
"Search History": "搜索歷史",
|
||||
"Clear Search History": "清除搜索歷史",
|
||||
"Search in": "搜索於",
|
||||
"Search History": "搜尋歷史",
|
||||
"Clear Search History": "清除搜尋歷史",
|
||||
"Search in": "搜尋於",
|
||||
"Clear History": "清除歷史",
|
||||
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
|
||||
"No Explore Pages": "沒有探索頁面",
|
||||
@@ -590,21 +629,21 @@
|
||||
"No Category Pages": "沒有分類頁面",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
"Page @page": "第 @page 頁",
|
||||
"Also remove files on disk": "同時刪除磁盤上的文件",
|
||||
"Copy to app local path": "將漫畫複製到本地儲存目錄中",
|
||||
"Delete all unavailable local favorite items": "刪除所有無效的本地收藏",
|
||||
"Also remove files on disk": "同時刪除磁碟上的文件",
|
||||
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
|
||||
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
|
||||
"Deleted @a favorite items.": "已刪除 @a 條無效收藏",
|
||||
"New version available": "有新版本可用",
|
||||
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
|
||||
"A new version is available. Do you want to update now?": "有新版本可用。您要現在更新嗎?",
|
||||
"No new version available": "沒有新版本可用",
|
||||
"Export as pdf": "匯出為pdf",
|
||||
"Export as epub": "匯出為epub",
|
||||
"Aggregated Search": "聚合搜索",
|
||||
"No search results found": "未找到搜索結果",
|
||||
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載隊列",
|
||||
"Aggregated Search": "聚合搜尋",
|
||||
"No search results found": "未找到搜尋結果",
|
||||
"Added @c comics to download queue.": "已添加 @c 本漫畫到下載佇列",
|
||||
"Download started": "下載已開始",
|
||||
"Click favorite": "點擊收藏",
|
||||
"Local comic collection is not supported at present": "本地收藏暫不支持",
|
||||
"Local comic collection is not supported at present": "本機收藏暫不支援",
|
||||
"The cover cannot be uncollected here": "封面不能在此取消收藏",
|
||||
"Uncollected the image": "取消收藏圖片",
|
||||
"Successfully collected": "收藏成功",
|
||||
@@ -613,7 +652,7 @@
|
||||
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在圖片瀏覽頁面, 你可以根據你的閱讀模式橫向或者縱向滑動快速收藏圖片",
|
||||
"Calculate your favorite from @a comics and @b images": "從 @a 本漫畫和 @b 張圖片中, 計算你最喜歡的",
|
||||
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括號後是圖片數量或圖片數比漫畫頁數",
|
||||
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支持收藏此章節",
|
||||
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支援收藏此章節",
|
||||
"Author: ": "作者: ",
|
||||
"Tags: ": "標籤: ",
|
||||
"Comics(number): ": "漫畫(數量): ",
|
||||
@@ -621,7 +660,7 @@
|
||||
"Time Filter": "時間篩選",
|
||||
"Image Favorites Greater Than": "圖片收藏數大於",
|
||||
"Collection time": "收藏時間",
|
||||
"Not enable": "不启用",
|
||||
"Not enable": "不啟用",
|
||||
"Double Tap": "雙擊",
|
||||
"Swipe": "滑動",
|
||||
"favoritesCompareComicPages": "收藏數與漫畫頁數比較",
|
||||
@@ -632,7 +671,7 @@
|
||||
"Favorite Num": "收藏數",
|
||||
"Favorite Num Compare Comic Pages": "收藏數比漫畫頁數",
|
||||
"All": "全部",
|
||||
"Last Week": "上周",
|
||||
"Last Week": "上週",
|
||||
"Last Month": "上月",
|
||||
"Last Half Year": "半年",
|
||||
"Last Year": "一年",
|
||||
@@ -648,21 +687,21 @@
|
||||
"End": "末尾",
|
||||
"None": "無",
|
||||
"View Detail": "查看詳情",
|
||||
"Select a directory which contains multiple archive files." : "選擇一個包含多個歸檔文件的目錄",
|
||||
"Multiple archive files" : "多個歸檔文件",
|
||||
"No valid comics found" : "未找到有效的漫畫",
|
||||
"Select a directory which contains multiple archive files.": "選擇一個包含多個歸檔文件的目錄",
|
||||
"Multiple archive files": "多個歸檔文件",
|
||||
"No valid comics found": "未找到有效的漫畫",
|
||||
"Enable DNS Overrides": "啟用DNS覆寫",
|
||||
"DNS Overrides": "DNS覆寫",
|
||||
"Custom Image Processing": "自定義圖片處理",
|
||||
"Custom Image Processing": "自訂圖片處理",
|
||||
"Enable": "啟用",
|
||||
"Aggregated": "聚合",
|
||||
"Default Search Target": "默認搜索目標",
|
||||
"Default Search Target": "預設搜尋目標",
|
||||
"Auto Language Filters": "自動語言篩選",
|
||||
"Check for updates on startup": "啟動時檢查更新",
|
||||
"Start Time": "開始時間",
|
||||
"End Time": "結束時間",
|
||||
"Custom": "自定義",
|
||||
"Reset": "重置",
|
||||
"Custom": "自訂",
|
||||
"Reset": "重設",
|
||||
"Tags": "標籤",
|
||||
"Authors": "作者",
|
||||
"Comics": "漫畫",
|
||||
@@ -670,45 +709,80 @@
|
||||
"New Version": "新版本",
|
||||
"@c updates": "@c 項更新",
|
||||
"No updates": "無更新",
|
||||
"Set comic source list url": "設置漫畫源列表URL",
|
||||
"Set comic source list url": "設定漫畫源列表URL",
|
||||
"Deselect All": "取消全選",
|
||||
"Add keyword": "添加關鍵詞",
|
||||
"Keyword": "關鍵詞",
|
||||
"Add keyword": "添加關鍵字",
|
||||
"Keyword": "關鍵字",
|
||||
"Manage": "管理",
|
||||
"Verify": "驗證",
|
||||
"Cloudflare verification required": "需要Cloudflare驗證",
|
||||
"Success": "成功",
|
||||
"Compressing": "壓縮中",
|
||||
"Exporting": "匯出中",
|
||||
"Search Sources": "搜索源",
|
||||
"Search Sources": "搜尋源",
|
||||
"Removed": "已移除",
|
||||
"Added to favorites": "已添加到收藏",
|
||||
"Not added": "未添加",
|
||||
"Create a folder": "新建收藏夾",
|
||||
"Created successfully": "創建成功",
|
||||
"Create a folder": "建立收藏夾",
|
||||
"Created successfully": "建立成功",
|
||||
"name": "名稱",
|
||||
"Reverse tap to turn Pages": "反轉點擊翻頁",
|
||||
"Show all": "顯示全部",
|
||||
"Number of images preloaded": "預加載圖片數量",
|
||||
"Number of images preloaded": "預載入圖片數量",
|
||||
"Ascending": "升序",
|
||||
"Descending": "降序",
|
||||
"Last Reading: Chapter @ep Page @page": "上次閱讀: 第 @ep 章 第 @page 頁",
|
||||
"Last Reading: Page @page": "上次閱讀: 第 @page 頁",
|
||||
"Last Reading": "上次閱讀",
|
||||
"Replies": "回覆",
|
||||
"Follow Updates": "追更",
|
||||
"Not Configured": "未配置",
|
||||
"Choose a folder to follow updates." : "選擇一個文件夾以追更",
|
||||
"Choose Folder": "選擇文件夾",
|
||||
"No folders available": "沒有可用的文件夾",
|
||||
"Choose a folder to follow updates.": "選擇一個資料夾以追更",
|
||||
"Choose Folder": "選擇資料夾",
|
||||
"No folders available": "沒有可用的資料夾",
|
||||
"Updating comics...": "更新漫畫中...",
|
||||
"Automatic update checking enabled." : "已啟用自動更新檢查",
|
||||
"The app will check for updates at most once a day." : "APP將每天最多檢查一次更新",
|
||||
"Change Folder": "更改文件夾",
|
||||
"Automatic update checking enabled.": "已啟用自動更新檢查",
|
||||
"The app will check for updates at most once a day.": "APP將每天最多檢查一次更新",
|
||||
"Change Folder": "更改資料夾",
|
||||
"Check Now": "立即檢查",
|
||||
"Updates": "更新",
|
||||
"No updates found": "未找到更新",
|
||||
"All Comics": "全部漫畫",
|
||||
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
|
||||
"Disable": "禁用"
|
||||
"Disable": "停用",
|
||||
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與伺服器同步資料",
|
||||
"Cache cleared": "快取已清除",
|
||||
"Disabled": "已停用",
|
||||
"Auto Sync Data": "自動同步資料",
|
||||
"Mark all as read": "全部標記為已讀",
|
||||
"Do you want to mark all as read?": "您要全部標記為已讀嗎?",
|
||||
"Swipe down for previous chapter": "向下滑動查看上一章",
|
||||
"Swipe up for next chapter": "向上滑動查看下一章",
|
||||
"Initial Page": "初始頁面",
|
||||
"Home Page": "首頁",
|
||||
"Favorites Page": "收藏頁面",
|
||||
"Explore Page": "探索頁面",
|
||||
"Categories Page": "分類頁面",
|
||||
"Convert to local": "轉換為本地",
|
||||
"Refresh": "刷新",
|
||||
"Paging": "分頁",
|
||||
"Continuous": "連續",
|
||||
"Display mode of comic list": "漫畫列表的顯示模式",
|
||||
"Show Page Number": "顯示頁碼",
|
||||
"Jump to page": "跳轉到頁面",
|
||||
"Page": "頁面",
|
||||
"Jump": "跳轉",
|
||||
"Copy Image": "複製圖片",
|
||||
"A valid WebDav directory URL": "有效的WebDav目錄URL",
|
||||
"Shut Down": "關閉",
|
||||
"Uploading data...": "正在上傳數據...",
|
||||
"Pages": "頁數",
|
||||
"Long press zoom position": "長按縮放位置",
|
||||
"Press position": "按壓位置",
|
||||
"Screen center": "螢幕中心",
|
||||
"Suggestions": "建議",
|
||||
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
|
||||
"Show single image on first page": "在首頁顯示單張圖片",
|
||||
"Click to select an image": "點擊選擇一張圖片",
|
||||
"Source URL": "源地址",
|
||||
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
BIN
debian/gui/venera.png
vendored
BIN
debian/gui/venera.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 64 KiB |
@@ -80,7 +80,7 @@ class _AppbarState extends State<Appbar> {
|
||||
var content = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.backgroundColor ??
|
||||
context.colorScheme.surface.toOpacity(0.72),
|
||||
context.colorScheme.surface.toOpacity(0.86),
|
||||
),
|
||||
height: _kAppBarHeight + context.padding.top,
|
||||
child: Row(
|
||||
@@ -231,7 +231,7 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
child: BlurEffect(
|
||||
blur: 15,
|
||||
child: Material(
|
||||
color: context.colorScheme.surface.toOpacity(0.72),
|
||||
color: context.colorScheme.surface.toOpacity(0.86),
|
||||
elevation: 0,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: body,
|
||||
@@ -632,6 +632,7 @@ class _TabViewBodyState extends State<TabViewBody> {
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_currentIndex = _controller.index;
|
||||
_controller.addListener(updateIndex);
|
||||
}
|
||||
|
||||
|
@@ -334,7 +334,12 @@ class ComicTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
var children = <Widget>[];
|
||||
for (var line in text.split('\n')) {
|
||||
var lines = text.split('\n');
|
||||
lines.removeWhere((e) => e.trim().isEmpty);
|
||||
if (lines.length > 3) {
|
||||
lines = lines.sublist(0, 3);
|
||||
}
|
||||
for (var line in lines) {
|
||||
children.add(Container(
|
||||
margin: const EdgeInsets.fromLTRB(2, 0, 2, 2),
|
||||
padding: constraints.maxWidth < 80
|
||||
@@ -770,7 +775,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SliverGridComics oldWidget) {
|
||||
if (!oldWidget.comics.isEqualTo(widget.comics)) {
|
||||
if (!comics.isEqualTo(widget.comics)) {
|
||||
comics.clear();
|
||||
for (var comic in widget.comics) {
|
||||
if (isBlocked(comic) == null) {
|
||||
@@ -879,6 +884,7 @@ class _SliverGridComics extends StatelessWidget {
|
||||
return comic;
|
||||
}
|
||||
return AnimatedContainer(
|
||||
key: ValueKey(comics[index].id),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
@@ -1140,7 +1146,7 @@ class ComicListState extends State<ComicList> {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
if (_loading[page] == true) {
|
||||
if (_data[page] != null || _loading[page] == true) {
|
||||
return;
|
||||
}
|
||||
_loading[page] = true;
|
||||
@@ -1150,8 +1156,8 @@ class ComicListState extends State<ComicList> {
|
||||
if (!mounted) return;
|
||||
if (res.success) {
|
||||
if (res.data.isEmpty) {
|
||||
_data[page] = const [];
|
||||
setState(() {
|
||||
_data[page] = const [];
|
||||
_maxPage = page;
|
||||
});
|
||||
} else {
|
||||
@@ -1201,6 +1207,11 @@ class ComicListState extends State<ComicList> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var type = appdata.settings['comicListDisplayMode'];
|
||||
return type == 'paging' ? buildPagingMode() : buildContinuousMode();
|
||||
}
|
||||
|
||||
Widget buildPagingMode() {
|
||||
if (_error != null) {
|
||||
return Column(
|
||||
children: [
|
||||
@@ -1249,6 +1260,85 @@ class ComicListState extends State<ComicList> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildContinuousMode() {
|
||||
if (_error != null && _data.isEmpty) {
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.errorLeading != null) widget.errorLeading!,
|
||||
_buildPageSelector(),
|
||||
Expanded(
|
||||
child: NetworkError(
|
||||
withAppbar: false,
|
||||
message: _error!,
|
||||
retry: () {
|
||||
setState(() {
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (_data[_page] == null) {
|
||||
_loadPage(_page);
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.errorLeading != null) widget.errorLeading!,
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return SmoothCustomScrollView(
|
||||
key: enablePageStorage ? PageStorageKey('scroll$_page') : null,
|
||||
controller: widget.controller,
|
||||
slivers: [
|
||||
if (widget.leadingSliver != null) widget.leadingSliver!,
|
||||
SliverGridComics(
|
||||
comics: _data.values.expand((element) => element).toList(),
|
||||
menuBuilder: widget.menuBuilder,
|
||||
onLastItemBuild: () {
|
||||
if (_error == null && (_maxPage == null || _page < _maxPage!)) {
|
||||
_loadPage(_data.length + 1);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(_error!, maxLines: 3)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_error = null;
|
||||
});
|
||||
},
|
||||
child: Text("Retry".tl),
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingHorizontal(16).paddingVertical(8),
|
||||
)
|
||||
else if (_maxPage == null || _page < _maxPage!)
|
||||
const SliverListLoadingIndicator(),
|
||||
if (widget.trailingSliver != null) widget.trailingSliver!,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StarRating extends StatelessWidget {
|
||||
@@ -1535,17 +1625,20 @@ class _SMClipper extends CustomClipper<Rect> {
|
||||
}
|
||||
|
||||
class SimpleComicTile extends StatelessWidget {
|
||||
const SimpleComicTile({super.key, required this.comic, this.onTap});
|
||||
const SimpleComicTile(
|
||||
{super.key, required this.comic, this.onTap, this.withTitle = false});
|
||||
|
||||
final Comic comic;
|
||||
|
||||
final void Function()? onTap;
|
||||
|
||||
final bool withTitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var image = _findImageProvider(comic);
|
||||
|
||||
var child = image == null
|
||||
Widget child = image == null
|
||||
? const SizedBox()
|
||||
: AnimatedImage(
|
||||
image: image,
|
||||
@@ -1555,7 +1648,18 @@ class SimpleComicTile extends StatelessWidget {
|
||||
filterQuality: FilterQuality.medium,
|
||||
);
|
||||
|
||||
return AnimatedTapRegion(
|
||||
child = Container(
|
||||
width: 98,
|
||||
height: 136,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: child,
|
||||
);
|
||||
|
||||
child = AnimatedTapRegion(
|
||||
borderRadius: 8,
|
||||
onTap: onTap ??
|
||||
() {
|
||||
@@ -1566,16 +1670,29 @@ class SimpleComicTile extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 92,
|
||||
height: 114,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (withTitle) {
|
||||
child = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
child,
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: Center(
|
||||
child: Text(
|
||||
comic.title.replaceAll('\n', ''),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
@@ -163,3 +163,29 @@ class SliverLazyToBoxAdapter extends StatelessWidget {
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class SliverAnimatedVisibility extends StatelessWidget {
|
||||
const SliverAnimatedVisibility({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool visible;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var child = visible ? this.child : const SizedBox.shrink();
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -61,7 +61,7 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
child: BlurEffect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Material(
|
||||
color: context.colorScheme.surface.toOpacity(0.78),
|
||||
color: context.colorScheme.surface.toOpacity(0.92),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
width: width,
|
||||
|
@@ -125,11 +125,11 @@ class OverlayWidgetState extends State<OverlayWidget> {
|
||||
void showDialogMessage(BuildContext context, String title, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
builder: (context) => ContentDialog(
|
||||
title: title,
|
||||
content: Text(message).paddingHorizontal(16),
|
||||
actions: [
|
||||
TextButton(
|
||||
FilledButton(
|
||||
onPressed: context.pop,
|
||||
child: Text("OK".tl),
|
||||
)
|
||||
|
@@ -51,10 +51,32 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
|
||||
static bool _isMouseScroll = App.isDesktop;
|
||||
|
||||
late int id;
|
||||
|
||||
static int _id = 0;
|
||||
|
||||
var activeChildren = <int>{};
|
||||
|
||||
ScrollState? parent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_controller = widget.controller ?? ScrollController();
|
||||
super.initState();
|
||||
id = _id;
|
||||
_id++;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
parent = ScrollState.maybeOf(context);
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
parent?.onChildInactive(id);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -66,8 +88,7 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
const BouncingScrollPhysics(),
|
||||
);
|
||||
}
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
var child = Listener(
|
||||
onPointerDown: (event) {
|
||||
_futurePosition = null;
|
||||
if (_isMouseScroll) {
|
||||
@@ -77,6 +98,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
}
|
||||
},
|
||||
onPointerSignal: (pointerSignal) {
|
||||
if (activeChildren.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
if (pointerSignal is PointerScrollEvent) {
|
||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
||||
return;
|
||||
@@ -99,11 +123,13 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
);
|
||||
if (_futurePosition == old) return;
|
||||
var target = _futurePosition!;
|
||||
_controller.animateTo(
|
||||
_controller
|
||||
.animateTo(
|
||||
_futurePosition!,
|
||||
duration: _fastAnimationDuration,
|
||||
curve: Curves.linear,
|
||||
).then((_) {
|
||||
)
|
||||
.then((_) {
|
||||
var current = _controller.position.pixels;
|
||||
if (current == target && current == _futurePosition) {
|
||||
_futurePosition = null;
|
||||
@@ -111,8 +137,14 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ScrollControllerProvider._(
|
||||
child: ScrollState._(
|
||||
controller: _controller,
|
||||
onChildActive: (id) {
|
||||
activeChildren.add(id);
|
||||
},
|
||||
onChildInactive: (id) {
|
||||
activeChildren.remove(id);
|
||||
},
|
||||
child: widget.builder(
|
||||
context,
|
||||
_controller,
|
||||
@@ -122,25 +154,215 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (parent != null) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) {
|
||||
parent!.onChildActive(id);
|
||||
},
|
||||
onExit: (_) {
|
||||
parent!.onChildInactive(id);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollControllerProvider extends InheritedWidget {
|
||||
const ScrollControllerProvider._({
|
||||
class ScrollState extends InheritedWidget {
|
||||
const ScrollState._({
|
||||
required this.controller,
|
||||
required super.child,
|
||||
required this.onChildActive,
|
||||
required this.onChildInactive,
|
||||
});
|
||||
|
||||
final ScrollController controller;
|
||||
|
||||
static ScrollController of(BuildContext context) {
|
||||
final ScrollControllerProvider? provider =
|
||||
context.dependOnInheritedWidgetOfExactType<ScrollControllerProvider>();
|
||||
return provider!.controller;
|
||||
final void Function(int id) onChildActive;
|
||||
|
||||
final void Function(int id) onChildInactive;
|
||||
|
||||
static ScrollState of(BuildContext context) {
|
||||
final ScrollState? provider =
|
||||
context.dependOnInheritedWidgetOfExactType<ScrollState>();
|
||||
return provider!;
|
||||
}
|
||||
|
||||
static ScrollState? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<ScrollState>();
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ScrollControllerProvider oldWidget) {
|
||||
bool updateShouldNotify(ScrollState oldWidget) {
|
||||
return oldWidget.controller != controller;
|
||||
}
|
||||
}
|
||||
|
||||
class AppScrollBar extends StatefulWidget {
|
||||
const AppScrollBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.child,
|
||||
this.topPadding = 0,
|
||||
});
|
||||
|
||||
final ScrollController controller;
|
||||
|
||||
final Widget child;
|
||||
|
||||
final double topPadding;
|
||||
|
||||
@override
|
||||
State<AppScrollBar> createState() => _AppScrollBarState();
|
||||
}
|
||||
|
||||
class _AppScrollBarState extends State<AppScrollBar> {
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
double minExtent = 0;
|
||||
double maxExtent = 0;
|
||||
double position = 0;
|
||||
|
||||
double viewHeight = 0;
|
||||
|
||||
final _scrollIndicatorSize = App.isDesktop ? 42.0 : 64.0;
|
||||
|
||||
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController = widget.controller;
|
||||
_scrollController.addListener(onChanged);
|
||||
Future.microtask(onChanged);
|
||||
_dragGestureRecognizer = VerticalDragGestureRecognizer()
|
||||
..onUpdate = onUpdate;
|
||||
}
|
||||
|
||||
void onUpdate(DragUpdateDetails details) {
|
||||
if (maxExtent - minExtent <= 0 ||
|
||||
viewHeight == 0 ||
|
||||
details.primaryDelta == null) {
|
||||
return;
|
||||
}
|
||||
var offset = details.primaryDelta!;
|
||||
var positionOffset =
|
||||
offset / (viewHeight - _scrollIndicatorSize) * (maxExtent - minExtent);
|
||||
_scrollController.jumpTo((position + positionOffset).clamp(
|
||||
minExtent,
|
||||
maxExtent,
|
||||
));
|
||||
}
|
||||
|
||||
void onChanged() {
|
||||
if (_scrollController.positions.isEmpty) return;
|
||||
var position = _scrollController.position;
|
||||
if (position.minScrollExtent != minExtent ||
|
||||
position.maxScrollExtent != maxExtent ||
|
||||
position.pixels != this.position) {
|
||||
setState(() {
|
||||
minExtent = position.minScrollExtent;
|
||||
maxExtent = position.maxScrollExtent;
|
||||
this.position = position.pixels;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
var scrollHeight = (maxExtent - minExtent);
|
||||
var height = constrains.maxHeight - widget.topPadding;
|
||||
viewHeight = height;
|
||||
var top = scrollHeight == 0
|
||||
? 0.0
|
||||
: (position - minExtent) /
|
||||
scrollHeight *
|
||||
(height - _scrollIndicatorSize);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: widget.child,
|
||||
),
|
||||
Positioned(
|
||||
top: top + widget.topPadding,
|
||||
right: 0,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
_dragGestureRecognizer.addPointer(event);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: _scrollIndicatorSize/2,
|
||||
height: _scrollIndicatorSize,
|
||||
child: CustomPaint(
|
||||
painter: _ScrollIndicatorPainter(
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Icon(Icons.arrow_drop_up, size: 18),
|
||||
Icon(Icons.arrow_drop_down, size: 18),
|
||||
const Spacer(),
|
||||
],
|
||||
).paddingLeft(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScrollIndicatorPainter extends CustomPainter {
|
||||
final Color backgroundColor;
|
||||
|
||||
final Color shadowColor;
|
||||
|
||||
const _ScrollIndicatorPainter({
|
||||
required this.backgroundColor,
|
||||
required this.shadowColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
var path = Path()
|
||||
..moveTo(size.width, 0)
|
||||
..lineTo(size.width, size.height)
|
||||
..arcToPoint(
|
||||
Offset(size.width, 0),
|
||||
radius: Radius.circular(size.width),
|
||||
);
|
||||
canvas.drawShadow(path, shadowColor, 4, true);
|
||||
var backgroundPaint = Paint()
|
||||
..color = backgroundColor
|
||||
..style = PaintingStyle.fill;
|
||||
path = Path()
|
||||
..moveTo(size.width, 0)
|
||||
..lineTo(size.width, size.height)
|
||||
..arcToPoint(
|
||||
Offset(size.width, 0),
|
||||
radius: Radius.circular(size.width),
|
||||
);
|
||||
canvas.drawPath(path, backgroundPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) {
|
||||
return oldDelegate is! _ScrollIndicatorPainter ||
|
||||
oldDelegate.backgroundColor != backgroundColor ||
|
||||
oldDelegate.shadowColor != shadowColor;
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,13 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SideBarRoute<T> extends PopupRoute<T> {
|
||||
SideBarRoute(this.title, this.widget,
|
||||
SideBarRoute(this.widget,
|
||||
{this.showBarrier = true,
|
||||
this.useSurfaceTintColor = false,
|
||||
required this.width,
|
||||
this.addBottomPadding = true,
|
||||
this.addTopPadding = true});
|
||||
|
||||
final String? title;
|
||||
|
||||
final Widget widget;
|
||||
|
||||
final bool showBarrier;
|
||||
@@ -36,11 +34,7 @@ class SideBarRoute<T> extends PopupRoute<T> {
|
||||
Animation<double> secondaryAnimation) {
|
||||
bool showSideBar = MediaQuery.of(context).size.width > width;
|
||||
|
||||
Widget body = SidebarBody(
|
||||
title: title,
|
||||
widget: widget,
|
||||
autoChangeTitleBarColor: !useSurfaceTintColor,
|
||||
);
|
||||
Widget body = widget;
|
||||
|
||||
if (addTopPadding) {
|
||||
body = Padding(
|
||||
@@ -129,97 +123,13 @@ class SideBarRoute<T> extends PopupRoute<T> {
|
||||
}
|
||||
}
|
||||
|
||||
class SidebarBody extends StatefulWidget {
|
||||
const SidebarBody(
|
||||
{required this.title,
|
||||
required this.widget,
|
||||
required this.autoChangeTitleBarColor,
|
||||
super.key});
|
||||
|
||||
final String? title;
|
||||
final Widget widget;
|
||||
final bool autoChangeTitleBarColor;
|
||||
|
||||
@override
|
||||
State<SidebarBody> createState() => _SidebarBodyState();
|
||||
}
|
||||
|
||||
class _SidebarBodyState extends State<SidebarBody> {
|
||||
bool top = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body = Expanded(child: widget.widget);
|
||||
|
||||
if (widget.autoChangeTitleBarColor) {
|
||||
body = NotificationListener<ScrollNotification>(
|
||||
onNotification: (notifications) {
|
||||
if (notifications.metrics.pixels ==
|
||||
notifications.metrics.minScrollExtent &&
|
||||
!top) {
|
||||
setState(() {
|
||||
top = true;
|
||||
});
|
||||
} else if (notifications.metrics.pixels !=
|
||||
notifications.metrics.minScrollExtent &&
|
||||
top) {
|
||||
setState(() {
|
||||
top = false;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
height: 60 + MediaQuery.of(context).padding.top,
|
||||
color: top
|
||||
? null
|
||||
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
|
||||
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Tooltip(
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
iconSize: 25,
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
),
|
||||
Text(
|
||||
widget.title!,
|
||||
style: const TextStyle(fontSize: 22),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
body
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showSideBar(BuildContext context, Widget widget,
|
||||
{String? title,
|
||||
bool showBarrier = true,
|
||||
{bool showBarrier = true,
|
||||
bool useSurfaceTintColor = false,
|
||||
double width = 500,
|
||||
bool addTopPadding = false}) {
|
||||
return Navigator.of(context).push(
|
||||
SideBarRoute(
|
||||
title,
|
||||
widget,
|
||||
showBarrier: showBarrier,
|
||||
useSurfaceTintColor: useSurfaceTintColor,
|
||||
|
@@ -10,6 +10,34 @@ import 'package:window_manager/window_manager.dart';
|
||||
|
||||
const _kTitleBarHeight = 36.0;
|
||||
|
||||
class WindowFrameController extends InheritedWidget {
|
||||
/// Whether the window frame is hidden.
|
||||
final bool isWindowFrameHidden;
|
||||
|
||||
/// Sets the visibility of the window frame.
|
||||
final void Function(bool) setWindowFrame;
|
||||
|
||||
/// Adds a listener that will be called when close button is clicked.
|
||||
/// The listener should return `true` to allow the window to be closed.
|
||||
final void Function(WindowCloseListener listener) addCloseListener;
|
||||
|
||||
/// Removes a close listener.
|
||||
final void Function(WindowCloseListener listener) removeCloseListener;
|
||||
|
||||
const WindowFrameController._create({
|
||||
required this.isWindowFrameHidden,
|
||||
required this.setWindowFrame,
|
||||
required this.addCloseListener,
|
||||
required this.removeCloseListener,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class WindowFrame extends StatefulWidget {
|
||||
const WindowFrame(this.child, {super.key});
|
||||
|
||||
@@ -17,98 +45,146 @@ class WindowFrame extends StatefulWidget {
|
||||
|
||||
@override
|
||||
State<WindowFrame> createState() => _WindowFrameState();
|
||||
|
||||
static WindowFrameController of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
|
||||
}
|
||||
}
|
||||
|
||||
typedef WindowCloseListener = bool Function();
|
||||
|
||||
class _WindowFrameState extends State<WindowFrame> {
|
||||
bool isHideWindowFrame = false;
|
||||
bool isWindowFrameHidden = false;
|
||||
bool useDarkTheme = false;
|
||||
var closeListeners = <WindowCloseListener>[];
|
||||
|
||||
/// Sets the visibility of the window frame.
|
||||
void setWindowFrame(bool show) {
|
||||
setState(() {
|
||||
isWindowFrameHidden = !show;
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds a listener that will be called when close button is clicked.
|
||||
/// The listener should return `true` to allow the window to be closed.
|
||||
void addCloseListener(WindowCloseListener listener) {
|
||||
closeListeners.add(listener);
|
||||
}
|
||||
|
||||
/// Removes a close listener.
|
||||
void removeCloseListener(WindowCloseListener listener) {
|
||||
closeListeners.remove(listener);
|
||||
}
|
||||
|
||||
void _onClose() {
|
||||
for (var listener in closeListeners) {
|
||||
if (!listener()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (App.isMobile) return widget.child;
|
||||
if (isHideWindowFrame) return widget.child;
|
||||
|
||||
var body = Stack(
|
||||
Widget body = Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
|
||||
padding: isWindowFrameHidden
|
||||
? null
|
||||
: const EdgeInsets.only(top: _kTitleBarHeight),
|
||||
),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
brightness: useDarkTheme ? Brightness.dark : null,
|
||||
),
|
||||
child: Builder(builder: (context) {
|
||||
return SizedBox(
|
||||
height: _kTitleBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
if (App.isMacOS)
|
||||
const DragToMoveArea(
|
||||
child: SizedBox(
|
||||
height: double.infinity,
|
||||
width: 16,
|
||||
),
|
||||
).paddingRight(52)
|
||||
else
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DragToMoveArea(
|
||||
child: Text(
|
||||
'Venera',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: (useDarkTheme ||
|
||||
context.brightness == Brightness.dark)
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
if (!isWindowFrameHidden)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
brightness: useDarkTheme ? Brightness.dark : null,
|
||||
),
|
||||
child: Builder(builder: (context) {
|
||||
return SizedBox(
|
||||
height: _kTitleBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
if (App.isMacOS)
|
||||
const DragToMoveArea(
|
||||
child: SizedBox(
|
||||
height: double.infinity,
|
||||
width: 16,
|
||||
),
|
||||
).paddingRight(52)
|
||||
else
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DragToMoveArea(
|
||||
child: Text(
|
||||
'Venera',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: (useDarkTheme ||
|
||||
context.brightness == Brightness.dark)
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
)
|
||||
.toAlign(Alignment.centerLeft)
|
||||
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
|
||||
),
|
||||
),
|
||||
if (kDebugMode)
|
||||
const TextButton(
|
||||
onPressed: debug,
|
||||
child: Text('Debug'),
|
||||
),
|
||||
if (!App.isMacOS)
|
||||
_WindowButtons(
|
||||
onClose: _onClose,
|
||||
)
|
||||
.toAlign(Alignment.centerLeft)
|
||||
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
|
||||
),
|
||||
),
|
||||
if (kDebugMode)
|
||||
const TextButton(
|
||||
onPressed: debug,
|
||||
child: Text('Debug'),
|
||||
),
|
||||
if (!App.isMacOS) const WindowButtons()
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
if (App.isLinux) {
|
||||
return VirtualWindowFrame(child: body);
|
||||
} else {
|
||||
return body;
|
||||
body = VirtualWindowFrame(child: body);
|
||||
}
|
||||
|
||||
return WindowFrameController._create(
|
||||
isWindowFrameHidden: isWindowFrameHidden,
|
||||
setWindowFrame: setWindowFrame,
|
||||
addCloseListener: addCloseListener,
|
||||
removeCloseListener: removeCloseListener,
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WindowButtons extends StatefulWidget {
|
||||
const WindowButtons({super.key});
|
||||
class _WindowButtons extends StatefulWidget {
|
||||
const _WindowButtons({required this.onClose});
|
||||
|
||||
final void Function() onClose;
|
||||
|
||||
@override
|
||||
State<WindowButtons> createState() => _WindowButtonsState();
|
||||
State<_WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
|
||||
bool isMaximized = false;
|
||||
|
||||
@override
|
||||
@@ -197,9 +273,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
color: !dark ? Colors.white : Colors.black,
|
||||
),
|
||||
hoverColor: Colors.red,
|
||||
onPressed: () {
|
||||
windowManager.close();
|
||||
},
|
||||
onPressed: widget.onClose,
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -486,22 +560,18 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
|
||||
}
|
||||
|
||||
Widget _buildVirtualWindowFrame(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_isMaximized ? 0 : 8),
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: (_isMaximized || _isFullScreen) ? 0 : 1,
|
||||
),
|
||||
boxShadow: <BoxShadow>[
|
||||
if (!_isMaximized && !_isFullScreen)
|
||||
BoxShadow(
|
||||
color: Colors.black.toOpacity(0.1),
|
||||
offset: Offset(0.0, _isFocused ? 4 : 2),
|
||||
blurRadius: 6,
|
||||
)
|
||||
BoxShadow(
|
||||
color: Colors.black.toOpacity(_isFocused ? 0.4 : 0.2),
|
||||
blurRadius: 4,
|
||||
)
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -510,7 +580,10 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
|
||||
Widget build(BuildContext context) {
|
||||
return DragToResizeArea(
|
||||
enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null,
|
||||
child: _buildVirtualWindowFrame(context),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(_isMaximized ? 0 : 4),
|
||||
child: _buildVirtualWindowFrame(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -567,5 +640,5 @@ TransitionBuilder VirtualWindowFrameInit() {
|
||||
}
|
||||
|
||||
void debug() {
|
||||
ComicSource.reload();
|
||||
ComicSourceManager().reload();
|
||||
}
|
||||
|
@@ -3,14 +3,17 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
|
||||
import 'appdata.dart';
|
||||
import 'favorites.dart';
|
||||
import 'local.dart';
|
||||
|
||||
export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.3.0";
|
||||
final version = "1.4.1";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
@@ -44,6 +47,7 @@ class _App {
|
||||
|
||||
late String dataPath;
|
||||
late String cachePath;
|
||||
String? externalStoragePath;
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
@@ -51,6 +55,14 @@ class _App {
|
||||
|
||||
BuildContext get rootContext => rootNavigatorKey.currentContext!;
|
||||
|
||||
final Appdata data = appdata;
|
||||
|
||||
final HistoryManager history = HistoryManager();
|
||||
|
||||
final LocalFavoritesManager favorites = LocalFavoritesManager();
|
||||
|
||||
final LocalManager local = LocalManager();
|
||||
|
||||
void rootPop() {
|
||||
rootNavigatorKey.currentState?.maybePop();
|
||||
}
|
||||
@@ -66,6 +78,18 @@ class _App {
|
||||
Future<void> init() async {
|
||||
cachePath = (await getApplicationCacheDirectory()).path;
|
||||
dataPath = (await getApplicationSupportDirectory()).path;
|
||||
if (isAndroid) {
|
||||
externalStoragePath = (await getExternalStorageDirectory())!.path;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initComponents() async {
|
||||
await Future.wait([
|
||||
data.init(),
|
||||
history.init(),
|
||||
favorites.init(),
|
||||
local.init(),
|
||||
]);
|
||||
}
|
||||
|
||||
Function? _forceRebuildHandler;
|
||||
|
@@ -4,27 +4,31 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/init.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
class _Appdata {
|
||||
final _Settings settings = _Settings();
|
||||
class Appdata with Init {
|
||||
Appdata._create();
|
||||
|
||||
final Settings settings = Settings._create();
|
||||
|
||||
var searchHistory = <String>[];
|
||||
|
||||
bool _isSavingData = false;
|
||||
|
||||
Future<void> saveData([bool sync = true]) async {
|
||||
if (_isSavingData) {
|
||||
await Future.doWhile(() async {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
return _isSavingData;
|
||||
});
|
||||
while (_isSavingData) {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
_isSavingData = true;
|
||||
var data = jsonEncode(toJson());
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
await file.writeAsString(data);
|
||||
_isSavingData = false;
|
||||
try {
|
||||
var data = jsonEncode(toJson());
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
await file.writeAsString(data);
|
||||
}
|
||||
finally {
|
||||
_isSavingData = false;
|
||||
}
|
||||
if (sync) {
|
||||
DataSync().uploadData();
|
||||
}
|
||||
@@ -51,28 +55,6 @@ class _Appdata {
|
||||
saveData();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
var file = File(FilePath.join(
|
||||
dataPath,
|
||||
'appdata.json',
|
||||
));
|
||||
if (!await file.exists()) {
|
||||
return;
|
||||
}
|
||||
var json = jsonDecode(await file.readAsString());
|
||||
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
|
||||
if (json['settings'][key] != null) {
|
||||
settings[key] = json['settings'][key];
|
||||
}
|
||||
}
|
||||
searchHistory = List.from(json['searchHistory']);
|
||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||
if (await implicitDataFile.exists()) {
|
||||
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'settings': settings._data,
|
||||
@@ -104,16 +86,53 @@ class _Appdata {
|
||||
|
||||
var implicitData = <String, dynamic>{};
|
||||
|
||||
void writeImplicitData() {
|
||||
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
|
||||
file.writeAsString(jsonEncode(implicitData));
|
||||
void writeImplicitData() async {
|
||||
while (_isSavingData) {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
_isSavingData = true;
|
||||
try {
|
||||
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
|
||||
await file.writeAsString(jsonEncode(implicitData));
|
||||
}
|
||||
finally {
|
||||
_isSavingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> doInit() async {
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
var file = File(FilePath.join(
|
||||
dataPath,
|
||||
'appdata.json',
|
||||
));
|
||||
if (!await file.exists()) {
|
||||
return;
|
||||
}
|
||||
var json = jsonDecode(await file.readAsString());
|
||||
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
|
||||
if (json['settings'][key] != null) {
|
||||
settings[key] = json['settings'][key];
|
||||
}
|
||||
}
|
||||
searchHistory = List.from(json['searchHistory']);
|
||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||
if (await implicitDataFile.exists()) {
|
||||
try {
|
||||
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
||||
}
|
||||
catch(_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final appdata = _Appdata();
|
||||
final appdata = Appdata._create();
|
||||
|
||||
class _Settings with ChangeNotifier {
|
||||
_Settings();
|
||||
class Settings with ChangeNotifier {
|
||||
Settings._create();
|
||||
|
||||
final _data = <String, dynamic>{
|
||||
'comicDisplayMode': 'detailed', // detailed, brief
|
||||
@@ -133,7 +152,8 @@ class _Settings with ChangeNotifier {
|
||||
'defaultSearchTarget': null,
|
||||
'autoPageTurningInterval': 5, // in seconds
|
||||
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
|
||||
'readerScreenPicNumber': 1, // 1 - 5
|
||||
'readerScreenPicNumberForLandscape': 1, // 1 - 5
|
||||
'readerScreenPicNumberForPortrait': 1, // 1 - 5
|
||||
'enableTapToTurnPages': true,
|
||||
'reverseTapToTurnPages': false,
|
||||
'enablePageAnimation': true,
|
||||
@@ -141,6 +161,7 @@ class _Settings with ChangeNotifier {
|
||||
'cacheSize': 2048, // in MB
|
||||
'downloadThreads': 5,
|
||||
'enableLongPressToZoom': true,
|
||||
'longPressZoomPosition': "press", // press, center
|
||||
'checkUpdateOnStart': false,
|
||||
'limitImageWidth': true,
|
||||
'webdav': [], // empty means not configured
|
||||
@@ -157,9 +178,13 @@ class _Settings with ChangeNotifier {
|
||||
'customImageProcessing': defaultCustomImageProcessing,
|
||||
'sni': true,
|
||||
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
|
||||
'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
|
||||
'comicSourceListUrl': defaultComicSourceUrl,
|
||||
'preloadImageCount': 4,
|
||||
'followUpdatesFolder': null,
|
||||
'initialPage': '0',
|
||||
'comicListDisplayMode': 'paging', // paging, continuous
|
||||
'showPageNumberInReader': true,
|
||||
'showSingleImageOnFirstPage': false,
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
@@ -188,9 +213,11 @@ const defaultCustomImageProcessing = '''
|
||||
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
|
||||
*/
|
||||
function processImage(image, cid, eid, page, sourceKey) {
|
||||
let image = new Promise((resolve, reject) => {
|
||||
let futureImage = new Promise((resolve, reject) => {
|
||||
resolve(image);
|
||||
});
|
||||
return image;
|
||||
return futureImage;
|
||||
}
|
||||
''';
|
||||
|
||||
const defaultComicSourceUrl = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json";
|
||||
|
@@ -21,7 +21,7 @@ class CacheManager {
|
||||
|
||||
int _limitSize = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
CacheManager._create(){
|
||||
CacheManager._create() {
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db = sqlite3.open('${App.dataPath}/cache.db');
|
||||
_db.execute('''
|
||||
@@ -33,100 +33,102 @@ class CacheManager {
|
||||
type TEXT
|
||||
)
|
||||
''');
|
||||
compute((path) => Directory(path).size, cachePath)
|
||||
.then((value) => _currentSize = value);
|
||||
compute((path) => Directory(path).size, cachePath).then((value) {
|
||||
_currentSize = value;
|
||||
checkCache();
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the singleton instance of CacheManager.
|
||||
factory CacheManager() => instance ??= CacheManager._create();
|
||||
|
||||
/// set cache size limit in MB
|
||||
void setLimitSize(int size){
|
||||
void setLimitSize(int size) {
|
||||
_limitSize = size * 1024 * 1024;
|
||||
}
|
||||
|
||||
void setType(String key, String? type){
|
||||
_db.execute('''
|
||||
UPDATE cache
|
||||
SET type = ?
|
||||
WHERE key = ?
|
||||
''', [type, key]);
|
||||
}
|
||||
|
||||
String? getType(String key){
|
||||
var res = _db.select('''
|
||||
SELECT type FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return null;
|
||||
}
|
||||
return res.first[0];
|
||||
}
|
||||
|
||||
Future<void> writeCache(String key, List<int> data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
|
||||
/// Write cache to disk.
|
||||
Future<void> writeCache(String key, List<int> data,
|
||||
[int duration = 7 * 24 * 60 * 60 * 1000]) async {
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var name = md5.convert(key.codeUnits).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsBytes(data);
|
||||
var expires = DateTime.now().millisecondsSinceEpoch + duration;
|
||||
_db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir.toString(), name, expires]);
|
||||
if(_currentSize != null) {
|
||||
if (_currentSize != null) {
|
||||
_currentSize = _currentSize! + data.length;
|
||||
}
|
||||
checkCacheIfRequired();
|
||||
}
|
||||
|
||||
Future<CachingFile> openWrite(String key) async{
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
return CachingFile._(key, dir.toString(), name, file);
|
||||
}
|
||||
|
||||
Future<File?> findCache(String key) async{
|
||||
/// Find cache by key.
|
||||
/// If cache is expired, it will be deleted and return null.
|
||||
/// If cache is not found, it will return null.
|
||||
/// If cache is found, it will return the file, and update the expires time.
|
||||
Future<File?> findCache(String key) async {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
var row = res.first;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var expires = row[3] as int;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
var now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (expires < now) {
|
||||
// expired
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (await file.exists()) {
|
||||
// update time
|
||||
var expires = now + 7 * 24 * 60 * 60 * 1000;
|
||||
_db.execute('''
|
||||
UPDATE cache
|
||||
SET expires = ?
|
||||
WHERE key = ?
|
||||
''', [expires, key]);
|
||||
return file;
|
||||
} else {
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isChecking = false;
|
||||
|
||||
/// Check cache size and delete expired cache.
|
||||
/// Only check cache if current size is greater than limit size.
|
||||
void checkCacheIfRequired() {
|
||||
if(_currentSize != null && _currentSize! > _limitSize){
|
||||
if (_currentSize != null && _currentSize! > _limitSize) {
|
||||
checkCache();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> checkCache() async{
|
||||
if(_isChecking){
|
||||
/// Check cache size and delete expired cache.
|
||||
/// If current size is greater than limit size,
|
||||
/// delete cache until current size is less than limit size.
|
||||
Future<void> checkCache() async {
|
||||
if (_isChecking) {
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
@@ -134,11 +136,13 @@ class CacheManager {
|
||||
SELECT * FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
for(var row in res){
|
||||
for (var row in res) {
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
if (await file.exists()) {
|
||||
var size = await file.length();
|
||||
_currentSize = _currentSize! - size;
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
@@ -147,26 +151,18 @@ class CacheManager {
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
|
||||
int count = 0;
|
||||
var res2 = _db.select('''
|
||||
SELECT COUNT(*) FROM cache
|
||||
''');
|
||||
if(res2.isNotEmpty){
|
||||
count = res2.first[0] as int;
|
||||
}
|
||||
|
||||
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){
|
||||
while (_currentSize != null && _currentSize! > _limitSize) {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
ORDER BY expires ASC
|
||||
limit 10
|
||||
''');
|
||||
for(var row in res){
|
||||
for (var row in res) {
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
if (await file.exists()) {
|
||||
var size = await file.length();
|
||||
await file.delete();
|
||||
_db.execute('''
|
||||
@@ -174,7 +170,7 @@ class CacheManager {
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
_currentSize = _currentSize! - size;
|
||||
if(_currentSize! <= _limitSize){
|
||||
if (_currentSize! <= _limitSize) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -183,18 +179,18 @@ class CacheManager {
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
}
|
||||
count--;
|
||||
}
|
||||
}
|
||||
_isChecking = false;
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async{
|
||||
/// Delete cache by key.
|
||||
Future<void> delete(String key) async {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
if (res.isEmpty) {
|
||||
return;
|
||||
}
|
||||
var row = res.first;
|
||||
@@ -202,7 +198,7 @@ class CacheManager {
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
if (await file.exists()) {
|
||||
fileSize = await file.length();
|
||||
await file.delete();
|
||||
}
|
||||
@@ -210,11 +206,12 @@ class CacheManager {
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
if (_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all cache.
|
||||
Future<void> clear() async {
|
||||
await Directory(cachePath).delete(recursive: true);
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
@@ -223,75 +220,4 @@ class CacheManager {
|
||||
''');
|
||||
_currentSize = 0;
|
||||
}
|
||||
|
||||
Future<void> deleteKeyword(String keyword) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key LIKE ?
|
||||
''', ['%$keyword%']);
|
||||
for(var row in res){
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
fileSize = await file.length();
|
||||
try {
|
||||
await file.delete();
|
||||
}
|
||||
finally {}
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CachingFile{
|
||||
CachingFile._(this.key, this.dir, this.name, this.file);
|
||||
|
||||
final String key;
|
||||
|
||||
final String dir;
|
||||
|
||||
final String name;
|
||||
|
||||
final File file;
|
||||
|
||||
final List<int> _buffer = [];
|
||||
|
||||
Future<void> writeBytes(List<int> data) async{
|
||||
_buffer.addAll(data);
|
||||
if(_buffer.length > 1024 * 1024){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
_buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async{
|
||||
if(_buffer.isNotEmpty){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
}
|
||||
CacheManager()._db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
|
||||
CacheManager().checkCacheIfRequired();
|
||||
}
|
||||
|
||||
Future<void> cancel() async{
|
||||
await file.deleteIgnoreError();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_buffer.clear();
|
||||
if(file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
@@ -34,24 +34,28 @@ class CategoryButtonData {
|
||||
});
|
||||
}
|
||||
|
||||
class CategoryItem {
|
||||
final String label;
|
||||
|
||||
final PageJumpTarget target;
|
||||
|
||||
const CategoryItem(this.label, this.target);
|
||||
}
|
||||
|
||||
abstract class BaseCategoryPart {
|
||||
String get title;
|
||||
|
||||
List<String> get categories;
|
||||
|
||||
List<String>? get categoryParams => null;
|
||||
List<CategoryItem> get categories;
|
||||
|
||||
bool get enableRandom;
|
||||
|
||||
String get categoryType;
|
||||
|
||||
/// Data class for building a part of category page.
|
||||
const BaseCategoryPart();
|
||||
}
|
||||
|
||||
class FixedCategoryPart extends BaseCategoryPart {
|
||||
@override
|
||||
final List<String> categories;
|
||||
final List<CategoryItem> categories;
|
||||
|
||||
@override
|
||||
bool get enableRandom => false;
|
||||
@@ -59,19 +63,12 @@ class FixedCategoryPart extends BaseCategoryPart {
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
@override
|
||||
final List<String>? categoryParams;
|
||||
|
||||
/// A [BaseCategoryPart] that show fixed tags on category page.
|
||||
const FixedCategoryPart(this.title, this.categories, this.categoryType,
|
||||
[this.categoryParams]);
|
||||
const FixedCategoryPart(this.title, this.categories);
|
||||
}
|
||||
|
||||
class RandomCategoryPart extends BaseCategoryPart {
|
||||
final List<String> tags;
|
||||
final List<CategoryItem> all;
|
||||
|
||||
final int randomNumber;
|
||||
|
||||
@@ -81,71 +78,63 @@ class RandomCategoryPart extends BaseCategoryPart {
|
||||
@override
|
||||
bool get enableRandom => true;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
List<String> _categories() {
|
||||
if (randomNumber >= tags.length) {
|
||||
return tags;
|
||||
List<CategoryItem> _categories() {
|
||||
if (randomNumber >= all.length) {
|
||||
return all;
|
||||
}
|
||||
var start = math.Random().nextInt(tags.length - randomNumber);
|
||||
return tags.sublist(start, start + randomNumber);
|
||||
var start = math.Random().nextInt(all.length - randomNumber);
|
||||
return all.sublist(start, start + randomNumber);
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get categories => _categories();
|
||||
List<CategoryItem> get categories => _categories();
|
||||
|
||||
/// A [BaseCategoryPart] that show random tags on category page.
|
||||
/// A [BaseCategoryPart] that show a part of random tags on category page.
|
||||
const RandomCategoryPart(
|
||||
this.title, this.tags, this.randomNumber, this.categoryType);
|
||||
this.title,
|
||||
this.all,
|
||||
this.randomNumber,
|
||||
);
|
||||
}
|
||||
|
||||
class RandomCategoryPartWithRuntimeData extends BaseCategoryPart {
|
||||
final Iterable<String> Function() loadTags;
|
||||
class DynamicCategoryPart extends BaseCategoryPart {
|
||||
final JSAutoFreeFunction loader;
|
||||
|
||||
final int randomNumber;
|
||||
final String sourceKey;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
bool get enableRandom => true;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
static final random = math.Random();
|
||||
|
||||
List<String> _categories() {
|
||||
var tags = loadTags();
|
||||
if (randomNumber >= tags.length) {
|
||||
return tags.toList();
|
||||
List<CategoryItem> get categories {
|
||||
var data = loader([]);
|
||||
if (data is! List) {
|
||||
throw "DynamicCategoryPart loader must return a List";
|
||||
}
|
||||
final start = random.nextInt(tags.length - randomNumber);
|
||||
var res = List.filled(randomNumber, '');
|
||||
int index = -1;
|
||||
for (var s in tags) {
|
||||
index++;
|
||||
if (start > index) {
|
||||
continue;
|
||||
} else if (index == start + randomNumber) {
|
||||
break;
|
||||
var res = <CategoryItem>[];
|
||||
for (var item in data) {
|
||||
if (item is! Map) {
|
||||
throw "DynamicCategoryPart loader must return a List of Map";
|
||||
}
|
||||
res[index - start] = s;
|
||||
var label = item['label'];
|
||||
var target = PageJumpTarget.parse(sourceKey, item['target']);
|
||||
if (label is! String) {
|
||||
throw "Category label must be a String";
|
||||
}
|
||||
res.add(CategoryItem(label, target));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get categories => _categories();
|
||||
bool get enableRandom => false;
|
||||
|
||||
/// A [BaseCategoryPart] that show random tags on category page.
|
||||
RandomCategoryPartWithRuntimeData(
|
||||
this.title, this.loadTags, this.randomNumber, this.categoryType);
|
||||
@override
|
||||
final String title;
|
||||
|
||||
/// A [BaseCategoryPart] that show dynamic tags on category page.
|
||||
const DynamicCategoryPart(this.title, this.loader, this.sourceKey);
|
||||
}
|
||||
|
||||
CategoryData getCategoryDataWithKey(String key) {
|
||||
for (var source in ComicSource._sources) {
|
||||
for (var source in ComicSource.all()) {
|
||||
if (source.categoryData?.key == key) {
|
||||
return source.categoryData!;
|
||||
}
|
||||
|
@@ -11,8 +11,11 @@ import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/pages/category_comics_page.dart';
|
||||
import 'package:venera/pages/search_result_page.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/init.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
@@ -27,81 +30,29 @@ part 'parser.dart';
|
||||
|
||||
part 'models.dart';
|
||||
|
||||
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
|
||||
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
|
||||
part 'types.dart';
|
||||
|
||||
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
|
||||
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
|
||||
String? next);
|
||||
class ComicSourceManager with ChangeNotifier, Init {
|
||||
final List<ComicSource> _sources = [];
|
||||
|
||||
typedef LoginFunction = Future<Res<bool>> Function(String, String);
|
||||
static ComicSourceManager? _instance;
|
||||
|
||||
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
|
||||
ComicSourceManager._create();
|
||||
|
||||
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
|
||||
String id, String? ep);
|
||||
factory ComicSourceManager() => _instance ??= ComicSourceManager._create();
|
||||
|
||||
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
|
||||
String id, String? subId, int page, String? replyTo);
|
||||
List<ComicSource> all() => List.from(_sources);
|
||||
|
||||
typedef SendCommentFunc = Future<Res<bool>> Function(
|
||||
String id, String? subId, String content, String? replyTo);
|
||||
|
||||
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
|
||||
String imageKey, String comicId, String epId)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
|
||||
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
|
||||
String comicId, String? next);
|
||||
|
||||
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
|
||||
String comicId, bool isLiking);
|
||||
|
||||
/// [isLiking] is true if the user is liking the comment, false if unliking.
|
||||
/// return the new likes count or null.
|
||||
typedef LikeCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isLiking);
|
||||
|
||||
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
|
||||
/// return the new vote count or null.
|
||||
typedef VoteCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
|
||||
|
||||
typedef HandleClickTagEvent = Map<String, String> Function(
|
||||
String namespace, String tag);
|
||||
|
||||
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
|
||||
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
||||
|
||||
class ComicSource {
|
||||
static final List<ComicSource> _sources = [];
|
||||
|
||||
static final List<Function> _listeners = [];
|
||||
|
||||
static void addListener(Function listener) {
|
||||
_listeners.add(listener);
|
||||
}
|
||||
|
||||
static void removeListener(Function listener) {
|
||||
_listeners.remove(listener);
|
||||
}
|
||||
|
||||
static void notifyListeners() {
|
||||
for (var listener in _listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
static List<ComicSource> all() => List.from(_sources);
|
||||
|
||||
static ComicSource? find(String key) =>
|
||||
ComicSource? find(String key) =>
|
||||
_sources.firstWhereOrNull((element) => element.key == key);
|
||||
|
||||
static ComicSource? fromIntKey(int key) =>
|
||||
ComicSource? fromIntKey(int key) =>
|
||||
_sources.firstWhereOrNull((element) => element.key.hashCode == key);
|
||||
|
||||
static Future<void> init() async {
|
||||
@override
|
||||
@protected
|
||||
Future<void> doInit() async {
|
||||
await JsEngine().ensureInit();
|
||||
final path = "${App.dataPath}/comic_source";
|
||||
if (!(await Directory(path).exists())) {
|
||||
Directory(path).create();
|
||||
@@ -120,26 +71,49 @@ class ComicSource {
|
||||
}
|
||||
}
|
||||
|
||||
static Future reload() async {
|
||||
Future reload() async {
|
||||
_sources.clear();
|
||||
JsEngine().runCode("ComicSource.sources = {};");
|
||||
await init();
|
||||
await doInit();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static void add(ComicSource source) {
|
||||
void add(ComicSource source) {
|
||||
_sources.add(source);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static void remove(String key) {
|
||||
void remove(String key) {
|
||||
_sources.removeWhere((element) => element.key == key);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static final availableUpdates = <String, String>{};
|
||||
bool get isEmpty => _sources.isEmpty;
|
||||
|
||||
static bool get isEmpty => _sources.isEmpty;
|
||||
/// Key is the source key, value is the version.
|
||||
final _availableUpdates = <String, String>{};
|
||||
|
||||
void updateAvailableUpdates(Map<String, String> updates) {
|
||||
_availableUpdates.addAll(updates);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Map<String, String> get availableUpdates => Map.from(_availableUpdates);
|
||||
|
||||
void notifyStateChange() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class ComicSource {
|
||||
static List<ComicSource> all() => ComicSourceManager().all();
|
||||
|
||||
static ComicSource? find(String key) => ComicSourceManager().find(key);
|
||||
|
||||
static ComicSource? fromIntKey(int key) =>
|
||||
ComicSourceManager().fromIntKey(key);
|
||||
|
||||
static bool get isEmpty => ComicSourceManager().isEmpty;
|
||||
|
||||
/// Name of this source.
|
||||
final String name;
|
||||
@@ -321,7 +295,7 @@ class AccountConfig {
|
||||
this.onLoginWithWebviewSuccess,
|
||||
this.cookieFields,
|
||||
this.validateCookies,
|
||||
) : infoItems = const [];
|
||||
) : infoItems = const [];
|
||||
}
|
||||
|
||||
class AccountInfoItem {
|
||||
@@ -377,7 +351,7 @@ class ExplorePagePart {
|
||||
/// - category:categoryName
|
||||
///
|
||||
/// End with `@`+`param` if the category has a parameter.
|
||||
final String? viewMore;
|
||||
final PageJumpTarget? viewMore;
|
||||
|
||||
const ExplorePagePart(this.title, this.comics, this.viewMore);
|
||||
}
|
||||
@@ -478,4 +452,4 @@ class ArchiveDownloader {
|
||||
final Future<Res<String>> Function(String cid, String aid) getDownloadUrl;
|
||||
|
||||
const ArchiveDownloader(this.getArchives, this.getDownloadUrl);
|
||||
}
|
||||
}
|
||||
|
@@ -111,6 +111,9 @@ class Comic {
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ sourceKey.hashCode;
|
||||
|
||||
@override
|
||||
toString() => "$sourceKey@$id";
|
||||
}
|
||||
|
||||
class ComicDetails with HistoryMixin {
|
||||
@@ -128,12 +131,7 @@ class ComicDetails with HistoryMixin {
|
||||
final Map<String, List<String>> tags;
|
||||
|
||||
/// id-name
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
/// key is group name.
|
||||
/// When this field is not null, [chapters] will be a merged map of all groups.
|
||||
/// Only available in some sources.
|
||||
final Map<String, Map<String, String>>? groupedChapters;
|
||||
final ComicChapters? chapters;
|
||||
|
||||
final List<String>? thumbnails;
|
||||
|
||||
@@ -171,50 +169,20 @@ class ComicDetails with HistoryMixin {
|
||||
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
|
||||
var res = <String, List<String>>{};
|
||||
map.forEach((key, value) {
|
||||
res[key] = List<String>.from(value);
|
||||
if (value is List) {
|
||||
res[key] = List<String>.from(value);
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
static Map<String, String>? _getChapters(dynamic chapters) {
|
||||
if (chapters == null) return null;
|
||||
var result = <String, String>{};
|
||||
if (chapters is Map) {
|
||||
for (var entry in chapters.entries) {
|
||||
var value = entry.value;
|
||||
if (value is Map) {
|
||||
result.addAll(Map.from(value));
|
||||
} else {
|
||||
result[entry.key.toString()] = value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static Map<String, Map<String, String>>? _getGroupedChapters(dynamic chapters) {
|
||||
if (chapters == null) return null;
|
||||
var result = <String, Map<String, String>>{};
|
||||
if (chapters is Map) {
|
||||
for (var entry in chapters.entries) {
|
||||
var value = entry.value;
|
||||
if (value is Map) {
|
||||
result[entry.key.toString()] = Map.from(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.isEmpty) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
ComicDetails.fromJson(Map<String, dynamic> json)
|
||||
: title = json["title"],
|
||||
subTitle = json["subtitle"],
|
||||
cover = json["cover"],
|
||||
description = json["description"],
|
||||
tags = _generateMap(json["tags"]),
|
||||
chapters = _getChapters(json["chapters"]),
|
||||
groupedChapters = _getGroupedChapters(json["chapters"]),
|
||||
chapters = ComicChapters.fromJsonOrNull(json["chapters"]),
|
||||
sourceKey = json["sourceKey"],
|
||||
comicId = json["comicId"],
|
||||
thumbnails = ListOrNull.from(json["thumbnails"]),
|
||||
@@ -342,3 +310,232 @@ class ArchiveInfo {
|
||||
description = json["description"],
|
||||
id = json["id"];
|
||||
}
|
||||
|
||||
class ComicChapters {
|
||||
final Map<String, String>? _chapters;
|
||||
|
||||
final Map<String, Map<String, String>>? _groupedChapters;
|
||||
|
||||
/// Create a ComicChapters object with a flat map
|
||||
const ComicChapters(Map<String, String> this._chapters)
|
||||
: _groupedChapters = null;
|
||||
|
||||
/// Create a ComicChapters object with a grouped map
|
||||
const ComicChapters.grouped(
|
||||
Map<String, Map<String, String>> this._groupedChapters)
|
||||
: _chapters = null;
|
||||
|
||||
factory ComicChapters.fromJson(dynamic json) {
|
||||
if (json is! Map) throw ArgumentError("Invalid json type");
|
||||
var chapters = <String, String>{};
|
||||
var groupedChapters = <String, Map<String, String>>{};
|
||||
for (var entry in json.entries) {
|
||||
var key = entry.key;
|
||||
var value = entry.value;
|
||||
if (key is! String) throw ArgumentError("Invalid key type");
|
||||
if (value is Map) {
|
||||
groupedChapters[key] = Map.from(value);
|
||||
} else {
|
||||
chapters[key] = value.toString();
|
||||
}
|
||||
}
|
||||
if (chapters.isNotEmpty) {
|
||||
return ComicChapters(chapters);
|
||||
} else if (groupedChapters.isNotEmpty) {
|
||||
return ComicChapters.grouped(groupedChapters);
|
||||
} else {
|
||||
// return a empty list.
|
||||
return ComicChapters(chapters);
|
||||
}
|
||||
}
|
||||
|
||||
static fromJsonOrNull(dynamic json) {
|
||||
if (json == null) return null;
|
||||
return ComicChapters.fromJson(json);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
if (_chapters != null) {
|
||||
return _chapters;
|
||||
} else {
|
||||
return _groupedChapters!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the chapters are grouped
|
||||
bool get isGrouped => _groupedChapters != null;
|
||||
|
||||
/// All group names
|
||||
Iterable<String> get groups => _groupedChapters?.keys ?? [];
|
||||
|
||||
/// All chapters.
|
||||
/// If the chapters are grouped, all groups will be merged.
|
||||
Map<String, String> get allChapters {
|
||||
if (_chapters != null) return _chapters;
|
||||
var res = <String, String>{};
|
||||
for (var entry in _groupedChapters!.values) {
|
||||
res.addAll(entry);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/// Get a group of chapters by name
|
||||
Map<String, String> getGroup(String group) {
|
||||
return _groupedChapters![group] ?? {};
|
||||
}
|
||||
|
||||
/// Get a group of chapters by index(0-based)
|
||||
Map<String, String> getGroupByIndex(int index) {
|
||||
return _groupedChapters!.values.elementAt(index);
|
||||
}
|
||||
|
||||
/// Get total number of chapters
|
||||
int get length {
|
||||
return isGrouped
|
||||
? _groupedChapters!.values.map((e) => e.length).reduce((a, b) => a + b)
|
||||
: _chapters!.length;
|
||||
}
|
||||
|
||||
/// Get the number of groups
|
||||
int get groupCount => _groupedChapters?.length ?? 0;
|
||||
|
||||
/// Iterate all chapter ids
|
||||
Iterable<String> get ids sync* {
|
||||
if (isGrouped) {
|
||||
for (var entry in _groupedChapters!.values) {
|
||||
yield* entry.keys;
|
||||
}
|
||||
} else {
|
||||
yield* _chapters!.keys;
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate all chapter titles
|
||||
Iterable<String> get titles sync* {
|
||||
if (isGrouped) {
|
||||
for (var entry in _groupedChapters!.values) {
|
||||
yield* entry.values;
|
||||
}
|
||||
} else {
|
||||
yield* _chapters!.values;
|
||||
}
|
||||
}
|
||||
|
||||
String? operator [](String key) {
|
||||
if (isGrouped) {
|
||||
for (var entry in _groupedChapters!.values) {
|
||||
if (entry.containsKey(key)) return entry[key];
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return _chapters![key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PageJumpTarget {
|
||||
final String sourceKey;
|
||||
|
||||
final String page;
|
||||
|
||||
final Map<String, dynamic>? attributes;
|
||||
|
||||
const PageJumpTarget(this.sourceKey, this.page, this.attributes);
|
||||
|
||||
static PageJumpTarget parse(String sourceKey, dynamic value) {
|
||||
if (value is Map) {
|
||||
if (value['page'] != null) {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
value["page"] ?? "search",
|
||||
value["attributes"],
|
||||
);
|
||||
} else if (value["action"] != null) {
|
||||
// old version `onClickTag`
|
||||
var page = value["action"];
|
||||
if (page == "search") {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"search",
|
||||
{
|
||||
"text": value["keyword"],
|
||||
},
|
||||
);
|
||||
} else if (page == "category") {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"category",
|
||||
{
|
||||
"category": value["keyword"],
|
||||
"param": value["param"],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return PageJumpTarget(sourceKey, page, null);
|
||||
}
|
||||
}
|
||||
} else if (value is String) {
|
||||
// old version string encoding. search: `search:keyword`, category: `category:keyword` or `category:keyword@param`
|
||||
var segments = value.split(":");
|
||||
var page = segments[0];
|
||||
if (page == "search") {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"search",
|
||||
{
|
||||
"text": segments[1],
|
||||
},
|
||||
);
|
||||
} else if (page == "category") {
|
||||
var c = segments[1];
|
||||
if (c.contains('@')) {
|
||||
var parts = c.split('@');
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"category",
|
||||
{
|
||||
"category": parts[0],
|
||||
"param": parts[1],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return PageJumpTarget(
|
||||
sourceKey,
|
||||
"category",
|
||||
{
|
||||
"category": c,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return PageJumpTarget(sourceKey, page, null);
|
||||
}
|
||||
}
|
||||
return PageJumpTarget(sourceKey, "Invalid Data", null);
|
||||
}
|
||||
|
||||
void jump(BuildContext context) {
|
||||
if (page == "search") {
|
||||
context.to(
|
||||
() => SearchResultPage(
|
||||
text: attributes?["text"] ?? attributes?["keyword"] ?? "",
|
||||
sourceKey: sourceKey,
|
||||
options: List.from(attributes?["options"] ?? []),
|
||||
),
|
||||
);
|
||||
} else if (page == "category") {
|
||||
var key = ComicSource.find(sourceKey)!.categoryData!.key;
|
||||
context.to(
|
||||
() => CategoryComicsPage(
|
||||
categoryKey: key,
|
||||
category: attributes?["category"] ??
|
||||
(throw ArgumentError("Category name is required")),
|
||||
options: List.from(attributes?["options"] ?? []),
|
||||
param: attributes?["param"],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Log.error("Page Jump", "Unknown page: $page");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -80,9 +80,8 @@ class ComicSourceParser {
|
||||
|
||||
Future<ComicSource> parse(String js, String filePath) async {
|
||||
js = js.replaceAll("\r\n", "\n");
|
||||
var line1 = js
|
||||
.split('\n')
|
||||
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
|
||||
var line1 =
|
||||
js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class "));
|
||||
if (line1 == null ||
|
||||
!line1.startsWith("class ") ||
|
||||
!line1.contains("extends ComicSource")) {
|
||||
@@ -336,7 +335,7 @@ class ComicSourceParser {
|
||||
(e['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
e['viewMore'],
|
||||
PageJumpTarget.parse(_key!, e['viewMore']),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -404,21 +403,91 @@ class ComicSourceParser {
|
||||
var categoryParts = <BaseCategoryPart>[];
|
||||
|
||||
for (var c in doc["parts"]) {
|
||||
final String name = c["name"];
|
||||
final String type = c["type"];
|
||||
final List<String> tags = List.from(c["categories"]);
|
||||
final String itemType = c["itemType"];
|
||||
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
|
||||
final String? groupParam = c["groupParam"];
|
||||
if (groupParam != null) {
|
||||
categoryParams = List.filled(tags.length, groupParam);
|
||||
if (c["categories"] != null && c["categories"] is! List) {
|
||||
continue;
|
||||
}
|
||||
if (type == "fixed") {
|
||||
categoryParts
|
||||
.add(FixedCategoryPart(name, tags, itemType, categoryParams));
|
||||
} else if (type == "random") {
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, tags, c["randomNumber"] ?? 1, itemType));
|
||||
List? categories = c["categories"];
|
||||
if (categories == null || categories[0] is Map) {
|
||||
// new format
|
||||
final String name = c["name"];
|
||||
final String type = c["type"];
|
||||
final cs = categories
|
||||
?.map(
|
||||
(e) => CategoryItem(
|
||||
e['label'],
|
||||
PageJumpTarget.parse(_key!, e['target']),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
if (type != "dynamic" && (cs == null || cs.isEmpty)) {
|
||||
continue;
|
||||
}
|
||||
if (type == "fixed") {
|
||||
categoryParts.add(FixedCategoryPart(name, cs!));
|
||||
} else if (type == "random") {
|
||||
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!,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// old format
|
||||
final String name = c["name"];
|
||||
final String type = c["type"];
|
||||
final List<String> tags = List.from(c["categories"]);
|
||||
final String itemType = c["itemType"];
|
||||
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
|
||||
final String? groupParam = c["groupParam"];
|
||||
if (groupParam != null) {
|
||||
categoryParams = List.filled(tags.length, groupParam);
|
||||
}
|
||||
var cs = <CategoryItem>[];
|
||||
for (int i = 0; i < tags.length; i++) {
|
||||
PageJumpTarget target;
|
||||
if (itemType == 'category') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'category',
|
||||
{
|
||||
"category": tags[i],
|
||||
"param": categoryParams?.elementAtOrNull(i),
|
||||
},
|
||||
);
|
||||
} else if (itemType == 'search') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'search',
|
||||
{
|
||||
"keyword": tags[i],
|
||||
},
|
||||
);
|
||||
} else if (itemType == 'search_with_namespace') {
|
||||
target = PageJumpTarget(
|
||||
_key!,
|
||||
'search',
|
||||
{
|
||||
"keyword": "$name:$tags[i]",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
target = PageJumpTarget(_key!, itemType, null);
|
||||
}
|
||||
cs.add(CategoryItem(tags[i], target));
|
||||
}
|
||||
if (type == "fixed") {
|
||||
categoryParts.add(FixedCategoryPart(name, cs));
|
||||
} else if (type == "random") {
|
||||
categoryParts
|
||||
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +689,8 @@ 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) {
|
||||
@@ -978,9 +1048,12 @@ class ComicSourceParser {
|
||||
var res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.onClickTag(${jsonEncode(namespace)}, ${jsonEncode(tag)})
|
||||
""");
|
||||
var r = Map<String, String?>.from(res);
|
||||
if (res is! Map) {
|
||||
return null;
|
||||
}
|
||||
var r = Map<String, dynamic>.from(res);
|
||||
r.removeWhere((key, value) => value == null);
|
||||
return Map.from(r);
|
||||
return PageJumpTarget.parse(_key!, r);
|
||||
};
|
||||
}
|
||||
|
||||
|
48
lib/foundation/comic_source/types.dart
Normal file
48
lib/foundation/comic_source/types.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
part of 'comic_source.dart';
|
||||
|
||||
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
|
||||
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
|
||||
|
||||
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
|
||||
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
|
||||
String? next);
|
||||
|
||||
typedef LoginFunction = Future<Res<bool>> Function(String, String);
|
||||
|
||||
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
|
||||
|
||||
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
|
||||
String id, String? ep);
|
||||
|
||||
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
|
||||
String id, String? subId, int page, String? replyTo);
|
||||
|
||||
typedef SendCommentFunc = Future<Res<bool>> Function(
|
||||
String id, String? subId, String content, String? replyTo);
|
||||
|
||||
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
|
||||
String imageKey, String comicId, String epId)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
|
||||
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
|
||||
String comicId, String? next);
|
||||
|
||||
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
|
||||
String comicId, bool isLiking);
|
||||
|
||||
/// [isLiking] is true if the user is liking the comment, false if unliking.
|
||||
/// return the new likes count or null.
|
||||
typedef LikeCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isLiking);
|
||||
|
||||
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
|
||||
/// return the new vote count or null.
|
||||
typedef VoteCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
|
||||
|
||||
typedef HandleClickTagEvent = PageJumpTarget? Function(
|
||||
String namespace, String tag);
|
||||
|
||||
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
|
||||
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
@@ -185,6 +185,18 @@ class FavoriteItemWithUpdateInfo extends FavoriteItem {
|
||||
var sourceName = type.comicSource?.name ?? "Unknown";
|
||||
return "$updateTime | $sourceName";
|
||||
}
|
||||
|
||||
@override
|
||||
operator ==(Object other) {
|
||||
return other is FavoriteItemWithUpdateInfo &&
|
||||
other.updateTime == updateTime &&
|
||||
other.hasNewUpdate == hasNewUpdate &&
|
||||
super == other;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
super.hashCode ^ updateTime.hashCode ^ hasNewUpdate.hashCode;
|
||||
}
|
||||
|
||||
class LocalFavoritesManager with ChangeNotifier {
|
||||
@@ -212,7 +224,8 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
source_folder text
|
||||
);
|
||||
""");
|
||||
for (var folder in _getFolderNamesWithDB()) {
|
||||
var folderNames = _getFolderNamesWithDB();
|
||||
for (var folder in folderNames) {
|
||||
var columns = _db.select("""
|
||||
pragma table_info("$folder");
|
||||
""");
|
||||
@@ -221,7 +234,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
alter table "$folder"
|
||||
add column translated_tags TEXT;
|
||||
""");
|
||||
var comics = getAllComics(folder);
|
||||
var comics = getFolderComics(folder);
|
||||
for (var comic in comics) {
|
||||
var translatedTags = _translateTags(comic.tags);
|
||||
_db.execute("""
|
||||
@@ -234,6 +247,15 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
break;
|
||||
}
|
||||
}
|
||||
await appdata.ensureInit();
|
||||
// Make sure the follow updates folder is ready
|
||||
var followUpdateFolder = appdata.settings['followUpdatesFolder'];
|
||||
if (followUpdateFolder is String &&
|
||||
folderNames.contains(followUpdateFolder)) {
|
||||
prepareTableForFollowUpdates(followUpdateFolder, false);
|
||||
} else {
|
||||
appdata.settings['followUpdatesFolder'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> find(String id, ComicType type) {
|
||||
@@ -327,7 +349,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
""").firstOrNull?["min_value"] ?? 0;
|
||||
}
|
||||
|
||||
List<FavoriteItem> getAllComics(String folder) {
|
||||
List<FavoriteItem> getFolderComics(String folder) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
ORDER BY display_order;
|
||||
@@ -335,6 +357,17 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
|
||||
}
|
||||
|
||||
List<FavoriteItem> getAllComics() {
|
||||
var res = <FavoriteItem>{};
|
||||
for (final folder in folderNames) {
|
||||
var comics = _db.select("""
|
||||
select * from "$folder";
|
||||
""");
|
||||
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
|
||||
}
|
||||
return res.toList();
|
||||
}
|
||||
|
||||
void addTagTo(String folder, String id, String tag) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
@@ -714,10 +747,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return comics;
|
||||
}
|
||||
|
||||
List<FavoriteItemWithFolderInfo> search(String keyword) {
|
||||
List<FavoriteItem> search(String keyword) {
|
||||
var keywordList = keyword.split(" ");
|
||||
keyword = keywordList.first;
|
||||
var comics = <FavoriteItemWithFolderInfo>[];
|
||||
var comics = <FavoriteItem>{};
|
||||
for (var table in folderNames) {
|
||||
keyword = "%$keyword%";
|
||||
var res = _db.select("""
|
||||
@@ -725,15 +758,18 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
|
||||
""", [keyword, keyword, keyword, keyword]);
|
||||
for (var comic in res) {
|
||||
comics.add(
|
||||
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
|
||||
comics.add(FavoriteItem.fromRow(comic));
|
||||
}
|
||||
if (comics.length > 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool test(FavoriteItemWithFolderInfo comic, String keyword) {
|
||||
bool test(FavoriteItem comic, String keyword) {
|
||||
keyword = keyword.trim();
|
||||
if (keyword.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (comic.name.contains(keyword)) {
|
||||
return true;
|
||||
} else if (comic.author.contains(keyword)) {
|
||||
@@ -744,12 +780,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 1; i < keywordList.length; i++) {
|
||||
comics =
|
||||
comics.where((element) => test(element, keywordList[i])).toList();
|
||||
}
|
||||
|
||||
return comics;
|
||||
return comics.where((element) {
|
||||
for (var i = 1; i < keywordList.length; i++) {
|
||||
if (!test(element, keywordList[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void editTags(String id, String folder, List<String> tags) {
|
||||
@@ -785,7 +823,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void updateInfo(String folder, FavoriteItem comic) {
|
||||
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set name = ?, author = ?, cover_path = ?, tags = ?
|
||||
@@ -798,7 +836,9 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
comic.id,
|
||||
comic.type.value
|
||||
]);
|
||||
notifyListeners();
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String folderToJson(String folder) {
|
||||
@@ -835,7 +875,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void prepareTableForFollowUpdates(String table) {
|
||||
void prepareTableForFollowUpdates(String table, [bool clearData = true]) {
|
||||
// check if the table has the column "last_update_time" "has_new_update" "last_check_time"
|
||||
var columns = _db.select("""
|
||||
pragma table_info("$table");
|
||||
@@ -852,10 +892,12 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
add column has_new_update int;
|
||||
""");
|
||||
}
|
||||
_db.execute("""
|
||||
if (clearData) {
|
||||
_db.execute("""
|
||||
update "$table"
|
||||
set has_new_update = 0;
|
||||
""");
|
||||
}
|
||||
if (!columns.any((element) => element["name"] == "last_check_time")) {
|
||||
_db.execute("""
|
||||
alter table "$table"
|
||||
@@ -888,6 +930,18 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
]);
|
||||
}
|
||||
|
||||
void updateCheckTime(
|
||||
String folder,
|
||||
String id,
|
||||
ComicType type,
|
||||
) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set last_check_time = ?
|
||||
where id == ? and type == ?;
|
||||
""", [DateTime.now().millisecondsSinceEpoch, id, type.value]);
|
||||
}
|
||||
|
||||
int countUpdates(String folder) {
|
||||
return _db.select("""
|
||||
select count(*) as c from "$folder"
|
||||
@@ -949,4 +1003,8 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
void close() {
|
||||
_db.dispose();
|
||||
}
|
||||
|
||||
void notifyChanges() {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,6 @@ import 'dart:ffi' as ffi;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:sqlite3/common.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
@@ -51,17 +50,24 @@ class History implements Comic {
|
||||
@override
|
||||
String cover;
|
||||
|
||||
/// index of chapters. 1-based.
|
||||
int ep;
|
||||
|
||||
/// index of pages. 1-based.
|
||||
int page;
|
||||
|
||||
/// index of chapter groups. 1-based.
|
||||
/// If [group] is not null, [ep] is the index of chapter in the group.
|
||||
int? group;
|
||||
|
||||
@override
|
||||
String id;
|
||||
|
||||
/// readEpisode is a set of episode numbers that have been read.
|
||||
///
|
||||
/// The number of episodes is 1-based.
|
||||
Set<int> readEpisode;
|
||||
/// For normal chapters, it is a set of chapter numbers.
|
||||
/// For grouped chapters, it is a set of strings in the format of "group_number-chapter_number".
|
||||
/// 1-based.
|
||||
Set<String> readEpisode;
|
||||
|
||||
@override
|
||||
int? maxPage;
|
||||
@@ -70,29 +76,17 @@ class History implements Comic {
|
||||
{required HistoryMixin model,
|
||||
required this.ep,
|
||||
required this.page,
|
||||
Set<int>? readChapters,
|
||||
this.group,
|
||||
Set<String>? readChapters,
|
||||
DateTime? time})
|
||||
: type = model.historyType,
|
||||
title = model.title,
|
||||
subtitle = model.subTitle ?? '',
|
||||
cover = model.cover,
|
||||
id = model.id,
|
||||
readEpisode = readChapters ?? <int>{},
|
||||
readEpisode = readChapters ?? <String>{},
|
||||
time = time ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
"type": type.value,
|
||||
"time": time.millisecondsSinceEpoch,
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"cover": cover,
|
||||
"ep": ep,
|
||||
"page": page,
|
||||
"id": id,
|
||||
"readEpisode": readEpisode.toList(),
|
||||
"max_page": maxPage
|
||||
};
|
||||
|
||||
History.fromMap(Map<String, dynamic> map)
|
||||
: type = HistoryType(map["type"]),
|
||||
time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
|
||||
@@ -102,8 +96,9 @@ class History implements Comic {
|
||||
ep = map["ep"],
|
||||
page = map["page"],
|
||||
id = map["id"],
|
||||
readEpisode = Set<int>.from(
|
||||
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}),
|
||||
readEpisode = Set<String>.from(
|
||||
(map["readEpisode"] as List<dynamic>?)?.toSet() ??
|
||||
const <String>{}),
|
||||
maxPage = map["max_page"];
|
||||
|
||||
@override
|
||||
@@ -120,11 +115,11 @@ class History implements Comic {
|
||||
ep = row["ep"],
|
||||
page = row["page"],
|
||||
id = row["id"],
|
||||
readEpisode = Set<int>.from((row["readEpisode"] as String)
|
||||
readEpisode = Set<String>.from((row["readEpisode"] as String)
|
||||
.split(',')
|
||||
.where((element) => element != "")
|
||||
.map((e) => int.parse(e))),
|
||||
maxPage = row["max_page"];
|
||||
.where((element) => element != "")),
|
||||
maxPage = row["max_page"],
|
||||
group = row["chapter_group"];
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
@@ -213,18 +208,24 @@ class HistoryManager with ChangeNotifier {
|
||||
ep int,
|
||||
page int,
|
||||
readEpisode text,
|
||||
max_page int
|
||||
max_page int,
|
||||
chapter_group int
|
||||
);
|
||||
""");
|
||||
|
||||
var columns = _db.select("PRAGMA table_info(history);");
|
||||
if (!columns.any((element) => element["name"] == "chapter_group")) {
|
||||
_db.execute("alter table history add column chapter_group int;");
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
ImageFavoriteManager().init();
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
static const _insertHistorySql = """
|
||||
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
static const _insertHistorySql = """
|
||||
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page, chapter_group)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""";
|
||||
|
||||
static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
|
||||
@@ -240,7 +241,8 @@ class HistoryManager with ChangeNotifier {
|
||||
newItem.ep,
|
||||
newItem.page,
|
||||
newItem.readEpisode.join(','),
|
||||
newItem.maxPage
|
||||
newItem.maxPage,
|
||||
newItem.group
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -282,7 +284,8 @@ class HistoryManager with ChangeNotifier {
|
||||
newItem.ep,
|
||||
newItem.page,
|
||||
newItem.readEpisode.join(','),
|
||||
newItem.maxPage
|
||||
newItem.maxPage,
|
||||
newItem.group
|
||||
]);
|
||||
if (_cachedHistoryIds == null) {
|
||||
updateCache();
|
||||
@@ -319,7 +322,7 @@ class HistoryManager with ChangeNotifier {
|
||||
for (var element in res) {
|
||||
_cachedHistoryIds![element["id"] as String] = true;
|
||||
}
|
||||
for (var key in cachedHistories.keys) {
|
||||
for (var key in cachedHistories.keys.toList()) {
|
||||
if (!_cachedHistoryIds!.containsKey(key)) {
|
||||
cachedHistories.remove(key);
|
||||
}
|
||||
|
@@ -97,7 +97,7 @@ class ImageFavoritesProvider
|
||||
if (localComic == null) {
|
||||
return null;
|
||||
}
|
||||
var epIndex = localComic.chapters?.keys.toList().indexOf(eid) ?? -1;
|
||||
var epIndex = localComic.chapters?.ids.toList().indexOf(eid) ?? -1;
|
||||
if (epIndex == -1 && localComic.hasChapters) {
|
||||
return null;
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/foundation.dart' show protected;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
@@ -24,6 +25,8 @@ import 'package:venera/components/js_ui.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/network/proxy.dart';
|
||||
import 'package:venera/utils/init.dart';
|
||||
|
||||
import 'comic_source/comic_source.dart';
|
||||
import 'consts.dart';
|
||||
@@ -40,7 +43,7 @@ class JavaScriptRuntimeException implements Exception {
|
||||
}
|
||||
}
|
||||
|
||||
class JsEngine with _JSEngineApi, JsUiApi {
|
||||
class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
|
||||
|
||||
static JsEngine? _cache;
|
||||
@@ -64,7 +67,9 @@ class JsEngine with _JSEngineApi, JsUiApi {
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
@override
|
||||
@protected
|
||||
Future<void> doInit() async {
|
||||
if (!_closed) {
|
||||
return;
|
||||
}
|
||||
@@ -159,6 +164,13 @@ class JsEngine with _JSEngineApi, JsUiApi {
|
||||
return "${App.locale.languageCode}_${App.locale.countryCode}";
|
||||
case "getPlatform":
|
||||
return Platform.operatingSystem;
|
||||
case "setClipboard":
|
||||
return Clipboard.setData(ClipboardData(text: message["text"]));
|
||||
case "getClipboard":
|
||||
return Future.sync(() async {
|
||||
var res = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
return res?.text;
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -183,7 +195,7 @@ class JsEngine with _JSEngineApi, JsUiApi {
|
||||
responseType: ResponseType.plain,
|
||||
validateStatus: (status) => true,
|
||||
));
|
||||
var proxy = await AppDio.getProxy();
|
||||
var proxy = await getProxy();
|
||||
dio.httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
return HttpClient()
|
||||
|
@@ -9,7 +9,6 @@ import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
import 'app.dart';
|
||||
@@ -34,7 +33,7 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
/// key: chapter id, value: chapter title
|
||||
///
|
||||
/// chapter id is the name of the directory in `LocalManager.path/$directory`
|
||||
final Map<String, String>? chapters;
|
||||
final ComicChapters? chapters;
|
||||
|
||||
bool get hasChapters => chapters != null;
|
||||
|
||||
@@ -67,7 +66,7 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
subtitle = row[2] as String,
|
||||
tags = List.from(jsonDecode(row[3] as String)),
|
||||
directory = row[4] as String,
|
||||
chapters = MapOrNull.from(jsonDecode(row[5] as String)),
|
||||
chapters = ComicChapters.fromJsonOrNull(jsonDecode(row[5] as String)),
|
||||
cover = row[6] as String,
|
||||
comicType = ComicType(row[7] as int),
|
||||
downloadedChapters = List.from(jsonDecode(row[8] as String)),
|
||||
@@ -99,6 +98,7 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"sourceKey": sourceKey,
|
||||
"chapters": chapters?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
chapters: chapters,
|
||||
initialChapter: history?.ep,
|
||||
initialPage: history?.page,
|
||||
initialChapterGroup: history?.group,
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: this,
|
||||
@@ -264,6 +265,7 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
_checkPathValidation();
|
||||
_checkNoMedia();
|
||||
await ComicSourceManager().ensureInit();
|
||||
restoreDownloadingTasks();
|
||||
}
|
||||
|
||||
@@ -391,7 +393,7 @@ class LocalManager with ChangeNotifier {
|
||||
var directory = Directory(comic.baseDir);
|
||||
if (comic.hasChapters) {
|
||||
var cid =
|
||||
ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
|
||||
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
|
||||
directory = Directory(FilePath.join(directory.path, cid));
|
||||
}
|
||||
var files = <File>[];
|
||||
@@ -420,12 +422,30 @@ class LocalManager with ChangeNotifier {
|
||||
return files.map((e) => "file://${e.path}").toList();
|
||||
}
|
||||
|
||||
bool isDownloaded(String id, ComicType type, [int? ep]) {
|
||||
bool isDownloaded(String id, ComicType type,
|
||||
[int? ep, ComicChapters? chapters]) {
|
||||
var comic = find(id, type);
|
||||
if (comic == null) return false;
|
||||
if (comic.chapters == null || ep == null) return true;
|
||||
if (chapters != null) {
|
||||
if (comic.chapters?.length != chapters.length) {
|
||||
// update
|
||||
add(LocalComic(
|
||||
id: comic.id,
|
||||
title: comic.title,
|
||||
subtitle: comic.subtitle,
|
||||
tags: comic.tags,
|
||||
directory: comic.directory,
|
||||
chapters: chapters,
|
||||
cover: comic.cover,
|
||||
comicType: comic.comicType,
|
||||
downloadedChapters: comic.downloadedChapters,
|
||||
createdAt: comic.createdAt,
|
||||
));
|
||||
}
|
||||
}
|
||||
return comic.downloadedChapters
|
||||
.contains(comic.chapters!.keys.elementAt(ep - 1));
|
||||
.contains((chapters ?? comic.chapters)!.ids.elementAtOrNull(ep - 1));
|
||||
}
|
||||
|
||||
List<DownloadTask> downloadingTasks = [];
|
||||
@@ -441,6 +461,10 @@ class LocalManager with ChangeNotifier {
|
||||
if (comic != null) {
|
||||
return Directory(FilePath.join(path, comic.directory));
|
||||
}
|
||||
const comicDirectoryMaxLength = 128;
|
||||
if (name.length > comicDirectoryMaxLength) {
|
||||
name = name.substring(0, comicDirectoryMaxLength);
|
||||
}
|
||||
var dir = findValidDirectoryName(path, name);
|
||||
return Directory(FilePath.join(path, dir)).create().then((value) => value);
|
||||
}
|
||||
@@ -509,7 +533,7 @@ class LocalManager with ChangeNotifier {
|
||||
var dir = Directory(FilePath.join(path, c.directory));
|
||||
dir.deleteIgnoreError(recursive: true);
|
||||
}
|
||||
// Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
|
||||
// Deleting a local comic means that it's no longer available, thus both favorite and history should be deleted.
|
||||
if (c.comicType == ComicType.local) {
|
||||
if (HistoryManager().find(c.id, c.comicType) != null) {
|
||||
HistoryManager().remove(c.id, c.comicType);
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
class LogItem {
|
||||
final LogLevel level;
|
||||
@@ -28,9 +28,6 @@ class Log {
|
||||
|
||||
static bool ignoreLimitation = false;
|
||||
|
||||
/// only for debug
|
||||
static const String? logFile = null;
|
||||
|
||||
static void printWarning(String text) {
|
||||
debugPrint('\x1B[33m$text\x1B[0m');
|
||||
}
|
||||
@@ -39,7 +36,20 @@ class Log {
|
||||
debugPrint('\x1B[31m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static IOSink? _file;
|
||||
|
||||
static void addLog(LogLevel level, String title, String content) {
|
||||
if (_file == null) {
|
||||
Directory dir;
|
||||
if (App.isAndroid) {
|
||||
dir = Directory(App.externalStoragePath!);
|
||||
} else {
|
||||
dir = Directory(App.dataPath);
|
||||
}
|
||||
var file = dir.joinFile("logs.txt");
|
||||
_file = file.openWrite();
|
||||
}
|
||||
|
||||
if (!ignoreLimitation && content.length > maxLogLength) {
|
||||
content = "${content.substring(0, maxLogLength)}...";
|
||||
}
|
||||
@@ -62,8 +72,8 @@ class Log {
|
||||
}
|
||||
|
||||
_logs.add(newLog);
|
||||
if(logFile != null) {
|
||||
File(logFile!).writeAsString(newLog.toString(), mode: FileMode.append);
|
||||
if(_file != null) {
|
||||
_file!.write(newLog.toString());
|
||||
}
|
||||
if (_logs.length > maxLogNumber) {
|
||||
var res = _logs.remove(
|
||||
|
@@ -1,19 +1,20 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:rhttp/rhttp.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/js_engine.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/pages/comic_source_page.dart';
|
||||
import 'package:venera/pages/follow_updates_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/handle_text_share.dart';
|
||||
import 'package:venera/utils/tags_translation.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'foundation/appdata.dart';
|
||||
@@ -32,31 +33,56 @@ extension _FutureInit<T> on Future<T> {
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await Rhttp.init();
|
||||
await SAFTaskWorker().init().wait();
|
||||
await AppTranslation.init().wait();
|
||||
await appdata.init().wait();
|
||||
await App.init().wait();
|
||||
await HistoryManager().init().wait();
|
||||
await TagsTranslation.readData().wait();
|
||||
await LocalFavoritesManager().init().wait();
|
||||
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
|
||||
await JsEngine().init().wait();
|
||||
await ComicSource.init().wait();
|
||||
await LocalManager().init().wait();
|
||||
await SingleInstanceCookieJar.createInstance();
|
||||
var futures = [
|
||||
Rhttp.init(),
|
||||
App.initComponents(),
|
||||
SAFTaskWorker().init().wait(),
|
||||
AppTranslation.init().wait(),
|
||||
TagsTranslation.readData().wait(),
|
||||
JsEngine().init().wait(),
|
||||
ComicSourceManager().init().wait(),
|
||||
];
|
||||
await Future.wait(futures);
|
||||
CacheManager().setLimitSize(appdata.settings['cacheSize']);
|
||||
_checkOldConfigs();
|
||||
if (App.isAndroid) {
|
||||
handleLinks();
|
||||
handleTextShare();
|
||||
}
|
||||
FlutterError.onError = (details) {
|
||||
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
};
|
||||
if (App.isWindows) {
|
||||
// Report to the monitor thread that the app is running
|
||||
// https://github.com/venera-app/venera/issues/343
|
||||
Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
const methodChannel = MethodChannel('venera/method_channel');
|
||||
methodChannel.invokeMethod("heartBeat");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _checkOldConfigs() {
|
||||
if (appdata.settings['searchSources'] == null) {
|
||||
appdata.settings['searchSources'] = ComicSource.all()
|
||||
.where((e) => e.searchPageData != null)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
}
|
||||
if (App.isAndroid) {
|
||||
handleLinks();
|
||||
|
||||
if (appdata.implicitData['webdavAutoSync'] == null) {
|
||||
var webdavConfig = appdata.settings['webdav'];
|
||||
if (webdavConfig is List &&
|
||||
webdavConfig.length == 3 &&
|
||||
webdavConfig.whereType<String>().length == 3) {
|
||||
appdata.implicitData['webdavAutoSync'] = true;
|
||||
} else {
|
||||
appdata.implicitData['webdavAutoSync'] = false;
|
||||
}
|
||||
appdata.writeImplicitData();
|
||||
}
|
||||
FlutterError.onError = (details) {
|
||||
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _checkAppUpdates() async {
|
||||
|
@@ -34,13 +34,16 @@ void main(List<String> args) {
|
||||
await windowManager.setBackgroundColor(Colors.transparent);
|
||||
}
|
||||
await windowManager.setMinimumSize(const Size(500, 600));
|
||||
if (!App.isLinux) {
|
||||
// https://github.com/leanflutter/window_manager/issues/460
|
||||
var placement = await WindowPlacement.loadFromFile();
|
||||
var placement = await WindowPlacement.loadFromFile();
|
||||
if (App.isLinux) {
|
||||
await windowManager.show();
|
||||
await placement.applyToWindow();
|
||||
} else {
|
||||
await placement.applyToWindow();
|
||||
await windowManager.show();
|
||||
WindowPlacement.loop();
|
||||
}
|
||||
|
||||
WindowPlacement.loop();
|
||||
});
|
||||
}
|
||||
}, (error, stack) {
|
||||
@@ -141,13 +144,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
) {
|
||||
String? font;
|
||||
List<String>? fallback;
|
||||
if (App.isWindows) {
|
||||
font = 'Segoe UI';
|
||||
if (App.isLinux || App.isWindows) {
|
||||
font = 'Noto Sans CJK';
|
||||
fallback = [
|
||||
'Segoe UI',
|
||||
'Noto Sans SC',
|
||||
'Noto Sans TC',
|
||||
'Noto Sans',
|
||||
'Microsoft YaHei',
|
||||
'PingFang SC',
|
||||
'Noto Sans CJK',
|
||||
'Arial',
|
||||
'sans-serif'
|
||||
];
|
||||
@@ -199,6 +204,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
'dark' => ThemeMode.dark,
|
||||
_ => ThemeMode.system
|
||||
},
|
||||
color: Colors.transparent,
|
||||
localizationsDelegates: [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
@@ -246,6 +252,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
return _SystemUiProvider(Material(
|
||||
color: App.isLinux ? Colors.transparent : null,
|
||||
child: widget,
|
||||
));
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ import 'package:rhttp/rhttp.dart' as rhttp;
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/network/cache.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/network/proxy.dart';
|
||||
|
||||
import '../foundation/app.dart';
|
||||
import 'cloudflare.dart';
|
||||
@@ -96,9 +96,11 @@ class MyLogInterceptor implements Interceptor {
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
Log.info("Network", "${options.method} ${options.uri}\n"
|
||||
"headers:\n${options.headers}\n"
|
||||
"data:\n${options.data}");
|
||||
Log.info(
|
||||
"Network",
|
||||
"${options.method} ${options.uri}\n"
|
||||
"headers:\n${options.headers}\n"
|
||||
"data:\n${options.data}");
|
||||
options.connectTimeout = const Duration(seconds: 15);
|
||||
options.receiveTimeout = const Duration(seconds: 15);
|
||||
options.sendTimeout = const Duration(seconds: 15);
|
||||
@@ -107,64 +109,15 @@ class MyLogInterceptor implements Interceptor {
|
||||
}
|
||||
|
||||
class AppDio with DioMixin {
|
||||
String? _proxy = proxy;
|
||||
|
||||
AppDio([BaseOptions? options]) {
|
||||
this.options = options ?? BaseOptions();
|
||||
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
|
||||
proxySettings: proxy == null
|
||||
? const rhttp.ProxySettings.noProxy()
|
||||
: rhttp.ProxySettings.proxy(proxy!),
|
||||
));
|
||||
httpClientAdapter = RHttpAdapter();
|
||||
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
interceptors.add(NetworkCacheManager());
|
||||
interceptors.add(CloudflareInterceptor());
|
||||
interceptors.add(MyLogInterceptor());
|
||||
}
|
||||
|
||||
static String? proxy;
|
||||
|
||||
static Future<String?> getProxy() async {
|
||||
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
|
||||
return null;
|
||||
}
|
||||
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
|
||||
|
||||
String res;
|
||||
if (!App.isLinux) {
|
||||
const channel = MethodChannel("venera/method_channel");
|
||||
try {
|
||||
res = await channel.invokeMethod("getProxy");
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
res = "No Proxy";
|
||||
}
|
||||
if (res == "No Proxy") return null;
|
||||
|
||||
if (res.contains(";")) {
|
||||
var proxies = res.split(";");
|
||||
for (String proxy in proxies) {
|
||||
proxy = proxy.removeAllBlank;
|
||||
if (proxy.startsWith('https=')) {
|
||||
return proxy.substring(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final RegExp regex = RegExp(
|
||||
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
|
||||
caseSensitive: false,
|
||||
multiLine: false,
|
||||
);
|
||||
if (!regex.hasMatch(res)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
static final Map<String, bool> _requests = {};
|
||||
|
||||
@override
|
||||
@@ -184,16 +137,6 @@ class AppDio with DioMixin {
|
||||
_requests[path] = true;
|
||||
options!.headers!.remove('prevent-parallel');
|
||||
}
|
||||
proxy = await getProxy();
|
||||
if (_proxy != proxy) {
|
||||
Log.info("Network", "Proxy changed to $proxy");
|
||||
_proxy = proxy;
|
||||
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
|
||||
proxySettings: proxy == null
|
||||
? const rhttp.ProxySettings.noProxy()
|
||||
: rhttp.ProxySettings.proxy(proxy!),
|
||||
));
|
||||
}
|
||||
try {
|
||||
return super.request<T>(
|
||||
path,
|
||||
@@ -213,7 +156,26 @@ class AppDio with DioMixin {
|
||||
}
|
||||
|
||||
class RHttpAdapter implements HttpClientAdapter {
|
||||
rhttp.ClientSettings settings;
|
||||
Future<rhttp.ClientSettings> get settings async {
|
||||
var proxy = await getProxy();
|
||||
|
||||
return rhttp.ClientSettings(
|
||||
proxySettings: proxy == null
|
||||
? const rhttp.ProxySettings.noProxy()
|
||||
: rhttp.ProxySettings.proxy(proxy),
|
||||
redirectSettings: const rhttp.RedirectSettings.limited(5),
|
||||
timeoutSettings: const rhttp.TimeoutSettings(
|
||||
connectTimeout: Duration(seconds: 15),
|
||||
keepAliveTimeout: Duration(seconds: 60),
|
||||
keepAlivePing: Duration(seconds: 30),
|
||||
),
|
||||
throwOnStatusCode: false,
|
||||
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
|
||||
tlsSettings: rhttp.TlsSettings(
|
||||
sni: appdata.settings['sni'] != false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, List<String>> _getOverrides() {
|
||||
if (!appdata.settings['enableDnsOverrides'] == true) {
|
||||
@@ -231,22 +193,6 @@ class RHttpAdapter implements HttpClientAdapter {
|
||||
return result;
|
||||
}
|
||||
|
||||
RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
|
||||
settings = settings.copyWith(
|
||||
redirectSettings: const rhttp.RedirectSettings.limited(5),
|
||||
timeoutSettings: const rhttp.TimeoutSettings(
|
||||
connectTimeout: Duration(seconds: 15),
|
||||
keepAliveTimeout: Duration(seconds: 60),
|
||||
keepAlivePing: Duration(seconds: 30),
|
||||
),
|
||||
throwOnStatusCode: false,
|
||||
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
|
||||
tlsSettings: rhttp.TlsSettings(
|
||||
sni: appdata.settings['sni'] != false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void close({bool force = false}) {}
|
||||
|
||||
@@ -256,21 +202,15 @@ class RHttpAdapter implements HttpClientAdapter {
|
||||
Stream<Uint8List>? requestStream,
|
||||
Future<void>? cancelFuture,
|
||||
) async {
|
||||
if (options.headers['User-Agent'] == null &&
|
||||
options.headers['user-agent'] == null) {
|
||||
options.headers['User-Agent'] = "venera/v${App.version}";
|
||||
}
|
||||
|
||||
var res = await rhttp.Rhttp.request(
|
||||
method: switch (options.method) {
|
||||
'GET' => rhttp.HttpMethod.get,
|
||||
'POST' => rhttp.HttpMethod.post,
|
||||
'PUT' => rhttp.HttpMethod.put,
|
||||
'PATCH' => rhttp.HttpMethod.patch,
|
||||
'DELETE' => rhttp.HttpMethod.delete,
|
||||
'HEAD' => rhttp.HttpMethod.head,
|
||||
'OPTIONS' => rhttp.HttpMethod.options,
|
||||
'TRACE' => rhttp.HttpMethod.trace,
|
||||
'CONNECT' => rhttp.HttpMethod.connect,
|
||||
_ => throw ArgumentError('Unsupported method: ${options.method}'),
|
||||
},
|
||||
method: rhttp.HttpMethod(options.method),
|
||||
url: options.uri.toString(),
|
||||
settings: settings,
|
||||
settings: await settings,
|
||||
expectBody: rhttp.HttpExpectBody.stream,
|
||||
body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream),
|
||||
headers: rhttp.HttpHeaders.rawMap(
|
||||
@@ -293,9 +233,29 @@ class RHttpAdapter implements HttpClientAdapter {
|
||||
return ResponseBody(
|
||||
res.body,
|
||||
res.statusCode,
|
||||
statusMessage: null,
|
||||
statusMessage: _getStatusMessage(res.statusCode),
|
||||
isRedirect: false,
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
|
||||
static String _getStatusMessage(int statusCode) {
|
||||
return switch (statusCode) {
|
||||
200 => "OK",
|
||||
201 => "Created",
|
||||
202 => "Accepted",
|
||||
204 => "No Content",
|
||||
206 => "Partial Content",
|
||||
301 => "Moved Permanently",
|
||||
302 => "Found",
|
||||
400 => "Invalid Status Code 400: The Request is invalid.",
|
||||
401 => "Invalid Status Code 401: The Request is unauthorized.",
|
||||
403 =>
|
||||
"Invalid Status Code 403: No permission to access the resource. Check your account or network.",
|
||||
404 => "Invalid Status Code 404: Not found.",
|
||||
429 =>
|
||||
"Invalid Status Code 429: Too many requests. Please try again later.",
|
||||
_ => "Invalid Status Code $statusCode",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ import 'cookie_jar.dart';
|
||||
class CloudflareException implements DioException {
|
||||
final String url;
|
||||
|
||||
const CloudflareException(this.url);
|
||||
CloudflareException(this.url);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -55,6 +55,9 @@ class CloudflareException implements DioException {
|
||||
|
||||
@override
|
||||
DioExceptionType get type => DioExceptionType.badResponse;
|
||||
|
||||
@override
|
||||
DioExceptionReadableStringBuilder? stringBuilder;
|
||||
}
|
||||
|
||||
class CloudflareInterceptor extends Interceptor {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
@@ -200,6 +201,11 @@ class SingleInstanceCookieJar extends CookieJarSql {
|
||||
SingleInstanceCookieJar._create(super.path);
|
||||
|
||||
static SingleInstanceCookieJar? instance;
|
||||
|
||||
static Future<void> createInstance() async {
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
|
||||
}
|
||||
}
|
||||
|
||||
class CookieManagerSql extends Interceptor {
|
||||
|
@@ -2,6 +2,8 @@ import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
@@ -328,8 +330,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
_images = {};
|
||||
_totalCount = 0;
|
||||
int cpCount = 0;
|
||||
int totalCpCount = chapters?.length ?? comic!.chapters!.length;
|
||||
for (var i in comic!.chapters!.keys) {
|
||||
int totalCpCount =
|
||||
chapters?.length ?? comic!.chapters!.allChapters.length;
|
||||
for (var i in comic!.chapters!.allChapters.keys) {
|
||||
if (chapters != null && !chapters!.contains(i)) {
|
||||
continue;
|
||||
}
|
||||
@@ -422,7 +425,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
"comic": comic?.toJson(),
|
||||
"chapters": chapters,
|
||||
"path": path,
|
||||
"cover": cover,
|
||||
"cover": _cover,
|
||||
"images": _images,
|
||||
"downloadedCount": _downloadedCount,
|
||||
"totalCount": _totalCount,
|
||||
@@ -479,7 +482,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
chapters: comic!.chapters,
|
||||
cover: File(_cover!.split("file://").last).name,
|
||||
comicType: ComicType(source.key.hashCode),
|
||||
downloadedChapters: chapters ?? [],
|
||||
downloadedChapters: chapters ?? comic?.chapters?.ids.toList() ?? [],
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
@@ -738,11 +741,12 @@ class ArchiveDownloadTask extends DownloadTask {
|
||||
path = dir.path;
|
||||
}
|
||||
|
||||
var resultFile = File(FilePath.join(path!, "archive.zip"));
|
||||
var archiveFile =
|
||||
File(FilePath.join(App.dataPath, "archive_downloading.zip"));
|
||||
|
||||
Log.info("Download", "Downloading $archiveUrl");
|
||||
|
||||
_downloader = FileDownloader(archiveUrl, resultFile.path);
|
||||
_downloader = FileDownloader(archiveUrl, archiveFile.path);
|
||||
|
||||
bool isDownloaded = false;
|
||||
|
||||
@@ -771,22 +775,33 @@ class ArchiveDownloadTask extends DownloadTask {
|
||||
}
|
||||
|
||||
try {
|
||||
await extractArchive(path!);
|
||||
await _extractArchive(archiveFile.path, path!);
|
||||
} catch (e) {
|
||||
_setError("Failed to extract archive: $e");
|
||||
return;
|
||||
}
|
||||
|
||||
await resultFile.deleteIgnoreError();
|
||||
await archiveFile.deleteIgnoreError();
|
||||
|
||||
LocalManager().completeTask(this);
|
||||
}
|
||||
|
||||
static Future<void> extractArchive(String path) async {
|
||||
var resultFile = FilePath.join(path, "archive.zip");
|
||||
await Isolate.run(() {
|
||||
ZipFile.openAndExtract(resultFile, path);
|
||||
});
|
||||
static Future<void> _extractArchive(String archive, String outDir) async {
|
||||
var out = Directory(outDir);
|
||||
if (out is AndroidDirectory) {
|
||||
// Saf directory can't be accessed by native code.
|
||||
var cacheDir = FilePath.join(App.cachePath, "archive_downloading");
|
||||
Directory(cacheDir).forceCreateSync();
|
||||
await Isolate.run(() {
|
||||
ZipFile.openAndExtract(archive, cacheDir);
|
||||
});
|
||||
await copyDirectoryIsolate(Directory(cacheDir), Directory(outDir));
|
||||
await Directory(cacheDir).deleteIgnoreError(recursive: true);
|
||||
} else {
|
||||
await Isolate.run(() {
|
||||
ZipFile.openAndExtract(archive, outDir);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/proxy.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
class FileDownloader {
|
||||
@@ -105,7 +106,7 @@ class FileDownloader {
|
||||
|
||||
void _download(StreamController<DownloadingStatus> resultStream) async {
|
||||
try {
|
||||
var proxy = await AppDio.getProxy();
|
||||
var proxy = await getProxy();
|
||||
_dio.httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
return HttpClient()
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
@@ -8,7 +9,7 @@ import 'package:venera/utils/image.dart';
|
||||
|
||||
import 'app_dio.dart';
|
||||
|
||||
class ImageDownloader {
|
||||
abstract class ImageDownloader {
|
||||
static Stream<ImageDownloadProgress> loadThumbnail(
|
||||
String url, String? sourceKey,
|
||||
[String? cid]) async* {
|
||||
@@ -82,7 +83,35 @@ class ImageDownloader {
|
||||
);
|
||||
}
|
||||
|
||||
static final _loadingImages = <String, _StreamWrapper<ImageDownloadProgress>>{};
|
||||
|
||||
/// Cancel all loading images.
|
||||
static void cancelAllLoadingImages() {
|
||||
for (var wrapper in _loadingImages.values) {
|
||||
wrapper.cancel();
|
||||
}
|
||||
_loadingImages.clear();
|
||||
}
|
||||
|
||||
/// Load a comic image from the network or cache.
|
||||
/// The function will prevent multiple requests for the same image.
|
||||
static Stream<ImageDownloadProgress> loadComicImage(
|
||||
String imageKey, String? sourceKey, String cid, String eid) {
|
||||
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
||||
if (_loadingImages.containsKey(cacheKey)) {
|
||||
return _loadingImages[cacheKey]!.stream;
|
||||
}
|
||||
final stream = _StreamWrapper<ImageDownloadProgress>(
|
||||
_loadComicImage(imageKey, sourceKey, cid, eid),
|
||||
(wrapper) {
|
||||
_loadingImages.remove(cacheKey);
|
||||
},
|
||||
);
|
||||
_loadingImages[cacheKey] = stream;
|
||||
return stream.stream;
|
||||
}
|
||||
|
||||
static Stream<ImageDownloadProgress> _loadComicImage(
|
||||
String imageKey, String? sourceKey, String cid, String eid) async* {
|
||||
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
||||
final cache = await CacheManager().findCache(cacheKey);
|
||||
@@ -139,12 +168,10 @@ class ImageDownloader {
|
||||
var buffer = <int>[];
|
||||
await for (var data in stream) {
|
||||
buffer.addAll(data);
|
||||
if (expectedBytes != null) {
|
||||
yield ImageDownloadProgress(
|
||||
currentBytes: buffer.length,
|
||||
totalBytes: expectedBytes,
|
||||
);
|
||||
}
|
||||
yield ImageDownloadProgress(
|
||||
currentBytes: buffer.length,
|
||||
totalBytes: expectedBytes,
|
||||
);
|
||||
}
|
||||
|
||||
if (configs['onResponse'] is JSInvokable) {
|
||||
@@ -191,10 +218,67 @@ class ImageDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper class for a stream that
|
||||
/// allows multiple listeners to listen to the same stream.
|
||||
class _StreamWrapper<T> {
|
||||
final Stream<T> _stream;
|
||||
|
||||
final List<StreamController> controllers = [];
|
||||
|
||||
final void Function(_StreamWrapper<T> wrapper) onClosed;
|
||||
|
||||
bool isClosed = false;
|
||||
|
||||
_StreamWrapper(this._stream, this.onClosed) {
|
||||
_listen();
|
||||
}
|
||||
|
||||
void _listen() async {
|
||||
await for (var data in _stream) {
|
||||
if (isClosed) {
|
||||
break;
|
||||
}
|
||||
for (var controller in controllers) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (var controller in controllers) {
|
||||
if (!controller.isClosed) {
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
controllers.clear();
|
||||
isClosed = true;
|
||||
onClosed(this);
|
||||
}
|
||||
|
||||
Stream<T> get stream {
|
||||
if (isClosed) {
|
||||
throw Exception('Stream is closed');
|
||||
}
|
||||
var controller = StreamController<T>();
|
||||
controllers.add(controller);
|
||||
controller.onCancel = () {
|
||||
controllers.remove(controller);
|
||||
};
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
for (var controller in controllers) {
|
||||
controller.close();
|
||||
}
|
||||
controllers.clear();
|
||||
isClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class ImageDownloadProgress {
|
||||
final int currentBytes;
|
||||
|
||||
final int totalBytes;
|
||||
final int? totalBytes;
|
||||
|
||||
final Uint8List? imageBytes;
|
||||
|
||||
|
60
lib/network/proxy.dart
Normal file
60
lib/network/proxy.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
String? _cachedProxy;
|
||||
|
||||
DateTime? _cachedProxyTime;
|
||||
|
||||
Future<String?> getProxy() async {
|
||||
if (_cachedProxyTime != null &&
|
||||
DateTime.now().difference(_cachedProxyTime!).inSeconds < 1) {
|
||||
return _cachedProxy;
|
||||
}
|
||||
String? proxy = await _getProxy();
|
||||
_cachedProxy = proxy;
|
||||
_cachedProxyTime = DateTime.now();
|
||||
return proxy;
|
||||
}
|
||||
|
||||
Future<String?> _getProxy() async {
|
||||
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
|
||||
return null;
|
||||
}
|
||||
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
|
||||
|
||||
String res;
|
||||
if (!App.isLinux) {
|
||||
const channel = MethodChannel("venera/method_channel");
|
||||
try {
|
||||
res = await channel.invokeMethod("getProxy");
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
res = "No Proxy";
|
||||
}
|
||||
if (res == "No Proxy") return null;
|
||||
|
||||
if (res.contains(";")) {
|
||||
var proxies = res.split(";");
|
||||
for (String proxy in proxies) {
|
||||
proxy = proxy.removeAllBlank;
|
||||
if (proxy.startsWith('https=')) {
|
||||
return proxy.substring(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final RegExp regex = RegExp(
|
||||
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
|
||||
caseSensitive: false,
|
||||
multiLine: false,
|
||||
);
|
||||
if (!regex.hasMatch(res)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
@@ -90,7 +90,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
bool isLoading = true;
|
||||
|
||||
static const _kComicHeight = 132.0;
|
||||
static const _kComicHeight = 162.0;
|
||||
|
||||
get _comicWidth => _kComicHeight * 0.7;
|
||||
|
||||
@@ -152,7 +152,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
|
||||
}
|
||||
|
||||
Widget buildComic(Comic c) {
|
||||
return SimpleComicTile(comic: c)
|
||||
return SimpleComicTile(comic: c, withTitle: true)
|
||||
.paddingLeft(_kLeftPadding)
|
||||
.paddingBottom(2);
|
||||
}
|
||||
|
@@ -4,12 +4,10 @@ import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/pages/ranking_page.dart';
|
||||
import 'package:venera/pages/search_result_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'category_comics_page.dart';
|
||||
import 'comic_source_page.dart';
|
||||
|
||||
class CategoriesPage extends StatefulWidget {
|
||||
@@ -147,43 +145,6 @@ class _CategoryPage extends StatelessWidget {
|
||||
return "";
|
||||
}
|
||||
|
||||
void handleClick(
|
||||
String tag,
|
||||
String? param,
|
||||
String type,
|
||||
String namespace,
|
||||
String categoryKey,
|
||||
) {
|
||||
if (type == 'search') {
|
||||
App.mainNavigatorKey?.currentContext?.to(
|
||||
() => SearchResultPage(
|
||||
text: tag,
|
||||
options: const [],
|
||||
sourceKey: findComicSourceKey(),
|
||||
),
|
||||
);
|
||||
} else if (type == "search_with_namespace") {
|
||||
if (tag.contains(" ")) {
|
||||
tag = '"$tag"';
|
||||
}
|
||||
App.mainNavigatorKey?.currentContext?.to(
|
||||
() => SearchResultPage(
|
||||
text: "$namespace:$tag",
|
||||
options: const [],
|
||||
sourceKey: findComicSourceKey(),
|
||||
),
|
||||
);
|
||||
} else if (type == "category") {
|
||||
App.mainNavigatorKey!.currentContext!.to(
|
||||
() => CategoryComicsPage(
|
||||
category: tag,
|
||||
categoryKey: categoryKey,
|
||||
param: param,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var children = <Widget>[];
|
||||
@@ -194,11 +155,11 @@ class _CategoryPage extends StatelessWidget {
|
||||
child: Wrap(
|
||||
children: [
|
||||
if (data.enableRankingPage)
|
||||
buildTag("Ranking".tl, (p0, p1) {
|
||||
buildTag("Ranking".tl, () {
|
||||
context.to(() => RankingPage(categoryKey: data.key));
|
||||
}),
|
||||
for (var buttonData in data.buttons)
|
||||
buildTag(buttonData.label.tl, (p0, p1) => buttonData.onTap())
|
||||
buildTag(buttonData.label.tl, buttonData.onTap)
|
||||
],
|
||||
),
|
||||
));
|
||||
@@ -212,36 +173,14 @@ class _CategoryPage extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitleWithRefresh(part.title, () => updater(() {})),
|
||||
buildTagsWithParams(
|
||||
part.categories,
|
||||
part.categoryParams,
|
||||
part.title,
|
||||
(key, param) => handleClick(
|
||||
key,
|
||||
param,
|
||||
part.categoryType,
|
||||
part.title,
|
||||
category,
|
||||
),
|
||||
)
|
||||
buildTags(part.categories)
|
||||
],
|
||||
);
|
||||
}));
|
||||
} else {
|
||||
children.add(buildTitle(part.title));
|
||||
children.add(
|
||||
buildTagsWithParams(
|
||||
part.categories,
|
||||
part.categoryParams,
|
||||
part.title,
|
||||
(tag, param) => handleClick(
|
||||
tag,
|
||||
param,
|
||||
part.categoryType,
|
||||
part.title,
|
||||
data.key,
|
||||
),
|
||||
),
|
||||
buildTags(part.categories),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -280,30 +219,28 @@ class _CategoryPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTagsWithParams(
|
||||
List<String> tags,
|
||||
List<String>? params,
|
||||
String? namespace,
|
||||
ClickTagCallback onClick,
|
||||
Widget buildTags(
|
||||
List<CategoryItem> categories,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
children: List<Widget>.generate(
|
||||
tags.length,
|
||||
(index) => buildTag(
|
||||
tags[index],
|
||||
onClick,
|
||||
namespace,
|
||||
params?.elementAtOrNull(index),
|
||||
),
|
||||
categories.length,
|
||||
(index) => buildCategory(categories[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTag(String tag, ClickTagCallback onClick,
|
||||
[String? namespace, String? param]) {
|
||||
Widget buildCategory(CategoryItem c) {
|
||||
return buildTag(c.label, () {
|
||||
var context = App.mainNavigatorKey!.currentContext!;
|
||||
c.target.jump(context);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildTag(String label, VoidCallback onClick) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Builder(
|
||||
@@ -313,10 +250,10 @@ class _CategoryPage extends StatelessWidget {
|
||||
color: context.colorScheme.primaryContainer.toOpacity(0.72),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
onTap: () => onClick(tag, param),
|
||||
onTap: onClick,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(tag),
|
||||
child: Text(label),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@@ -9,6 +9,7 @@ class CategoryComicsPage extends StatefulWidget {
|
||||
required this.category,
|
||||
this.param,
|
||||
required this.categoryKey,
|
||||
this.options,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -18,6 +19,8 @@ class CategoryComicsPage extends StatefulWidget {
|
||||
|
||||
final String categoryKey;
|
||||
|
||||
final List<String>? options;
|
||||
|
||||
@override
|
||||
State<CategoryComicsPage> createState() => _CategoryComicsPageState();
|
||||
}
|
||||
@@ -31,6 +34,9 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
void findData() {
|
||||
for (final source in ComicSource.all()) {
|
||||
if (source.categoryData?.key == widget.categoryKey) {
|
||||
if (source.categoryComicsData == null) {
|
||||
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)) {
|
||||
@@ -40,7 +46,16 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
optionsValue = options.map((e) => e.options.keys.first).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;
|
||||
}
|
||||
sourceKey = source.key;
|
||||
return;
|
||||
}
|
||||
@@ -50,6 +65,11 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.options != null) {
|
||||
optionsValue = widget.options!;
|
||||
} else {
|
||||
optionsValue = [];
|
||||
}
|
||||
findData();
|
||||
super.initState();
|
||||
}
|
||||
|
@@ -95,16 +95,19 @@ abstract mixin class _ComicPageActions {
|
||||
/// [ep] the episode number, start from 1
|
||||
///
|
||||
/// [page] the page number, start from 1
|
||||
void read([int? ep, int? page]) {
|
||||
///
|
||||
/// [group] the chapter group number, start from 1
|
||||
void read([int? ep, int? page, int? group]) {
|
||||
App.rootContext
|
||||
.to(
|
||||
() => Reader(
|
||||
() => Reader(
|
||||
type: comic.comicType,
|
||||
cid: comic.id,
|
||||
name: comic.title,
|
||||
chapters: comic.chapters,
|
||||
initialChapter: ep,
|
||||
initialPage: page,
|
||||
initialChapterGroup: group,
|
||||
history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
|
||||
author: comic.findAuthor() ?? '',
|
||||
tags: comic.plainTags,
|
||||
@@ -118,7 +121,8 @@ abstract mixin class _ComicPageActions {
|
||||
void continueRead() {
|
||||
var ep = history?.ep ?? 1;
|
||||
var page = history?.page ?? 1;
|
||||
read(ep, page);
|
||||
var group = history?.group ?? 1;
|
||||
read(ep, page, group);
|
||||
}
|
||||
|
||||
void onReadEnd();
|
||||
@@ -219,7 +223,7 @@ abstract mixin class _ComicPageActions {
|
||||
isGettingLink = true;
|
||||
});
|
||||
var res =
|
||||
await comicSource.archiveDownloader!.getDownloadUrl(
|
||||
await comicSource.archiveDownloader!.getDownloadUrl(
|
||||
comic.id,
|
||||
archives![selected].id,
|
||||
);
|
||||
@@ -262,7 +266,7 @@ abstract mixin class _ComicPageActions {
|
||||
if (localComic != null) {
|
||||
for (int i = 0; i < comic.chapters!.length; i++) {
|
||||
if (localComic.downloadedChapters
|
||||
.contains(comic.chapters!.keys.elementAt(i))) {
|
||||
.contains(comic.chapters!.ids.elementAt(i))) {
|
||||
downloaded.add(i);
|
||||
}
|
||||
}
|
||||
@@ -270,8 +274,8 @@ abstract mixin class _ComicPageActions {
|
||||
await showSideBar(
|
||||
App.rootContext,
|
||||
_SelectDownloadChapter(
|
||||
comic.chapters!.values.toList(),
|
||||
(v) => selected = v,
|
||||
comic.chapters!.titles.toList(),
|
||||
(v) => selected = v,
|
||||
downloaded,
|
||||
),
|
||||
);
|
||||
@@ -281,7 +285,7 @@ abstract mixin class _ComicPageActions {
|
||||
comicId: comic.id,
|
||||
comic: comic,
|
||||
chapters: selected!.map((i) {
|
||||
return comic.chapters!.keys.elementAt(i);
|
||||
return comic.chapters!.ids.elementAt(i);
|
||||
}).toList(),
|
||||
));
|
||||
}
|
||||
@@ -290,27 +294,9 @@ abstract mixin class _ComicPageActions {
|
||||
}
|
||||
|
||||
void onTapTag(String tag, String namespace) {
|
||||
var config = comicSource.handleClickTagEvent?.call(namespace, tag) ??
|
||||
{
|
||||
'action': 'search',
|
||||
'keyword': tag,
|
||||
};
|
||||
var target = comicSource.handleClickTagEvent?.call(namespace, tag);
|
||||
var context = App.mainNavigatorKey!.currentContext!;
|
||||
if (config['action'] == 'search') {
|
||||
context.to(() => SearchResultPage(
|
||||
text: config['keyword'] ?? '',
|
||||
sourceKey: comicSource.key,
|
||||
options: const [],
|
||||
));
|
||||
} else if (config['action'] == 'category') {
|
||||
context.to(
|
||||
() => CategoryComicsPage(
|
||||
category: config['keyword'] ?? '',
|
||||
categoryKey: comicSource.categoryData!.key,
|
||||
param: config['param'],
|
||||
),
|
||||
);
|
||||
}
|
||||
target?.jump(context);
|
||||
}
|
||||
|
||||
void showMoreActions() {
|
||||
@@ -432,4 +418,4 @@ abstract mixin class _ComicPageActions {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -33,7 +33,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||
|
||||
late History? history;
|
||||
|
||||
late Map<String, String> chapters;
|
||||
late ComicChapters chapters;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -101,11 +101,11 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||
if (reverse) {
|
||||
i = chapters.length - i - 1;
|
||||
}
|
||||
var key = chapters.keys.elementAt(i);
|
||||
var key = chapters.ids.elementAt(i);
|
||||
var value = chapters[key]!;
|
||||
bool visited = (history?.readEpisode ?? {}).contains(i + 1);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
||||
child: Material(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -113,7 +113,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||
onTap: () => state.read(i + 1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
value,
|
||||
@@ -134,7 +134,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||
},
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithFixedHeight(
|
||||
maxCrossAxisExtent: 200,
|
||||
maxCrossAxisExtent: 250,
|
||||
itemHeight: 48,
|
||||
),
|
||||
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
|
||||
@@ -182,24 +182,30 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
|
||||
late History? history;
|
||||
|
||||
late Map<String, Map<String, String>> chapters;
|
||||
late ComicChapters chapters;
|
||||
|
||||
late TabController tabController;
|
||||
|
||||
int index = 0;
|
||||
late int index;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
history = widget.history;
|
||||
if (history?.group != null) {
|
||||
index = history!.group! - 1;
|
||||
} else {
|
||||
index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
state = context.findAncestorStateOfType<_ComicPageState>()!;
|
||||
chapters = state.comic.groupedChapters!;
|
||||
chapters = state.comic.chapters!;
|
||||
tabController = TabController(
|
||||
length: chapters.keys.length,
|
||||
initialIndex: index,
|
||||
length: chapters.ids.length,
|
||||
vsync: this,
|
||||
);
|
||||
tabController.addListener(onTabChange);
|
||||
@@ -226,7 +232,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
Widget build(BuildContext context) {
|
||||
return SliverLayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
var group = chapters.values.elementAt(index);
|
||||
var group = chapters.getGroupByIndex(index);
|
||||
int length = group.length;
|
||||
bool canShowAll = showAll;
|
||||
if (!showAll) {
|
||||
@@ -265,7 +271,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
child: AppTabBar(
|
||||
withUnderLine: false,
|
||||
controller: tabController,
|
||||
tabs: chapters.keys.map((e) => Tab(text: e)).toList(),
|
||||
tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
|
||||
),
|
||||
),
|
||||
SliverPadding(padding: const EdgeInsets.only(top: 8)),
|
||||
@@ -279,25 +285,30 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
var key = group.keys.elementAt(i);
|
||||
var value = group[key]!;
|
||||
var chapterIndex = 0;
|
||||
for (var j = 0; j < chapters.length; j++) {
|
||||
for (var j = 0; j < chapters.groupCount; j++) {
|
||||
if (j == index) {
|
||||
chapterIndex += i;
|
||||
break;
|
||||
}
|
||||
chapterIndex += chapters.values.elementAt(j).length;
|
||||
chapterIndex += chapters.getGroupByIndex(j).length;
|
||||
}
|
||||
String rawIndex = (chapterIndex + 1).toString();
|
||||
String groupedIndex = "${index + 1}-${i + 1}";
|
||||
bool visited = false;
|
||||
if (history != null) {
|
||||
visited = history!.readEpisode.contains(groupedIndex) ||
|
||||
history!.readEpisode.contains(rawIndex);
|
||||
}
|
||||
bool visited =
|
||||
(history?.readEpisode ?? {}).contains(chapterIndex + 1);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
||||
child: Material(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: () => state.read(chapterIndex + 1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
value,
|
||||
@@ -318,7 +329,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
},
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithFixedHeight(
|
||||
maxCrossAxisExtent: 200,
|
||||
maxCrossAxisExtent: 250,
|
||||
itemHeight: 48,
|
||||
),
|
||||
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
|
||||
|
@@ -17,10 +17,8 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/pages/category_comics_page.dart';
|
||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/pages/search_result_page.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
@@ -75,6 +73,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
|
||||
bool isDownloaded = false;
|
||||
|
||||
bool showFAB = false;
|
||||
|
||||
@override
|
||||
void onReadEnd() {
|
||||
history ??=
|
||||
@@ -114,7 +114,15 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
ComicDetails get comic => data!;
|
||||
|
||||
void onScroll() {
|
||||
if (scrollController.offset > 100) {
|
||||
var offset = scrollController.position.pixels -
|
||||
scrollController.position.minScrollExtent;
|
||||
var showFAB = offset > 0;
|
||||
if (showFAB != this.showFAB) {
|
||||
setState(() {
|
||||
this.showFAB = showFAB;
|
||||
});
|
||||
}
|
||||
if (offset > 100) {
|
||||
if (!showAppbarTitle) {
|
||||
setState(() {
|
||||
showAppbarTitle = true;
|
||||
@@ -133,19 +141,33 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
|
||||
@override
|
||||
Widget buildContent(BuildContext context, ComicDetails data) {
|
||||
return SmoothCustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
...buildTitle(),
|
||||
buildActions(),
|
||||
buildDescription(),
|
||||
buildInfo(),
|
||||
buildChapters(),
|
||||
buildComments(),
|
||||
buildThumbnails(),
|
||||
buildRecommend(),
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
|
||||
],
|
||||
return Scaffold(
|
||||
floatingActionButton: showFAB
|
||||
? FloatingActionButton(
|
||||
onPressed: () {
|
||||
scrollController.animateTo(0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease);
|
||||
},
|
||||
child: const Icon(Icons.arrow_upward),
|
||||
)
|
||||
: null,
|
||||
body: SmoothCustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
...buildTitle(),
|
||||
buildActions(),
|
||||
buildDescription(),
|
||||
buildInfo(),
|
||||
buildChapters(),
|
||||
buildComments(),
|
||||
buildThumbnails(),
|
||||
buildRecommend(),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,6 +187,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
cid: widget.id,
|
||||
name: localComic.title,
|
||||
chapters: localComic.chapters,
|
||||
initialPage: history?.page,
|
||||
initialChapter: history?.ep,
|
||||
initialChapterGroup: history?.group,
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: localComic,
|
||||
@@ -369,7 +394,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -381,16 +406,26 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
bool haveChapter = comic.chapters != null;
|
||||
var page = history!.page;
|
||||
var ep = history!.ep;
|
||||
var group = history!.group;
|
||||
String text;
|
||||
if (haveChapter) {
|
||||
text = "Last Reading: Chapter @ep Page @page".tlParams({
|
||||
'ep': ep,
|
||||
'page': page,
|
||||
});
|
||||
var epName = "E$ep";
|
||||
try {
|
||||
epName = group == null
|
||||
? comic.chapters!.titles.elementAt(
|
||||
math.min(ep - 1, comic.chapters!.length - 1),
|
||||
)
|
||||
: comic.chapters!
|
||||
.getGroupByIndex(group - 1)
|
||||
.values
|
||||
.elementAt(ep - 1);
|
||||
}
|
||||
catch(e) {
|
||||
// ignore
|
||||
}
|
||||
text = "${"Last Reading".tl}: $epName P$page";
|
||||
} else {
|
||||
text = "Last Reading: Page @page".tlParams({
|
||||
'page': page,
|
||||
});
|
||||
text = "${"Last Reading".tl}: P$page";
|
||||
}
|
||||
return Text(text);
|
||||
},
|
||||
@@ -430,7 +465,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
if (comic.tags.isEmpty &&
|
||||
comic.uploader == null &&
|
||||
comic.uploadTime == null &&
|
||||
comic.uploadTime == null) {
|
||||
comic.uploadTime == null &&
|
||||
comic.maxPage == null) {
|
||||
return const SliverPadding(padding: EdgeInsets.zero);
|
||||
}
|
||||
|
||||
@@ -594,6 +630,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
buildTag(text: formatTime(comic.updateTime!)),
|
||||
],
|
||||
),
|
||||
if (comic.maxPage != null)
|
||||
buildWrap(
|
||||
children: [
|
||||
buildTag(text: 'Pages'.tl, isTitle: true),
|
||||
buildTag(text: comic.maxPage.toString()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
],
|
||||
@@ -607,7 +650,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
}
|
||||
return _ComicChapters(
|
||||
history: history,
|
||||
groupedMode: comic.groupedChapters != null,
|
||||
groupedMode: comic.chapters!.isGrouped,
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -99,61 +99,67 @@ class _CommentsPageState extends State<CommentsPage> {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
primary: false,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _comments!.length + 2,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
if (widget.replyComment != null) {
|
||||
return Column(
|
||||
children: [
|
||||
_CommentTile(
|
||||
comment: widget.replyComment!,
|
||||
source: widget.source,
|
||||
comic: widget.data,
|
||||
showAvatar: showAvatar,
|
||||
showActions: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
child: SmoothScrollProvider(
|
||||
builder: (context, controller, physics) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
primary: false,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _comments!.length + 2,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
if (widget.replyComment != null) {
|
||||
return Column(
|
||||
children: [
|
||||
_CommentTile(
|
||||
comment: widget.replyComment!,
|
||||
source: widget.source,
|
||||
comic: widget.data,
|
||||
showAvatar: showAvatar,
|
||||
showActions: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
"Replies".tl,
|
||||
style: ts.s18,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
"Replies".tl,
|
||||
style: ts.s18,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
index--;
|
||||
|
||||
if (index == _comments!.length) {
|
||||
if (_page < (maxPage ?? _page + 1)) {
|
||||
loadMore();
|
||||
return const ListLoadingIndicator();
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
return _CommentTile(
|
||||
comment: _comments![index],
|
||||
source: widget.source,
|
||||
comic: widget.data,
|
||||
showAvatar: showAvatar,
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
index--;
|
||||
|
||||
if (index == _comments!.length) {
|
||||
if (_page < (maxPage ?? _page + 1)) {
|
||||
loadMore();
|
||||
return const ListLoadingIndicator();
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
return _CommentTile(
|
||||
comment: _comments![index],
|
||||
source: widget.source,
|
||||
comic: widget.data,
|
||||
showAvatar: showAvatar,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -651,10 +657,16 @@ class _CommentImage {
|
||||
}
|
||||
|
||||
class RichCommentContent extends StatefulWidget {
|
||||
const RichCommentContent({super.key, required this.text});
|
||||
const RichCommentContent({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.showImages = true,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
final bool showImages;
|
||||
|
||||
@override
|
||||
State<RichCommentContent> createState() => _RichCommentContentState();
|
||||
}
|
||||
@@ -808,7 +820,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
|
||||
children: textSpan,
|
||||
),
|
||||
);
|
||||
if (images.isNotEmpty) {
|
||||
if (images.isNotEmpty && widget.showImages) {
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
@@ -138,7 +138,10 @@ class _CommentWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: RichCommentContent(text: comment.content).fixWidth(324),
|
||||
child: RichCommentContent(
|
||||
text: comment.content,
|
||||
showImages: false,
|
||||
).fixWidth(324),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (comment.time != null)
|
||||
@@ -147,4 +150,4 @@ class _CommentWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -40,10 +40,11 @@ class ComicSourcePage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
if (shouldUpdate.isNotEmpty) {
|
||||
var updates = <String, String>{};
|
||||
for (var key in shouldUpdate) {
|
||||
ComicSource.availableUpdates[key] = versions[key]!;
|
||||
updates[key] = versions[key]!;
|
||||
}
|
||||
ComicSource.notifyListeners();
|
||||
ComicSourceManager().updateAvailableUpdates(updates);
|
||||
}
|
||||
return shouldUpdate.length;
|
||||
}
|
||||
@@ -73,13 +74,13 @@ class _BodyState extends State<_Body> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ComicSource.addListener(updateUI);
|
||||
ComicSourceManager().addListener(updateUI);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
ComicSource.removeListener(updateUI);
|
||||
ComicSourceManager().removeListener(updateUI);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -115,7 +116,7 @@ class _BodyState extends State<_Body> {
|
||||
onConfirm: () {
|
||||
var file = File(source.filePath);
|
||||
file.delete();
|
||||
ComicSource.remove(source.key);
|
||||
ComicSourceManager().remove(source.key);
|
||||
_validatePages();
|
||||
App.forceRebuild();
|
||||
},
|
||||
@@ -136,7 +137,7 @@ class _BodyState extends State<_Body> {
|
||||
child: const Text("cancel")),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ComicSource.reload();
|
||||
await ComicSourceManager().reload();
|
||||
App.forceRebuild();
|
||||
},
|
||||
child: const Text("continue")),
|
||||
@@ -150,7 +151,7 @@ class _BodyState extends State<_Body> {
|
||||
}
|
||||
context.to(
|
||||
() => _EditFilePage(source.filePath, () async {
|
||||
await ComicSource.reload();
|
||||
await ComicSourceManager().reload();
|
||||
setState(() {});
|
||||
}),
|
||||
);
|
||||
@@ -162,7 +163,7 @@ class _BodyState extends State<_Body> {
|
||||
App.rootContext.showMessage(message: "Invalid url config");
|
||||
return;
|
||||
}
|
||||
ComicSource.remove(source.key);
|
||||
ComicSourceManager().remove(source.key);
|
||||
bool cancel = false;
|
||||
LoadingDialogController? controller;
|
||||
if (showLoading) {
|
||||
@@ -179,14 +180,14 @@ class _BodyState extends State<_Body> {
|
||||
controller?.close();
|
||||
await ComicSourceParser().parse(res.data!, source.filePath);
|
||||
await File(source.filePath).writeAsString(res.data!);
|
||||
if (ComicSource.availableUpdates.containsKey(source.key)) {
|
||||
ComicSource.availableUpdates.remove(source.key);
|
||||
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
|
||||
ComicSourceManager().availableUpdates.remove(source.key);
|
||||
}
|
||||
} catch (e) {
|
||||
if (cancel) return;
|
||||
App.rootContext.showMessage(message: e.toString());
|
||||
}
|
||||
await ComicSource.reload();
|
||||
await ComicSourceManager().reload();
|
||||
App.forceRebuild();
|
||||
}
|
||||
|
||||
@@ -304,7 +305,7 @@ class _BodyState extends State<_Body> {
|
||||
|
||||
Future<void> addSource(String js, String fileName) async {
|
||||
var comicSource = await ComicSourceParser().createAndParse(js, fileName);
|
||||
ComicSource.add(comicSource);
|
||||
ComicSourceManager().add(comicSource);
|
||||
_addAllPagesWithComicSource(comicSource);
|
||||
appdata.saveData();
|
||||
App.forceRebuild();
|
||||
@@ -321,95 +322,168 @@ class _ComicSourceList extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ComicSourceListState extends State<_ComicSourceList> {
|
||||
bool loading = true;
|
||||
List? json;
|
||||
bool changed = false;
|
||||
var controller = TextEditingController();
|
||||
|
||||
void load() async {
|
||||
var dio = AppDio();
|
||||
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
|
||||
if (res.statusCode != 200) {
|
||||
context.showMessage(message: "Network error".tl);
|
||||
return;
|
||||
if (json != null) {
|
||||
setState(() {
|
||||
json = null;
|
||||
});
|
||||
}
|
||||
var dio = AppDio();
|
||||
try {
|
||||
var res = await dio.get<String>(controller.text);
|
||||
if (res.statusCode != 200) {
|
||||
throw "error";
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
json = jsonDecode(res.data!);
|
||||
});
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
context.showMessage(message: "Network error".tl);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
json = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.text = appdata.settings['comicSourceListUrl'];
|
||||
load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
if (changed) {
|
||||
appdata.settings['comicSourceListUrl'] = controller.text;
|
||||
appdata.saveData();
|
||||
}
|
||||
setState(() {
|
||||
json = jsonDecode(res.data!);
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopUpWidgetScaffold(
|
||||
title: "Comic Source".tl,
|
||||
tailing: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
onPressed: () async {
|
||||
await showInputDialog(
|
||||
context: context,
|
||||
title: "Set comic source list url".tl,
|
||||
initialValue: appdata.settings['comicSourceListUrl'],
|
||||
onConfirm: (value) {
|
||||
appdata.settings['comicSourceListUrl'] = value;
|
||||
appdata.saveData();
|
||||
setState(() {
|
||||
loading = true;
|
||||
json = null;
|
||||
});
|
||||
return null;
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
body: buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBody() {
|
||||
if (loading) {
|
||||
load();
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else {
|
||||
var currentKey = ComicSource.all().map((e) => e.key).toList();
|
||||
return ListView.builder(
|
||||
itemCount: json!.length,
|
||||
itemBuilder: (context, index) {
|
||||
var key = json![index]["key"];
|
||||
var action = currentKey.contains(key)
|
||||
? const Icon(Icons.check, size: 20).paddingRight(8)
|
||||
: Button.filled(
|
||||
child: Text("Add".tl),
|
||||
onPressed: () async {
|
||||
var fileName = json![index]["fileName"];
|
||||
var url = json![index]["url"];
|
||||
if (url == null || !(url.toString()).isURL) {
|
||||
var listUrl =
|
||||
appdata.settings['comicSourceListUrl'] as String;
|
||||
if (listUrl
|
||||
.replaceFirst("https://", "")
|
||||
.replaceFirst("http://", "")
|
||||
.contains("/")) {
|
||||
url =
|
||||
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
|
||||
fileName;
|
||||
} else {
|
||||
url = '$listUrl/$fileName';
|
||||
}
|
||||
}
|
||||
await widget.onAdd(url);
|
||||
setState(() {});
|
||||
},
|
||||
).fixHeight(32);
|
||||
var currentKey = ComicSource.all().map((e) => e.key).toList();
|
||||
|
||||
return ListTile(
|
||||
title: Text(json![index]["name"]),
|
||||
subtitle: Text(json![index]["version"]),
|
||||
trailing: action,
|
||||
return ListView.builder(
|
||||
itemCount: (json?.length ?? 1) + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(Icons.source_outlined),
|
||||
title: Text("Source URL".tl),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: "URL",
|
||||
border: const UnderlineInputBorder(),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
onChanged: (value) {
|
||||
changed = true;
|
||||
},
|
||||
).paddingHorizontal(16).paddingBottom(8),
|
||||
Text("The URL should point to a 'index.json' file".tl).paddingLeft(16),
|
||||
Text("Do not report any issues related to sources to App repo.".tl).paddingLeft(16),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
controller.text = defaultComicSourceUrl;
|
||||
changed = true;
|
||||
},
|
||||
child: Text("Reset".tl),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: load,
|
||||
child: Text("Refresh".tl),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (index == 1 && json == null) {
|
||||
return Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
index--;
|
||||
|
||||
var key = json![index]["key"];
|
||||
var action = currentKey.contains(key)
|
||||
? const Icon(Icons.check, size: 20).paddingRight(8)
|
||||
: Button.filled(
|
||||
child: Text("Add".tl),
|
||||
onPressed: () async {
|
||||
var fileName = json![index]["fileName"];
|
||||
var url = json![index]["url"];
|
||||
if (url == null || !(url.toString()).isURL) {
|
||||
var listUrl =
|
||||
appdata.settings['comicSourceListUrl'] as String;
|
||||
if (listUrl
|
||||
.replaceFirst("https://", "")
|
||||
.replaceFirst("http://", "")
|
||||
.contains("/")) {
|
||||
url =
|
||||
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
|
||||
fileName;
|
||||
} else {
|
||||
url = '$listUrl/$fileName';
|
||||
}
|
||||
}
|
||||
await widget.onAdd(url);
|
||||
setState(() {});
|
||||
},
|
||||
).fixHeight(32);
|
||||
|
||||
var description = json![index]["version"];
|
||||
if (json![index]["description"] != null) {
|
||||
description = "$description\n${json![index]["description"]}";
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text(json![index]["name"]),
|
||||
subtitle: Text(description),
|
||||
trailing: action,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +534,7 @@ void _addAllPagesWithComicSource(ComicSource source) {
|
||||
var explorePages = appdata.settings['explore_pages'];
|
||||
var categoryPages = appdata.settings['categories'];
|
||||
var networkFavorites = appdata.settings['favorites'];
|
||||
var searchPages = appdata.settings['searchSources'];
|
||||
|
||||
if (source.explorePages.isNotEmpty) {
|
||||
for (var page in source.explorePages) {
|
||||
@@ -476,10 +551,15 @@ void _addAllPagesWithComicSource(ComicSource source) {
|
||||
!networkFavorites.contains(source.favoriteData!.key)) {
|
||||
networkFavorites.add(source.favoriteData!.key);
|
||||
}
|
||||
if (source.searchPageData != null &&
|
||||
!searchPages.contains(source.key)) {
|
||||
searchPages.add(source.key);
|
||||
}
|
||||
|
||||
appdata.settings['explore_pages'] = explorePages.toSet().toList();
|
||||
appdata.settings['categories'] = categoryPages.toSet().toList();
|
||||
appdata.settings['favorites'] = networkFavorites.toSet().toList();
|
||||
appdata.settings['searchSources'] = searchPages.toSet().toList();
|
||||
|
||||
appdata.saveData();
|
||||
}
|
||||
@@ -563,7 +643,7 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
||||
}
|
||||
|
||||
void showUpdateDialog() async {
|
||||
var text = ComicSource.availableUpdates.entries.map((e) {
|
||||
var text = ComicSourceManager().availableUpdates.entries.map((e) {
|
||||
return "${ComicSource.find(e.key)!.name}: ${e.value}";
|
||||
}).join("\n");
|
||||
bool doUpdate = false;
|
||||
@@ -592,9 +672,9 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
||||
withProgress: true,
|
||||
);
|
||||
int current = 0;
|
||||
int total = ComicSource.availableUpdates.length;
|
||||
int total = ComicSourceManager().availableUpdates.length;
|
||||
try {
|
||||
var shouldUpdate = ComicSource.availableUpdates.keys.toList();
|
||||
var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
|
||||
for (var key in shouldUpdate) {
|
||||
var source = ComicSource.find(key)!;
|
||||
await _BodyState.update(source, false);
|
||||
@@ -692,7 +772,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var newVersion = ComicSource.availableUpdates[source.key];
|
||||
var newVersion = ComicSourceManager().availableUpdates[source.key];
|
||||
bool hasUpdate =
|
||||
newVersion != null && compareSemVer(newVersion, source.version);
|
||||
|
||||
@@ -960,7 +1040,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
source.data["account"] = null;
|
||||
source.account?.logout();
|
||||
source.saveData();
|
||||
ComicSource.notifyListeners();
|
||||
ComicSourceManager().notifyStateChange();
|
||||
setState(() {});
|
||||
},
|
||||
trailing: const Icon(Icons.logout),
|
||||
|
@@ -15,6 +15,15 @@ class DownloadingPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadingPageState extends State<DownloadingPage> {
|
||||
DownloadTask? firstTask;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
firstTask = LocalManager().downloadingTasks.firstOrNull;
|
||||
firstTask?.addListener(update);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
LocalManager().addListener(update);
|
||||
@@ -24,10 +33,17 @@ class _DownloadingPageState extends State<DownloadingPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
LocalManager().removeListener(update);
|
||||
firstTask?.removeListener(update);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void update() {
|
||||
var currentFirstTask = LocalManager().downloadingTasks.firstOrNull;
|
||||
if (currentFirstTask != firstTask) {
|
||||
firstTask?.removeListener(update);
|
||||
firstTask = currentFirstTask;
|
||||
firstTask?.addListener(update);
|
||||
}
|
||||
if(mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
|
@@ -6,13 +6,10 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/global_state.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/pages/comic_source_page.dart';
|
||||
import 'package:venera/pages/search_result_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'category_comics_page.dart';
|
||||
|
||||
class ExplorePage extends StatefulWidget {
|
||||
const ExplorePage({super.key});
|
||||
|
||||
@@ -445,30 +442,7 @@ Iterable<Widget> _buildExplorePagePart(
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
var context = App.mainNavigatorKey!.currentContext!;
|
||||
if (part.viewMore!.startsWith("search:")) {
|
||||
context.to(
|
||||
() => SearchResultPage(
|
||||
text: part.viewMore!.replaceFirst("search:", ""),
|
||||
options: const [],
|
||||
sourceKey: sourceKey,
|
||||
),
|
||||
);
|
||||
} else if (part.viewMore!.startsWith("category:")) {
|
||||
var cp = part.viewMore!.replaceFirst("category:", "");
|
||||
var c = cp.split('@').first;
|
||||
String? p = cp.split('@').last;
|
||||
if (p == c) {
|
||||
p = null;
|
||||
}
|
||||
context.to(
|
||||
() => CategoryComicsPage(
|
||||
category: c,
|
||||
categoryKey:
|
||||
ComicSource.find(sourceKey)!.categoryData!.key,
|
||||
param: p,
|
||||
),
|
||||
);
|
||||
}
|
||||
part.viewMore!.jump(context);
|
||||
},
|
||||
child: Text("View more".tl),
|
||||
)
|
||||
|
@@ -133,7 +133,7 @@ void addFavorite(List<Comic> comics) {
|
||||
}
|
||||
|
||||
Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
|
||||
var comics = LocalFavoritesManager().getAllComics(folder);
|
||||
var comics = LocalFavoritesManager().getFolderComics(folder);
|
||||
|
||||
Future<void> updateSingleComic(int index) async {
|
||||
int retry = 3;
|
||||
|
@@ -25,7 +25,6 @@ part 'favorite_actions.dart';
|
||||
part 'side_bar.dart';
|
||||
part 'local_favorites_page.dart';
|
||||
part 'network_favorites_page.dart';
|
||||
part 'local_search_page.dart';
|
||||
|
||||
const _kLeftBarWidth = 256.0;
|
||||
|
||||
|
@@ -1,5 +1,7 @@
|
||||
part of 'favorites_page.dart';
|
||||
|
||||
const _localAllFolderLabel = '^_^[%local_all%]^_^';
|
||||
|
||||
class _LocalFavoritesPage extends StatefulWidget {
|
||||
const _LocalFavoritesPage({required this.folder, super.key});
|
||||
|
||||
@@ -31,14 +33,25 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
|
||||
int? lastSelectedIndex;
|
||||
|
||||
bool get isAllFolder => widget.folder == _localAllFolderLabel;
|
||||
|
||||
void updateComics() {
|
||||
if (keyword.isEmpty) {
|
||||
setState(() {
|
||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
||||
if (isAllFolder) {
|
||||
comics = LocalFavoritesManager().getAllComics();
|
||||
} else {
|
||||
comics = LocalFavoritesManager().getFolderComics(widget.folder);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword);
|
||||
if (isAllFolder) {
|
||||
comics = LocalFavoritesManager().search(keyword);
|
||||
} else {
|
||||
comics =
|
||||
LocalFavoritesManager().searchInFolder(widget.folder, keyword);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -46,10 +59,16 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
@override
|
||||
void initState() {
|
||||
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
|
||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
||||
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
||||
networkSource = a;
|
||||
networkFolder = b;
|
||||
if (!isAllFolder) {
|
||||
comics = LocalFavoritesManager().getFolderComics(widget.folder);
|
||||
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
||||
networkSource = a;
|
||||
networkFolder = b;
|
||||
} else {
|
||||
comics = LocalFavoritesManager().getAllComics();
|
||||
networkSource = null;
|
||||
networkFolder = null;
|
||||
}
|
||||
LocalFavoritesManager().addListener(updateComics);
|
||||
super.initState();
|
||||
}
|
||||
@@ -113,6 +132,11 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var title = favPage.folder ?? "Unselected".tl;
|
||||
if (title == _localAllFolderLabel) {
|
||||
title = "All".tl;
|
||||
}
|
||||
|
||||
Widget body = SmoothCustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
@@ -135,10 +159,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
onTap: context.width < _kTwoPanelChangeWidth
|
||||
? favPage.showFolderSelector
|
||||
: null,
|
||||
child: Text(favPage.folder ?? "Unselected".tl),
|
||||
child: Text(title),
|
||||
),
|
||||
actions: [
|
||||
if (networkSource != null)
|
||||
if (networkSource != null && !isAllFolder)
|
||||
Tooltip(
|
||||
message: "Sync".tl,
|
||||
child: Flyout(
|
||||
@@ -196,9 +220,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
},
|
||||
),
|
||||
),
|
||||
MenuButton(
|
||||
entries: [
|
||||
MenuEntry(
|
||||
if (!isAllFolder)
|
||||
MenuButton(
|
||||
entries: [
|
||||
MenuEntry(
|
||||
icon: Icons.edit_outlined,
|
||||
text: "Rename".tl,
|
||||
onClick: () {
|
||||
@@ -220,8 +245,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.reorder,
|
||||
text: "Reorder".tl,
|
||||
onClick: () {
|
||||
@@ -241,8 +267,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.upload_file,
|
||||
text: "Export".tl,
|
||||
onClick: () {
|
||||
@@ -253,8 +280,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
data: utf8.encode(json),
|
||||
filename: "${widget.folder}.json",
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.update,
|
||||
text: "Update Comics Info".tl,
|
||||
onClick: () {
|
||||
@@ -265,8 +293,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
MenuEntry(
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete Folder".tl,
|
||||
color: context.colorScheme.error,
|
||||
@@ -284,9 +313,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
favPage.folderList?.updateFolders();
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (multiSelectMode)
|
||||
@@ -330,22 +360,23 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
icon: Icons.flip,
|
||||
text: "Invert Selection".tl,
|
||||
onClick: invertSelection),
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete Comic".tl,
|
||||
color: context.colorScheme.error,
|
||||
onClick: () {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: "Delete".tl,
|
||||
content: "Delete @c comics?"
|
||||
.tlParams({"c": selectedComics.length}),
|
||||
btnColor: context.colorScheme.error,
|
||||
onConfirm: () {
|
||||
_deleteComicWithId();
|
||||
},
|
||||
);
|
||||
}),
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete Comic".tl,
|
||||
color: context.colorScheme.error,
|
||||
onClick: () {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: "Delete".tl,
|
||||
content: "Delete @c comics?"
|
||||
.tlParams({"c": selectedComics.length}),
|
||||
btnColor: context.colorScheme.error,
|
||||
onConfirm: () {
|
||||
_deleteComicWithId();
|
||||
},
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
icon: Icons.download,
|
||||
text: "Download".tl,
|
||||
@@ -404,17 +435,18 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
selections: selectedComics,
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
MenuEntry(
|
||||
icon: Icons.delete,
|
||||
text: "Delete".tl,
|
||||
onClick: () {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
widget.folder,
|
||||
c.id,
|
||||
(c as FavoriteItem).type,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.delete,
|
||||
text: "Delete".tl,
|
||||
onClick: () {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
widget.folder,
|
||||
c.id,
|
||||
(c as FavoriteItem).type,
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.check,
|
||||
text: "Select".tl,
|
||||
@@ -518,11 +550,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
),
|
||||
],
|
||||
);
|
||||
body = Scrollbar(
|
||||
body = AppScrollBar(
|
||||
topPadding: 48,
|
||||
controller: scrollController,
|
||||
thickness: App.isDesktop ? 8 : 12,
|
||||
radius: const Radius.circular(8),
|
||||
interactive: true,
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: body,
|
||||
@@ -727,7 +757,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
||||
final _key = GlobalKey();
|
||||
var reorderWidgetKey = UniqueKey();
|
||||
final _scrollController = ScrollController();
|
||||
late var comics = LocalFavoritesManager().getAllComics(widget.name);
|
||||
late var comics = LocalFavoritesManager().getFolderComics(widget.name);
|
||||
bool changed = false;
|
||||
|
||||
static int _floatToInt8(double x) {
|
||||
|
@@ -1,41 +0,0 @@
|
||||
part of 'favorites_page.dart';
|
||||
|
||||
class LocalSearchPage extends StatefulWidget {
|
||||
const LocalSearchPage({super.key});
|
||||
|
||||
@override
|
||||
State<LocalSearchPage> createState() => _LocalSearchPageState();
|
||||
}
|
||||
|
||||
class _LocalSearchPageState extends State<LocalSearchPage> {
|
||||
String keyword = '';
|
||||
|
||||
var comics = <FavoriteItemWithFolderInfo>[];
|
||||
|
||||
late final SearchBarController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = SearchBarController(onSearch: (text) {
|
||||
keyword = text;
|
||||
comics = LocalFavoritesManager().search(keyword);
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SmoothCustomScrollView(slivers: [
|
||||
SliverSearchBar(controller: controller),
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
badgeBuilder: (c) {
|
||||
return (c as FavoriteItemWithFolderInfo).folder;
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
@@ -110,6 +110,15 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
|
||||
child: Text(widget.data.title),
|
||||
),
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: "Refresh".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
comicListKey.currentState!.refresh();
|
||||
},
|
||||
),
|
||||
),
|
||||
MenuButton(entries: [
|
||||
MenuEntry(
|
||||
icon: Icons.sync,
|
||||
|
@@ -102,13 +102,6 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
const Spacer(),
|
||||
MenuButton(
|
||||
entries: [
|
||||
MenuEntry(
|
||||
icon: Icons.search,
|
||||
text: 'Search'.tl,
|
||||
onClick: () {
|
||||
context.to(() => const LocalSearchPage());
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.add,
|
||||
text: 'Create Folder'.tl,
|
||||
@@ -140,6 +133,10 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
);
|
||||
}
|
||||
index--;
|
||||
if (index == 0) {
|
||||
return buildLocalFolder(_localAllFolderLabel);
|
||||
}
|
||||
index--;
|
||||
if (index < folders.length) {
|
||||
return buildLocalFolder(folders[index]);
|
||||
}
|
||||
@@ -214,7 +211,9 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Text(name),
|
||||
child: Text(name == _localAllFolderLabel
|
||||
? "All".tl
|
||||
: getFavoriteDataOrNull(name)?.title ?? name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
@@ -133,7 +134,18 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
} else if (b.updateTime == null) {
|
||||
return 1;
|
||||
}
|
||||
return b.updateTime!.compareTo(a.updateTime!);
|
||||
try {
|
||||
var aNums = a.updateTime!.split('-').map(int.parse).toList();
|
||||
var bNums = b.updateTime!.split('-').map(int.parse).toList();
|
||||
for (int i = 0; i < aNums.length; i++) {
|
||||
if (aNums[i] != bNums[i]) {
|
||||
return bNums[i] - aNums[i];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,6 +282,27 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
"Updates".tl,
|
||||
style: ts.s18,
|
||||
),
|
||||
const Spacer(),
|
||||
if (updatedComics.isNotEmpty)
|
||||
IconButton(
|
||||
icon: Icon(Icons.clear_all),
|
||||
onPressed: () {
|
||||
showConfirmDialog(
|
||||
context: App.rootContext,
|
||||
title: "Mark all as read".tl,
|
||||
content: "Do you want to mark all as read?".tl,
|
||||
onConfirm: () {
|
||||
for (var comic in updatedComics) {
|
||||
LocalFavoritesManager().markAsRead(
|
||||
comic.id,
|
||||
comic.type,
|
||||
);
|
||||
}
|
||||
updateFollowUpdatesUI();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -408,7 +441,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
}
|
||||
|
||||
void setFolder(String folder) async {
|
||||
FollowUpdatesService.cancelChecking?.call();
|
||||
FollowUpdatesService._cancelChecking?.call();
|
||||
LocalFavoritesManager().prepareTableForFollowUpdates(folder);
|
||||
|
||||
var count = LocalFavoritesManager().count(folder);
|
||||
@@ -447,7 +480,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
}
|
||||
|
||||
void checkNow() async {
|
||||
FollowUpdatesService.cancelChecking?.call();
|
||||
FollowUpdatesService._cancelChecking?.call();
|
||||
|
||||
bool isCanceled = false;
|
||||
void onCancel() {
|
||||
@@ -570,7 +603,7 @@ void _updateFolderBase(
|
||||
tags: newTags,
|
||||
);
|
||||
|
||||
LocalFavoritesManager().updateInfo(folder, item);
|
||||
LocalFavoritesManager().updateInfo(folder, item, false);
|
||||
|
||||
var updateTime = newInfo.findUpdateTime();
|
||||
if (updateTime != null && updateTime != c.updateTime) {
|
||||
@@ -580,6 +613,8 @@ void _updateFolderBase(
|
||||
c.type,
|
||||
updateTime,
|
||||
);
|
||||
} else {
|
||||
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
|
||||
}
|
||||
updated++;
|
||||
return;
|
||||
@@ -606,6 +641,10 @@ void _updateFolderBase(
|
||||
|
||||
await Future.wait(futures);
|
||||
|
||||
if (updated > 0) {
|
||||
LocalFavoritesManager().notifyChanges();
|
||||
}
|
||||
|
||||
stream.close();
|
||||
}
|
||||
|
||||
@@ -617,12 +656,14 @@ Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) {
|
||||
|
||||
/// Background service for checking updates
|
||||
abstract class FollowUpdatesService {
|
||||
static bool isChecking = false;
|
||||
static bool _isChecking = false;
|
||||
|
||||
static void Function()? cancelChecking;
|
||||
static void Function()? _cancelChecking;
|
||||
|
||||
static void check() async {
|
||||
if (isChecking) {
|
||||
static bool _isInitialized = false;
|
||||
|
||||
static void _check() async {
|
||||
if (_isChecking) {
|
||||
return;
|
||||
}
|
||||
var folder = appdata.settings["followUpdatesFolder"];
|
||||
@@ -630,11 +671,16 @@ abstract class FollowUpdatesService {
|
||||
return;
|
||||
}
|
||||
bool isCanceled = false;
|
||||
cancelChecking = () {
|
||||
_cancelChecking = () {
|
||||
isCanceled = true;
|
||||
};
|
||||
|
||||
isChecking = true;
|
||||
_isChecking = true;
|
||||
|
||||
while (DataSync().isDownloading) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
int updated = 0;
|
||||
try {
|
||||
await for (var progress in _updateFolder(folder, false)) {
|
||||
@@ -644,21 +690,28 @@ abstract class FollowUpdatesService {
|
||||
updated = progress.updated;
|
||||
}
|
||||
} finally {
|
||||
cancelChecking = null;
|
||||
isChecking = false;
|
||||
_cancelChecking = null;
|
||||
_isChecking = false;
|
||||
if (updated > 0) {
|
||||
updateFollowUpdatesUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the checker.
|
||||
static void initChecker() {
|
||||
Timer.periodic(const Duration(hours: 1), (timer) {
|
||||
check();
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
_check();
|
||||
DataSync().addListener(updateFollowUpdatesUI);
|
||||
// A short interval will not affect the performance since every comic has a check time.
|
||||
Timer.periodic(const Duration(minutes: 10), (timer) {
|
||||
_check();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the UI of follow updates.
|
||||
void updateFollowUpdatesUI() {
|
||||
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
|
||||
GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics();
|
||||
|
@@ -29,86 +29,211 @@ class _HistoryPageState extends State<HistoryPage> {
|
||||
void onUpdate() {
|
||||
setState(() {
|
||||
comics = HistoryManager().getAll();
|
||||
if (multiSelectMode) {
|
||||
selectedComics.removeWhere((comic, _) => !comics.contains(comic));
|
||||
if (selectedComics.isEmpty) {
|
||||
multiSelectMode = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var comics = HistoryManager().getAll();
|
||||
|
||||
var controller = FlyoutController();
|
||||
|
||||
bool multiSelectMode = false;
|
||||
Map<History, bool> selectedComics = {};
|
||||
|
||||
void selectAll() {
|
||||
setState(() {
|
||||
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
|
||||
});
|
||||
}
|
||||
|
||||
void deSelect() {
|
||||
setState(() {
|
||||
selectedComics.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void invertSelection() {
|
||||
setState(() {
|
||||
comics.asMap().forEach((k, v) {
|
||||
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
|
||||
});
|
||||
selectedComics.removeWhere((k, v) => !v);
|
||||
});
|
||||
}
|
||||
|
||||
void _removeHistory(History comic) {
|
||||
if (comic.sourceKey.startsWith("Unknown")) {
|
||||
HistoryManager().remove(
|
||||
comic.id,
|
||||
ComicType(int.parse(comic.sourceKey.split(':')[1])),
|
||||
);
|
||||
} else if (comic.sourceKey == 'local') {
|
||||
HistoryManager().remove(
|
||||
comic.id,
|
||||
ComicType.local,
|
||||
);
|
||||
} else {
|
||||
HistoryManager().remove(
|
||||
comic.id,
|
||||
ComicType(comic.sourceKey.hashCode),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(
|
||||
title: Text('History'.tl),
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: 'Clear History'.tl,
|
||||
child: Flyout(
|
||||
controller: controller,
|
||||
flyoutBuilder: (context) {
|
||||
return FlyoutContent(
|
||||
title: 'Clear History'.tl,
|
||||
content: Text(
|
||||
'Are you sure you want to clear your history?'.tl),
|
||||
actions: [
|
||||
Button.filled(
|
||||
color: context.colorScheme.error,
|
||||
onPressed: () {
|
||||
HistoryManager().clearHistory();
|
||||
context.pop();
|
||||
},
|
||||
child: Text('Clear'.tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.clear_all),
|
||||
onPressed: () {
|
||||
controller.show();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
badgeBuilder: (c) {
|
||||
return ComicSource.find(c.sourceKey)?.name;
|
||||
},
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
MenuEntry(
|
||||
icon: Icons.remove,
|
||||
text: 'Remove'.tl,
|
||||
List<Widget> selectActions = [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.select_all),
|
||||
tooltip: "Select All".tl,
|
||||
onPressed: selectAll
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.deselect),
|
||||
tooltip: "Deselect".tl,
|
||||
onPressed: deSelect
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.flip),
|
||||
tooltip: "Invert Selection".tl,
|
||||
onPressed: invertSelection
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
tooltip: "Delete".tl,
|
||||
onPressed: selectedComics.isEmpty
|
||||
? null
|
||||
: () {
|
||||
final comicsToDelete = List<History>.from(selectedComics.keys);
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
|
||||
for (final comic in comicsToDelete) {
|
||||
_removeHistory(comic);
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
List<Widget> normalActions = [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.checklist),
|
||||
tooltip: multiSelectMode ? "Exit Multi-Select".tl : "Multi-Select".tl,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
multiSelectMode = !multiSelectMode;
|
||||
});
|
||||
},
|
||||
),
|
||||
Tooltip(
|
||||
message: 'Clear History'.tl,
|
||||
child: Flyout(
|
||||
controller: controller,
|
||||
flyoutBuilder: (context) {
|
||||
return FlyoutContent(
|
||||
title: 'Clear History'.tl,
|
||||
content: Text('Are you sure you want to clear your history?'.tl),
|
||||
actions: [
|
||||
Button.filled(
|
||||
color: context.colorScheme.error,
|
||||
onClick: () {
|
||||
if (c.sourceKey.startsWith("Unknown")) {
|
||||
HistoryManager().remove(
|
||||
c.id,
|
||||
ComicType(int.parse(c.sourceKey.split(':')[1])),
|
||||
);
|
||||
} else if (c.sourceKey == 'local') {
|
||||
HistoryManager().remove(
|
||||
c.id,
|
||||
ComicType.local,
|
||||
);
|
||||
onPressed: () {
|
||||
HistoryManager().clearHistory();
|
||||
context.pop();
|
||||
},
|
||||
child: Text('Clear'.tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.clear_all),
|
||||
onPressed: () {
|
||||
controller.show();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
];
|
||||
|
||||
return PopScope(
|
||||
canPop: !multiSelectMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(
|
||||
leading: Tooltip(
|
||||
message: multiSelectMode ? "Cancel".tl : "Back".tl,
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
} else {
|
||||
HistoryManager().remove(
|
||||
c.id,
|
||||
ComicType(c.sourceKey.hashCode),
|
||||
);
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
icon: multiSelectMode
|
||||
? const Icon(Icons.close)
|
||||
: const Icon(Icons.arrow_back),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
title: multiSelectMode
|
||||
? Text(selectedComics.length.toString())
|
||||
: Text('History'.tl),
|
||||
actions: multiSelectMode ? selectActions : normalActions,
|
||||
),
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
selections: selectedComics,
|
||||
onLongPressed: null,
|
||||
onTap: multiSelectMode
|
||||
? (c) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as History)) {
|
||||
selectedComics.remove(c);
|
||||
} else {
|
||||
selectedComics[c] = true;
|
||||
}
|
||||
if (selectedComics.isEmpty) {
|
||||
multiSelectMode = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
badgeBuilder: (c) {
|
||||
return ComicSource.find(c.sourceKey)?.name;
|
||||
},
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
MenuEntry(
|
||||
icon: Icons.remove,
|
||||
text: 'Remove'.tl,
|
||||
color: context.colorScheme.error,
|
||||
onClick: () {
|
||||
_removeHistory(c as History);
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -52,7 +52,7 @@ class _SearchBar extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Container(
|
||||
height: 52,
|
||||
height: App.isMobile ? 52 : 46,
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Material(
|
||||
@@ -162,16 +162,50 @@ class _SyncDataWidgetState extends State<_SyncDataWidget>
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (DataSync().lastError != null)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
showDialogMessage(
|
||||
App.rootContext,
|
||||
"Error".tl,
|
||||
DataSync().lastError!,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text('Error'.tl, style: ts.s12),
|
||||
],
|
||||
),
|
||||
),
|
||||
).paddingRight(4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_upload_outlined),
|
||||
onPressed: () async {
|
||||
DataSync().uploadData();
|
||||
}),
|
||||
icon: const Icon(Icons.cloud_upload_outlined),
|
||||
onPressed: () async {
|
||||
DataSync().uploadData();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_download_outlined),
|
||||
onPressed: () async {
|
||||
DataSync().downloadData();
|
||||
}),
|
||||
icon: const Icon(Icons.cloud_download_outlined),
|
||||
onPressed: () async {
|
||||
DataSync().downloadData();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -197,10 +231,12 @@ class _HistoryState extends State<_History> {
|
||||
late int count;
|
||||
|
||||
void onHistoryChange() {
|
||||
setState(() {
|
||||
history = HistoryManager().getRecent();
|
||||
count = HistoryManager().count();
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
history = HistoryManager().getRecent();
|
||||
count = HistoryManager().count();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -261,7 +297,7 @@ class _HistoryState extends State<_History> {
|
||||
).paddingHorizontal(16),
|
||||
if (history.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 128,
|
||||
height: 136,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: history.length,
|
||||
@@ -364,13 +400,14 @@ class _LocalState extends State<_Local> {
|
||||
).paddingHorizontal(16),
|
||||
if (local.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 128,
|
||||
height: 136,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: local.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SimpleComicTile(comic: local[index])
|
||||
.paddingHorizontal(8);
|
||||
.paddingHorizontal(8)
|
||||
.paddingVertical(2);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
@@ -536,7 +573,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
|
||||
launchUrlString(
|
||||
"https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
|
||||
},
|
||||
).fixWidth(90).paddingRight(8),
|
||||
Button.filled(
|
||||
@@ -593,16 +631,29 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
|
||||
@override
|
||||
void initState() {
|
||||
comicSources = ComicSource.all().map((e) => e.name).toList();
|
||||
ComicSource.addListener(onComicSourceChange);
|
||||
ComicSourceManager().addListener(onComicSourceChange);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ComicSource.removeListener(onComicSourceChange);
|
||||
ComicSourceManager().removeListener(onComicSourceChange);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int get _availableUpdates {
|
||||
int c = 0;
|
||||
ComicSourceManager().availableUpdates.forEach((key, version) {
|
||||
var source = ComicSource.find(key);
|
||||
if (source != null) {
|
||||
if (compareSemVer(version, source.version)) {
|
||||
c++;
|
||||
}
|
||||
}
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverToBoxAdapter(
|
||||
@@ -666,7 +717,7 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
|
||||
}).toList(),
|
||||
).paddingHorizontal(16).paddingBottom(16),
|
||||
),
|
||||
if (ComicSource.availableUpdates.isNotEmpty)
|
||||
if (_availableUpdates > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
@@ -682,14 +733,24 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.update, color: context.colorScheme.primary, size: 20,),
|
||||
Icon(
|
||||
Icons.update,
|
||||
color: context.colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text("@c updates".tlParams({
|
||||
'c': ComicSource.availableUpdates.length,
|
||||
}), style: ts.withColor(context.colorScheme.primary),),
|
||||
Text(
|
||||
"@c updates".tlParams({
|
||||
'c': _availableUpdates,
|
||||
}),
|
||||
style: ts.withColor(context.colorScheme.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8),
|
||||
)
|
||||
.toAlign(Alignment.centerLeft)
|
||||
.paddingHorizontal(16)
|
||||
.paddingBottom(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -829,7 +890,8 @@ class _ImageFavoritesState extends State<ImageFavorites> {
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
color:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
@@ -880,7 +942,7 @@ class _ImageFavoritesState extends State<ImageFavorites> {
|
||||
displayType = type;
|
||||
});
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
var scrollController = ScrollControllerProvider.of(context);
|
||||
var scrollController = ScrollState.of(context).controller;
|
||||
scrollController.animateTo(
|
||||
scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
|
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||
@@ -304,7 +305,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
(c as LocalComic).read();
|
||||
// prevent dirty data
|
||||
var comic =
|
||||
LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
|
||||
comic.read();
|
||||
}
|
||||
},
|
||||
menuBuilder: (c) {
|
||||
@@ -441,7 +445,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
var fileName = "";
|
||||
// For each comic, export it to a file
|
||||
for (var comic in comics) {
|
||||
fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext);
|
||||
fileName = FilePath.join(
|
||||
cacheDir,
|
||||
sanitizeFileName(comic.title, maxLength: 100) + ext,
|
||||
);
|
||||
await export(comic, fileName);
|
||||
current++;
|
||||
if (comics.length > 1) {
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/pages/categories_page.dart';
|
||||
import 'package:venera/pages/search_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
@@ -39,6 +40,7 @@ class _MainPageState extends State<MainPage> {
|
||||
_observer = NaviObserver();
|
||||
_navigatorKey = GlobalKey();
|
||||
App.mainNavigatorKey = _navigatorKey;
|
||||
index = int.tryParse(appdata.settings['initialPage'].toString()) ?? 0;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ class _MainPageState extends State<MainPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NaviPane(
|
||||
initialPage: index,
|
||||
observer: _observer,
|
||||
navigatorKey: _navigatorKey!,
|
||||
paneItems: [
|
||||
|
242
lib/pages/reader/chapters.dart
Normal file
242
lib/pages/reader/chapters.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
part of 'reader.dart';
|
||||
|
||||
class _ChaptersView extends StatefulWidget {
|
||||
const _ChaptersView(this.reader);
|
||||
|
||||
final _ReaderState reader;
|
||||
|
||||
@override
|
||||
State<_ChaptersView> createState() => _ChaptersViewState();
|
||||
}
|
||||
|
||||
class _ChaptersViewState extends State<_ChaptersView> {
|
||||
bool desc = false;
|
||||
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
var downloaded = <String>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
int epIndex = widget.reader.chapter - 2;
|
||||
_scrollController = ScrollController(
|
||||
initialScrollOffset: (epIndex * 48.0 + 52).clamp(0, double.infinity),
|
||||
);
|
||||
var local = LocalManager().find(widget.reader.cid, widget.reader.type);
|
||||
if (local != null) {
|
||||
downloaded = local.downloadedChapters;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chapters = widget.reader.widget.chapters!;
|
||||
var current = widget.reader.chapter - 1;
|
||||
return Scaffold(
|
||||
body: SmoothCustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAppbar(
|
||||
style: AppbarStyle.shadow,
|
||||
title: Text("Chapters".tl),
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: "Click to change the order".tl,
|
||||
child: TextButton.icon(
|
||||
icon: Icon(
|
||||
!desc ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(!desc ? "Ascending".tl : "Descending".tl),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
desc = !desc;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (desc) {
|
||||
index = chapters.length - 1 - index;
|
||||
}
|
||||
var chapter = chapters.titles.elementAt(index);
|
||||
return _ChapterListTile(
|
||||
onTap: () {
|
||||
widget.reader.toChapter(index + 1);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
title: chapter,
|
||||
isActive: current == index,
|
||||
isDownloaded:
|
||||
downloaded.contains(chapters.ids.elementAt(index)),
|
||||
);
|
||||
},
|
||||
childCount: chapters.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupedChaptersView extends StatefulWidget {
|
||||
const _GroupedChaptersView(this.reader);
|
||||
|
||||
final _ReaderState reader;
|
||||
|
||||
@override
|
||||
State<_GroupedChaptersView> createState() => _GroupedChaptersViewState();
|
||||
}
|
||||
|
||||
class _GroupedChaptersViewState extends State<_GroupedChaptersView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
ComicChapters get chapters => widget.reader.widget.chapters!;
|
||||
|
||||
late final TabController tabController;
|
||||
|
||||
late final ScrollController _scrollController;
|
||||
|
||||
late final String initialGroupName;
|
||||
|
||||
var downloaded = <String>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
int index = 0;
|
||||
int epIndex = widget.reader.chapter - 1;
|
||||
while (epIndex >= 0) {
|
||||
epIndex -= chapters.getGroupByIndex(index).length;
|
||||
index++;
|
||||
}
|
||||
tabController = TabController(
|
||||
length: chapters.groups.length,
|
||||
vsync: this,
|
||||
initialIndex: index - 1,
|
||||
);
|
||||
initialGroupName = chapters.groups.elementAt(index - 1);
|
||||
var epIndexAtGroup = widget.reader.chapter - 1;
|
||||
for (var i = 0; i < index - 1; i++) {
|
||||
epIndexAtGroup -= chapters.getGroupByIndex(i).length;
|
||||
}
|
||||
_scrollController = ScrollController(
|
||||
initialScrollOffset: (epIndexAtGroup * 48.0).clamp(0, double.infinity),
|
||||
);
|
||||
var local = LocalManager().find(widget.reader.cid, widget.reader.type);
|
||||
if (local != null) {
|
||||
downloaded = local.downloadedChapters;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Appbar(title: Text("Chapters".tl)),
|
||||
AppTabBar(
|
||||
controller: tabController,
|
||||
tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
|
||||
),
|
||||
Expanded(
|
||||
child: TabViewBody(
|
||||
controller: tabController,
|
||||
children: chapters.groups.map(buildGroup).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildGroup(String groupName) {
|
||||
var group = chapters.getGroup(groupName);
|
||||
return SmoothCustomScrollView(
|
||||
controller: initialGroupName == groupName ? _scrollController : null,
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
var name = group.values.elementAt(index);
|
||||
var i = 0;
|
||||
for (var g in chapters.groups) {
|
||||
if (g == groupName) {
|
||||
break;
|
||||
}
|
||||
i += chapters.getGroup(g).length;
|
||||
}
|
||||
i += index + 1;
|
||||
return _ChapterListTile(
|
||||
onTap: () {
|
||||
widget.reader.toChapter(i);
|
||||
context.pop();
|
||||
},
|
||||
title: name,
|
||||
isActive: widget.reader.chapter == i,
|
||||
isDownloaded: downloaded.contains(group.keys.elementAt(index)),
|
||||
);
|
||||
},
|
||||
childCount: group.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChapterListTile extends StatelessWidget {
|
||||
const _ChapterListTile({
|
||||
required this.title,
|
||||
required this.isActive,
|
||||
required this.isDownloaded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
final bool isActive;
|
||||
|
||||
final bool isDownloaded;
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color:
|
||||
isActive ? context.colorScheme.primary : Colors.transparent,
|
||||
width: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: isActive
|
||||
? ts.withColor(context.colorScheme.primary).bold.s16
|
||||
: ts.s16,
|
||||
),
|
||||
const Spacer(),
|
||||
if (isDownloaded)
|
||||
Icon(
|
||||
Icons.download_done_rounded,
|
||||
color: context.colorScheme.secondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -24,6 +24,8 @@ class ComicImage extends StatefulWidget {
|
||||
Map<String, String>? headers,
|
||||
int? cacheWidth,
|
||||
int? cacheHeight,
|
||||
this.onInit,
|
||||
this.onDispose,
|
||||
}) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
|
||||
assert(cacheWidth == null || cacheWidth > 0),
|
||||
assert(cacheHeight == null || cacheHeight > 0);
|
||||
@@ -60,6 +62,10 @@ class ComicImage extends StatefulWidget {
|
||||
|
||||
final bool isAntiAlias;
|
||||
|
||||
final void Function(State<ComicImage> state)? onInit;
|
||||
|
||||
final void Function(State<ComicImage> state)? onDispose;
|
||||
|
||||
static void clear() => _ComicImageState.clear();
|
||||
|
||||
@override
|
||||
@@ -87,6 +93,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this);
|
||||
widget.onInit?.call(this);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -97,6 +104,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
|
||||
_completerHandle?.dispose();
|
||||
_scrollAwareContext.dispose();
|
||||
_replaceImage(info: null);
|
||||
widget.onDispose?.call(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -136,6 +144,15 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
|
||||
super.reassemble();
|
||||
}
|
||||
|
||||
bool containsPoint(Offset point) {
|
||||
if (!mounted) {
|
||||
return false;
|
||||
}
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
var localPoint = renderBox.globalToLocal(point);
|
||||
return renderBox.paintBounds.contains(localPoint);
|
||||
}
|
||||
|
||||
void _updateInvertColors() {
|
||||
_invertColors = MediaQuery.maybeInvertColorsOf(context) ??
|
||||
SemanticsBinding.instance.accessibilityFeatures.invertColors;
|
||||
|
@@ -131,11 +131,11 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
||||
}
|
||||
if (context.reader.mode.key.startsWith('gallery')) {
|
||||
if (forward) {
|
||||
if (!context.reader.toNextPage()) {
|
||||
if (!context.reader.toNextPage() && !context.reader.isLastChapterOfGroup) {
|
||||
context.reader.toNextChapter();
|
||||
}
|
||||
} else {
|
||||
if (!context.reader.toPrevPage()) {
|
||||
if (!context.reader.toPrevPage() && !context.reader.isFirstChapterOfGroup) {
|
||||
context.reader.toPrevChapter();
|
||||
}
|
||||
}
|
||||
@@ -281,6 +281,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
if (App.isDesktop && !reader.isLoading)
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
text: "Copy Image".tl,
|
||||
onClick: () => copyImage(location),
|
||||
),
|
||||
if (!reader.isLoading)
|
||||
MenuEntry(
|
||||
icon: Icons.download_outlined,
|
||||
text: "Save Image".tl,
|
||||
onClick: () => saveImage(location),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -303,6 +315,27 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
||||
|
||||
@override
|
||||
Object? get key => "reader_gesture";
|
||||
|
||||
void copyImage(Offset location) async {
|
||||
var controller = reader._imageViewController;
|
||||
var image = await controller!.getImageByOffset(location);
|
||||
if (image != null) {
|
||||
writeImageToClipboard(image);
|
||||
} else {
|
||||
context.showMessage(message: "No Image");
|
||||
}
|
||||
}
|
||||
|
||||
void saveImage(Offset location) async {
|
||||
var controller = reader._imageViewController;
|
||||
var image = await controller!.getImageByOffset(location);
|
||||
if (image != null) {
|
||||
var filetype = detectFileType(image);
|
||||
saveFile(filename: "image${filetype.ext}", data: image);
|
||||
} else {
|
||||
context.showMessage(message: "No Image");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _DragListener {
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ class _ReaderWithLoadingState
|
||||
history: data.history,
|
||||
initialChapter: widget.initialEp ?? data.history.ep,
|
||||
initialPage: widget.initialPage ?? data.history.page,
|
||||
initialChapterGroup: data.history.group,
|
||||
author: data.author,
|
||||
tags: data.tags,
|
||||
);
|
||||
@@ -101,7 +102,7 @@ class ReaderProps {
|
||||
|
||||
final String name;
|
||||
|
||||
final Map<String, String>? chapters;
|
||||
final ComicChapters? chapters;
|
||||
|
||||
final History history;
|
||||
|
||||
|
@@ -15,6 +15,7 @@ import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/components/custom_slider.dart';
|
||||
import 'package:venera/components/window_frame.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
@@ -28,7 +29,9 @@ import 'package:venera/foundation/image_provider/reader_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/clipboard_image.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/file_type.dart';
|
||||
@@ -40,11 +43,17 @@ import 'package:window_manager/window_manager.dart';
|
||||
import 'package:battery_plus/battery_plus.dart';
|
||||
|
||||
part 'scaffold.dart';
|
||||
|
||||
part 'images.dart';
|
||||
|
||||
part 'gesture.dart';
|
||||
|
||||
part 'comic_image.dart';
|
||||
|
||||
part 'loading.dart';
|
||||
|
||||
part 'chapters.dart';
|
||||
|
||||
extension _ReaderContext on BuildContext {
|
||||
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
|
||||
|
||||
@@ -62,6 +71,7 @@ class Reader extends StatefulWidget {
|
||||
required this.history,
|
||||
this.initialPage,
|
||||
this.initialChapter,
|
||||
this.initialChapterGroup,
|
||||
required this.author,
|
||||
required this.tags,
|
||||
});
|
||||
@@ -76,9 +86,7 @@ class Reader extends StatefulWidget {
|
||||
|
||||
final String name;
|
||||
|
||||
/// key: Chapter ID, value: Chapter Name
|
||||
/// null if the comic is a gallery
|
||||
final Map<String, String>? chapters;
|
||||
final ComicChapters? chapters;
|
||||
|
||||
/// Starts from 1, invalid values equal to 1
|
||||
final int? initialPage;
|
||||
@@ -86,13 +94,17 @@ class Reader extends StatefulWidget {
|
||||
/// Starts from 1, invalid values equal to 1
|
||||
final int? initialChapter;
|
||||
|
||||
/// Starts from 1, invalid values equal to 1
|
||||
final int? initialChapterGroup;
|
||||
|
||||
final History history;
|
||||
|
||||
@override
|
||||
State<Reader> createState() => _ReaderState();
|
||||
}
|
||||
|
||||
class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
class _ReaderState extends State<Reader>
|
||||
with _ReaderLocation, _ReaderWindow, _VolumeListener, _ImagePerPageHandler {
|
||||
@override
|
||||
void update() {
|
||||
setState(() {});
|
||||
@@ -105,37 +117,15 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
|
||||
String get cid => widget.cid;
|
||||
|
||||
String get eid => widget.chapters?.keys.elementAt(chapter - 1) ?? '0';
|
||||
String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
|
||||
|
||||
List<String>? images;
|
||||
|
||||
@override
|
||||
late ReaderMode mode;
|
||||
|
||||
int get imagesPerPage => appdata.settings['readerScreenPicNumber'] ?? 1;
|
||||
|
||||
int _lastImagesPerPage = appdata.settings['readerScreenPicNumber'] ?? 1;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_checkImagesPerPageChange();
|
||||
}
|
||||
|
||||
void _checkImagesPerPageChange() {
|
||||
int currentImagesPerPage = imagesPerPage;
|
||||
if (_lastImagesPerPage != currentImagesPerPage) {
|
||||
_adjustPageForImagesPerPageChange(
|
||||
_lastImagesPerPage, currentImagesPerPage);
|
||||
_lastImagesPerPage = currentImagesPerPage;
|
||||
}
|
||||
}
|
||||
|
||||
void _adjustPageForImagesPerPageChange(
|
||||
int oldImagesPerPage, int newImagesPerPage) {
|
||||
int previousImageIndex = (page - 1) * oldImagesPerPage;
|
||||
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
|
||||
page = newPage;
|
||||
}
|
||||
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait;
|
||||
|
||||
History? history;
|
||||
|
||||
@@ -144,18 +134,24 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
|
||||
var focusNode = FocusNode();
|
||||
|
||||
VolumeListener? volumeListener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
page = widget.initialPage ?? 1;
|
||||
chapter = widget.initialChapter ?? 1;
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
chapter = widget.initialChapter ?? 1;
|
||||
if (chapter < 1) {
|
||||
chapter = 1;
|
||||
}
|
||||
if (widget.initialChapterGroup != null) {
|
||||
for (int i = 0; i < (widget.initialChapterGroup! - 1); i++) {
|
||||
chapter += widget.chapters!.getGroupByIndex(i).length;
|
||||
}
|
||||
}
|
||||
if (widget.initialPage != null) {
|
||||
page = widget.initialPage!;
|
||||
}
|
||||
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||
history = widget.history;
|
||||
Future.microtask(() {
|
||||
@@ -172,6 +168,13 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
initImagesPerPage(widget.initialPage ?? 1);
|
||||
initReaderWindow();
|
||||
}
|
||||
|
||||
void setImageCacheSize() async {
|
||||
var availableRAM = await MemoryInfo.getFreePhysicalMemorySize();
|
||||
if (availableRAM == null) return;
|
||||
@@ -192,6 +195,9 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (isFullscreen) {
|
||||
fullscreen();
|
||||
}
|
||||
autoPageTurningTimer?.cancel();
|
||||
focusNode.dispose();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
@@ -200,6 +206,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
DataSync().onDataChanged();
|
||||
});
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20;
|
||||
disposeReaderWindow();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -210,15 +217,24 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: onKeyEvent,
|
||||
child: _ReaderScaffold(
|
||||
child: _ReaderGestureDetector(
|
||||
child: _ReaderImages(key: Key(chapter.toString())),
|
||||
),
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return _ReaderScaffold(
|
||||
child: _ReaderGestureDetector(
|
||||
child: _ReaderImages(key: Key(chapter.toString())),
|
||||
),
|
||||
);
|
||||
})
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onKeyEvent(KeyEvent event) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.f12 && event is KeyUpEvent) {
|
||||
fullscreen();
|
||||
}
|
||||
_imageViewController?.handleKeyEvent(event);
|
||||
}
|
||||
|
||||
@@ -236,12 +252,28 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
|
||||
void updateHistory() {
|
||||
if (history != null) {
|
||||
history!.page = page;
|
||||
history!.ep = chapter;
|
||||
if (maxPage > 1) {
|
||||
history!.maxPage = maxPage;
|
||||
if (page == maxPage) {
|
||||
/// Record the last image of chapter
|
||||
history!.page = images?.length ?? 1;
|
||||
} else {
|
||||
/// Record the first image of the page
|
||||
history!.page = (page - 1) * imagesPerPage + 1;
|
||||
}
|
||||
history!.maxPage = images?.length ?? 1;
|
||||
if (widget.chapters?.isGrouped ?? false) {
|
||||
int g = 0;
|
||||
int c = chapter;
|
||||
while (c > widget.chapters!.getGroupByIndex(g).length) {
|
||||
c -= widget.chapters!.getGroupByIndex(g).length;
|
||||
g++;
|
||||
}
|
||||
history!.readEpisode.add('${g + 1}-$c');
|
||||
history!.ep = c;
|
||||
history!.group = g + 1;
|
||||
} else {
|
||||
history!.readEpisode.add(chapter.toString());
|
||||
history!.ep = chapter;
|
||||
}
|
||||
history!.readEpisode.add(chapter);
|
||||
history!.time = DateTime.now();
|
||||
_updateHistoryTimer?.cancel();
|
||||
_updateHistoryTimer = Timer(const Duration(seconds: 1), () {
|
||||
@@ -251,6 +283,118 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
}
|
||||
}
|
||||
|
||||
bool get isFirstChapterOfGroup {
|
||||
if (widget.chapters?.isGrouped ?? false) {
|
||||
int c = chapter - 1;
|
||||
int g = 1;
|
||||
while (c > 0) {
|
||||
c -= widget.chapters!.getGroupByIndex(g - 1).length;
|
||||
g++;
|
||||
}
|
||||
if (c == 0) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return chapter == 1;
|
||||
}
|
||||
|
||||
bool get isLastChapterOfGroup {
|
||||
if (widget.chapters?.isGrouped ?? false) {
|
||||
int c = chapter;
|
||||
int g = 1;
|
||||
while (c > 0) {
|
||||
c -= widget.chapters!.getGroupByIndex(g - 1).length;
|
||||
g++;
|
||||
}
|
||||
if (c == 0) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return chapter == maxChapter;
|
||||
}
|
||||
|
||||
/// Get the size of the reader.
|
||||
/// The size is not always the same as the size of the screen.
|
||||
Size get size {
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
return renderBox.size;
|
||||
}
|
||||
}
|
||||
|
||||
abstract mixin class _ImagePerPageHandler {
|
||||
late int _lastImagesPerPage;
|
||||
|
||||
bool get isPortrait;
|
||||
|
||||
int get page;
|
||||
|
||||
set page(int value);
|
||||
|
||||
ReaderMode get mode;
|
||||
|
||||
void initImagesPerPage(int initialPage) {
|
||||
_lastImagesPerPage = imagesPerPage;
|
||||
if (imagesPerPage != 1) {
|
||||
page = (initialPage / imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of images displayed on one screen
|
||||
int get imagesPerPage {
|
||||
if (mode.isContinuous) return 1;
|
||||
if (isPortrait) {
|
||||
return appdata.settings['readerScreenPicNumberForPortrait'] ?? 1;
|
||||
} else {
|
||||
return appdata.settings['readerScreenPicNumberForLandscape'] ?? 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the number of images per page has changed
|
||||
void _checkImagesPerPageChange() {
|
||||
int currentImagesPerPage = imagesPerPage;
|
||||
if (_lastImagesPerPage != currentImagesPerPage) {
|
||||
_adjustPageForImagesPerPageChange(
|
||||
_lastImagesPerPage, currentImagesPerPage);
|
||||
_lastImagesPerPage = currentImagesPerPage;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust the page number when the number of images per page changes
|
||||
void _adjustPageForImagesPerPageChange(
|
||||
int oldImagesPerPage, int newImagesPerPage) {
|
||||
int previousImageIndex = (page - 1) * oldImagesPerPage;
|
||||
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
|
||||
page = newPage;
|
||||
}
|
||||
}
|
||||
|
||||
abstract mixin class _VolumeListener {
|
||||
bool toNextPage();
|
||||
|
||||
bool toPrevPage();
|
||||
|
||||
bool toNextChapter();
|
||||
|
||||
bool toPrevChapter();
|
||||
|
||||
VolumeListener? volumeListener;
|
||||
|
||||
void onDown() {
|
||||
if (!toNextPage()) {
|
||||
toNextChapter();
|
||||
}
|
||||
}
|
||||
|
||||
void onUp() {
|
||||
if (!toPrevPage()) {
|
||||
toPrevChapter();
|
||||
}
|
||||
}
|
||||
|
||||
void handleVolumeEvent() {
|
||||
if (!App.isAndroid) {
|
||||
// Currently only support Android
|
||||
@@ -260,12 +404,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
volumeListener?.cancel();
|
||||
}
|
||||
volumeListener = VolumeListener(
|
||||
onDown: () {
|
||||
toNextPage();
|
||||
},
|
||||
onUp: () {
|
||||
toPrevPage();
|
||||
},
|
||||
onDown: onDown,
|
||||
onUp: onUp,
|
||||
)..listen();
|
||||
}
|
||||
|
||||
@@ -329,11 +469,8 @@ abstract mixin class _ReaderLocation {
|
||||
|
||||
bool toPage(int page) {
|
||||
if (_validatePage(page)) {
|
||||
if (page == this.page) {
|
||||
if (!(chapter == 1 && page == 1) &&
|
||||
!(chapter == maxChapter && page == maxPage)) {
|
||||
return false;
|
||||
}
|
||||
if (page == this.page && page != 1 && page != maxPage) {
|
||||
return false;
|
||||
}
|
||||
this.page = page;
|
||||
update();
|
||||
@@ -395,9 +532,38 @@ abstract mixin class _ReaderLocation {
|
||||
mixin class _ReaderWindow {
|
||||
bool isFullscreen = false;
|
||||
|
||||
void fullscreen() {
|
||||
windowManager.setFullScreen(!isFullscreen);
|
||||
late WindowFrameController windowFrame;
|
||||
|
||||
bool _isInit = false;
|
||||
|
||||
void initReaderWindow() {
|
||||
if (!App.isDesktop || _isInit) return;
|
||||
windowFrame = WindowFrame.of(App.rootContext);
|
||||
windowFrame.addCloseListener(onWindowClose);
|
||||
_isInit = true;
|
||||
}
|
||||
|
||||
void fullscreen() async {
|
||||
if (!App.isDesktop) return;
|
||||
await windowManager.hide();
|
||||
await windowManager.setFullScreen(!isFullscreen);
|
||||
await windowManager.show();
|
||||
isFullscreen = !isFullscreen;
|
||||
WindowFrame.of(App.rootContext).setWindowFrame(!isFullscreen);
|
||||
}
|
||||
|
||||
bool onWindowClose() {
|
||||
if (Navigator.of(App.rootContext).canPop()) {
|
||||
Navigator.of(App.rootContext).pop();
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void disposeReaderWindow() {
|
||||
if (!App.isDesktop) return;
|
||||
windowFrame.removeCloseListener(onWindowClose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,4 +608,8 @@ abstract interface class _ImageViewController {
|
||||
|
||||
/// Returns true if the event is handled.
|
||||
bool handleOnTap(Offset location);
|
||||
|
||||
Future<Uint8List?> getImageByOffset(Offset offset);
|
||||
|
||||
String? getImageKeyByOffset(Offset offset);
|
||||
}
|
||||
|
@@ -26,73 +26,21 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
|
||||
var lastValue = 0;
|
||||
|
||||
var fABValue = ValueNotifier<double>(0);
|
||||
|
||||
_ReaderGestureDetectorState? _gestureDetectorState;
|
||||
|
||||
_DragListener? _floatingButtonDragListener;
|
||||
|
||||
void setFloatingButton(int value) {
|
||||
lastValue = showFloatingButtonValue;
|
||||
if (value == 0) {
|
||||
if (showFloatingButtonValue != 0) {
|
||||
showFloatingButtonValue = 0;
|
||||
fABValue.value = 0;
|
||||
update();
|
||||
}
|
||||
if (_floatingButtonDragListener != null) {
|
||||
_gestureDetectorState!.removeDragListener(_floatingButtonDragListener!);
|
||||
_floatingButtonDragListener = null;
|
||||
}
|
||||
}
|
||||
var readerMode = context.reader.mode;
|
||||
if (value == 1 && showFloatingButtonValue == 0) {
|
||||
showFloatingButtonValue = 1;
|
||||
_floatingButtonDragListener = _DragListener(
|
||||
onMove: (offset) {
|
||||
if (readerMode == ReaderMode.continuousTopToBottom) {
|
||||
fABValue.value -= offset.dy;
|
||||
} else if (readerMode == ReaderMode.continuousLeftToRight) {
|
||||
fABValue.value -= offset.dx;
|
||||
} else if (readerMode == ReaderMode.continuousRightToLeft) {
|
||||
fABValue.value += offset.dx;
|
||||
}
|
||||
},
|
||||
onEnd: () {
|
||||
if (fABValue.value.abs() > 58 * 3) {
|
||||
setState(() {
|
||||
showFloatingButtonValue = 0;
|
||||
});
|
||||
context.reader.toNextChapter();
|
||||
}
|
||||
fABValue.value = 0;
|
||||
},
|
||||
);
|
||||
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
|
||||
update();
|
||||
} else if (value == -1 && showFloatingButtonValue == 0) {
|
||||
showFloatingButtonValue = -1;
|
||||
_floatingButtonDragListener = _DragListener(
|
||||
onMove: (offset) {
|
||||
if (readerMode == ReaderMode.continuousTopToBottom) {
|
||||
fABValue.value += offset.dy;
|
||||
} else if (readerMode == ReaderMode.continuousLeftToRight) {
|
||||
fABValue.value += offset.dx;
|
||||
} else if (readerMode == ReaderMode.continuousRightToLeft) {
|
||||
fABValue.value -= offset.dx;
|
||||
}
|
||||
},
|
||||
onEnd: () {
|
||||
if (fABValue.value.abs() > 58 * 3) {
|
||||
setState(() {
|
||||
showFloatingButtonValue = 0;
|
||||
});
|
||||
context.reader.toPrevChapter();
|
||||
}
|
||||
fABValue.value = 0;
|
||||
},
|
||||
);
|
||||
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
|
||||
update();
|
||||
}
|
||||
}
|
||||
@@ -179,7 +127,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
Positioned.fill(
|
||||
child: widget.child,
|
||||
),
|
||||
buildPageInfoText(),
|
||||
if (appdata.settings['showPageNumberInReader'] == true)
|
||||
buildPageInfoText(),
|
||||
buildStatusInfo(),
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
@@ -213,7 +162,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(top: context.padding.top),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface.toOpacity(0.82),
|
||||
color: context.colorScheme.surface.toOpacity(0.92),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.toOpacity(0.5),
|
||||
@@ -259,7 +208,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
);
|
||||
}
|
||||
|
||||
void addImageFavorite() {
|
||||
void addImageFavorite() async {
|
||||
try {
|
||||
if (context.reader.images![0].contains('file://')) {
|
||||
showToast(
|
||||
@@ -273,13 +222,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
String title = context.reader.history!.title;
|
||||
String subTitle = context.reader.history!.subtitle;
|
||||
int maxPage = context.reader.images!.length;
|
||||
int page = context.reader.page;
|
||||
int? page = await selectImage();
|
||||
if (page == null) return;
|
||||
page += 1;
|
||||
String sourceKey = context.reader.type.sourceKey;
|
||||
String imageKey = context.reader.images![page - 1];
|
||||
List<String> tags = context.reader.widget.tags;
|
||||
String author = context.reader.widget.author;
|
||||
|
||||
var epName = context.reader.widget.chapters?.values
|
||||
var epName = context.reader.widget.chapters?.titles
|
||||
.elementAtOrNull(context.reader.chapter - 1) ??
|
||||
"E${context.reader.chapter}";
|
||||
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
|
||||
@@ -429,11 +380,12 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
Tooltip(
|
||||
message: "Collect the image".tl,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
isLiked() ? Icons.favorite : Icons.favorite_border),
|
||||
onPressed: addImageFavorite),
|
||||
icon:
|
||||
Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
|
||||
onPressed: addImageFavorite,
|
||||
),
|
||||
),
|
||||
if (App.isWindows)
|
||||
if (App.isDesktop)
|
||||
Tooltip(
|
||||
message: "${"Full Screen".tl}(F12)",
|
||||
child: IconButton(
|
||||
@@ -527,7 +479,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
return BlurEffect(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface.toOpacity(0.82),
|
||||
color: context.colorScheme.surface.toOpacity(0.92),
|
||||
border: isOpen
|
||||
? Border(
|
||||
top: BorderSide(
|
||||
@@ -561,7 +513,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
|
||||
Widget buildPageInfoText() {
|
||||
var epName = context.reader.widget.chapters?.values
|
||||
var epName = context.reader.widget.chapters?.titles
|
||||
.elementAtOrNull(context.reader.chapter - 1) ??
|
||||
"E${context.reader.chapter}";
|
||||
if (epName.length > 8) {
|
||||
@@ -614,99 +566,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
void openChapterDrawer() {
|
||||
showSideBar(
|
||||
context,
|
||||
_ChaptersView(context.reader),
|
||||
context.reader.widget.chapters!.isGrouped
|
||||
? _GroupedChaptersView(context.reader)
|
||||
: _ChaptersView(context.reader),
|
||||
width: 400,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _getCurrentImageData() async {
|
||||
var imageKey = context.reader.images![context.reader.page - 1];
|
||||
var reader = context.reader;
|
||||
if (context.reader.mode.isContinuous) {
|
||||
var continuesState =
|
||||
context.reader._imageViewController as _ContinuousModeState;
|
||||
var imagesOnScreen =
|
||||
continuesState.itemPositionsListener.itemPositions.value;
|
||||
var images = imagesOnScreen
|
||||
.map((e) => context.reader.images!.elementAtOrNull(e.index - 1))
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
String? selected;
|
||||
if (images.length > 1) {
|
||||
await showPopUpWidget(
|
||||
context,
|
||||
PopUpWidgetScaffold(
|
||||
title: "Select an image on screen".tl,
|
||||
body: GridView.builder(
|
||||
itemCount: images.length,
|
||||
itemBuilder: (context, index) {
|
||||
ImageProvider image;
|
||||
var imageKey = images[index];
|
||||
if (imageKey.startsWith('file://')) {
|
||||
image = FileImage(File(imageKey.replaceFirst("file://", '')));
|
||||
} else {
|
||||
image = ReaderImageProvider(
|
||||
imageKey,
|
||||
reader.type.comicSource!.key,
|
||||
reader.cid,
|
||||
reader.eid,
|
||||
reader.page,
|
||||
);
|
||||
}
|
||||
return InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
onTap: () {
|
||||
selected = images[index];
|
||||
App.rootContext.pop();
|
||||
},
|
||||
child: Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: Image(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
image: image,
|
||||
),
|
||||
),
|
||||
).padding(const EdgeInsets.all(8));
|
||||
},
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 200,
|
||||
childAspectRatio: 0.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
selected = images.first;
|
||||
}
|
||||
if (selected == null) {
|
||||
return null;
|
||||
} else {
|
||||
imageKey = selected!;
|
||||
}
|
||||
}
|
||||
if (imageKey.startsWith("file://")) {
|
||||
return await File(imageKey.substring(7)).readAsBytes();
|
||||
} else {
|
||||
return (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
||||
.readAsBytes();
|
||||
}
|
||||
}
|
||||
|
||||
void saveCurrentImage() async {
|
||||
var data = await _getCurrentImageData();
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
@@ -716,7 +584,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
|
||||
void share() async {
|
||||
var data = await _getCurrentImageData();
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
@@ -776,67 +644,106 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
);
|
||||
case -1:
|
||||
case 1:
|
||||
return Container(
|
||||
return SizedBox(
|
||||
width: 58,
|
||||
height: 58,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: fABValue,
|
||||
builder: (context, value, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (showFloatingButtonValue == 1) {
|
||||
context.reader.toNextChapter();
|
||||
} else if (showFloatingButtonValue == -1) {
|
||||
context.reader.toPrevChapter();
|
||||
}
|
||||
setFloatingButton(0);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
showFloatingButtonValue == 1
|
||||
? Icons.arrow_forward_ios
|
||||
: Icons.arrow_back_ios_outlined,
|
||||
size: 24,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: value.clamp(0, 58 * 3) / 3,
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceTint
|
||||
.toOpacity(0.2),
|
||||
child: const SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (showFloatingButtonValue == 1) {
|
||||
context.reader.toNextChapter();
|
||||
} else if (showFloatingButtonValue == -1) {
|
||||
context.reader.toPrevChapter();
|
||||
}
|
||||
setFloatingButton(0);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
showFloatingButtonValue == 1
|
||||
? Icons.arrow_forward_ios
|
||||
: Icons.arrow_back_ios_outlined,
|
||||
size: 24,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
/// If there is only one image on screen, return it.
|
||||
///
|
||||
/// If there are multiple images on screen,
|
||||
/// show an overlay to let the user select an image.
|
||||
///
|
||||
/// The return value is the index of the selected image.
|
||||
Future<int?> selectImage() async {
|
||||
var reader = context.reader;
|
||||
var imageViewController = context.reader._imageViewController;
|
||||
if (imageViewController is _GalleryModeState && reader.imagesPerPage == 1) {
|
||||
return reader.page - 1;
|
||||
} else {
|
||||
var location = await _showSelectImageOverlay();
|
||||
if (location == null) {
|
||||
return null;
|
||||
}
|
||||
var imageKey = imageViewController!.getImageKeyByOffset(location);
|
||||
if (imageKey == null) {
|
||||
return null;
|
||||
}
|
||||
return reader.images!.indexOf(imageKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [selectImage], but return the image data.
|
||||
Future<Uint8List?> selectImageToData() async {
|
||||
var i = await selectImage();
|
||||
if (i == null) {
|
||||
return null;
|
||||
}
|
||||
var imageKey = context.reader.images![i];
|
||||
if (imageKey.startsWith("file://")) {
|
||||
return await File(imageKey.substring(7)).readAsBytes();
|
||||
} else {
|
||||
return (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
||||
.readAsBytes();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Offset?> _showSelectImageOverlay() {
|
||||
if (_isOpen) {
|
||||
openOrClose();
|
||||
}
|
||||
|
||||
var completer = Completer<Offset?>();
|
||||
|
||||
var overlay = Overlay.of(context);
|
||||
OverlayEntry? entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return Positioned.fill(
|
||||
child: _SelectImageOverlayContent(onTap: (offset) {
|
||||
completer.complete(offset);
|
||||
entry!.remove();
|
||||
}, onDispose: () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
overlay.insert(entry);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
|
||||
class _BatteryWidget extends StatefulWidget {
|
||||
@@ -1018,77 +925,67 @@ class _ClockWidgetState extends State<_ClockWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChaptersView extends StatefulWidget {
|
||||
const _ChaptersView(this.reader);
|
||||
class _SelectImageOverlayContent extends StatefulWidget {
|
||||
const _SelectImageOverlayContent({
|
||||
required this.onTap,
|
||||
required this.onDispose,
|
||||
});
|
||||
|
||||
final _ReaderState reader;
|
||||
final void Function(Offset) onTap;
|
||||
|
||||
final void Function() onDispose;
|
||||
|
||||
@override
|
||||
State<_ChaptersView> createState() => _ChaptersViewState();
|
||||
State<_SelectImageOverlayContent> createState() => _SelectImageOverlayContentState();
|
||||
}
|
||||
|
||||
class _ChaptersViewState extends State<_ChaptersView> {
|
||||
bool desc = false;
|
||||
class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.onDispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var chapters = widget.reader.widget.chapters!;
|
||||
var current = widget.reader.chapter - 1;
|
||||
return Scaffold(
|
||||
body: SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(
|
||||
title: Text("Chapters".tl),
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: "Click to change the order".tl,
|
||||
child: TextButton.icon(
|
||||
icon: Icon(
|
||||
!desc ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(!desc ? "Ascending".tl : "Descending".tl),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
desc = !desc;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapUp: (details) {
|
||||
widget.onTap(details.globalPosition);
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(50),
|
||||
child: Align(
|
||||
alignment: Alignment(
|
||||
0,
|
||||
-0.8,
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (desc) {
|
||||
index = chapters.length - 1 - index;
|
||||
}
|
||||
var chapter = chapters.values.elementAt(index);
|
||||
return ListTile(
|
||||
shape: Border(
|
||||
left: BorderSide(
|
||||
color: current == index
|
||||
? context.colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: 4,
|
||||
),
|
||||
child: Container(
|
||||
width: 232,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.info_outline),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
"Click to select an image".tl,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: context.colorScheme.onSurface,
|
||||
),
|
||||
title: Text(
|
||||
chapter,
|
||||
style: current == index
|
||||
? ts.withColor(context.colorScheme.primary).bold
|
||||
: null,
|
||||
),
|
||||
onTap: () {
|
||||
widget.reader.toChapter(index + 1);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
},
|
||||
childCount: chapters.length,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -441,6 +441,11 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var sources = ComicSource.all();
|
||||
var enabled = appdata.settings['searchSources'] as List;
|
||||
sources.removeWhere((e) {
|
||||
return !enabled.contains(e.key);
|
||||
});
|
||||
return ContentDialog(
|
||||
title: "Settings".tl,
|
||||
content: Column(
|
||||
@@ -452,7 +457,7 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: ComicSource.all().map((e) {
|
||||
children: sources.map((e) {
|
||||
return OptionChip(
|
||||
text: e.name.tl,
|
||||
isSelected: searchTarget == e.key,
|
||||
|
@@ -140,17 +140,6 @@ class _AppSettingsState extends State<AppSettings> {
|
||||
},
|
||||
actionTitle: 'Set'.tl,
|
||||
).toSliver(),
|
||||
_SettingPartTitle(
|
||||
title: "Log".tl,
|
||||
icon: Icons.error_outline,
|
||||
),
|
||||
_CallbackSetting(
|
||||
title: "Open Log".tl,
|
||||
callback: () {
|
||||
context.to(() => const LogsPage());
|
||||
},
|
||||
actionTitle: 'Open'.tl,
|
||||
).toSliver(),
|
||||
_SettingPartTitle(
|
||||
title: "User".tl,
|
||||
icon: Icons.person_outline,
|
||||
@@ -330,9 +319,9 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
String url = "";
|
||||
String user = "";
|
||||
String pass = "";
|
||||
bool autoSync = true;
|
||||
|
||||
bool isTesting = false;
|
||||
|
||||
bool upload = true;
|
||||
|
||||
@override
|
||||
@@ -348,6 +337,15 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
url = configs[0];
|
||||
user = configs[1];
|
||||
pass = configs[2];
|
||||
autoSync = appdata.implicitData['webdavAutoSync'] ?? true;
|
||||
}
|
||||
|
||||
void onAutoSyncChanged(bool value) {
|
||||
setState(() {
|
||||
autoSync = value;
|
||||
appdata.implicitData['webdavAutoSync'] = value;
|
||||
appdata.writeImplicitData();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -359,8 +357,9 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
labelText: "URL",
|
||||
hintText: "A valid WebDav directory URL".tl,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
controller: TextEditingController(text: url),
|
||||
@@ -385,6 +384,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
onChanged: (value) => pass = value,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
leading: Icon(Icons.sync),
|
||||
title: Text("Auto Sync Data".tl),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
trailing: Switch(
|
||||
value: autoSync,
|
||||
onChanged: onAutoSyncChanged,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text("Operation".tl),
|
||||
@@ -411,12 +420,60 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: autoSync
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
"Once the operation is successful, app will automatically sync data with the server."
|
||||
.tl),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isTesting,
|
||||
onPressed: () async {
|
||||
var oldConfig = appdata.settings['webdav'];
|
||||
var oldAutoSync = appdata.implicitData['webdavAutoSync'];
|
||||
|
||||
if (url.trim().isEmpty &&
|
||||
user.trim().isEmpty &&
|
||||
pass.trim().isEmpty) {
|
||||
appdata.settings['webdav'] = [];
|
||||
appdata.implicitData['webdavAutoSync'] = false;
|
||||
appdata.writeImplicitData();
|
||||
appdata.saveData();
|
||||
context.showMessage(message: "Saved".tl);
|
||||
App.rootPop();
|
||||
return;
|
||||
}
|
||||
|
||||
appdata.settings['webdav'] = [url, user, pass];
|
||||
appdata.implicitData['webdavAutoSync'] = autoSync;
|
||||
appdata.writeImplicitData();
|
||||
|
||||
if (!autoSync) {
|
||||
appdata.saveData();
|
||||
context.showMessage(message: "Saved".tl);
|
||||
App.rootPop();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isTesting = true;
|
||||
});
|
||||
@@ -428,12 +485,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
isTesting = false;
|
||||
});
|
||||
appdata.settings['webdav'] = oldConfig;
|
||||
appdata.implicitData['webdavAutoSync'] = oldAutoSync;
|
||||
appdata.writeImplicitData();
|
||||
appdata.saveData();
|
||||
context.showMessage(message: testResult.errorMessage!);
|
||||
return;
|
||||
context.showMessage(message: "Saved Failed".tl);
|
||||
} else {
|
||||
appdata.saveData();
|
||||
context.showMessage(message: "Saved".tl);
|
||||
App.rootPop();
|
||||
}
|
||||
appdata.saveData();
|
||||
context.showMessage(message: "Saved".tl);
|
||||
App.rootPop();
|
||||
},
|
||||
child: Text("Continue".tl),
|
||||
),
|
||||
|
95
lib/pages/settings/debug.dart
Normal file
95
lib/pages/settings/debug.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
part of 'settings_page.dart';
|
||||
|
||||
class DebugPage extends StatefulWidget {
|
||||
const DebugPage({super.key});
|
||||
|
||||
@override
|
||||
State<DebugPage> createState() => DebugPageState();
|
||||
}
|
||||
|
||||
class DebugPageState extends State<DebugPage> {
|
||||
final controller = TextEditingController();
|
||||
|
||||
var result = "";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(title: Text("Debug".tl)),
|
||||
_CallbackSetting(
|
||||
title: "Reload Configs",
|
||||
actionTitle: "Reload",
|
||||
callback: () {
|
||||
ComicSourceManager().reload();
|
||||
},
|
||||
).toSliver(),
|
||||
_CallbackSetting(
|
||||
title: "Open Log".tl,
|
||||
callback: () {
|
||||
context.to(() => const LogsPage());
|
||||
},
|
||||
actionTitle: 'Open'.tl,
|
||||
).toSliver(),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"JS Evaluator",
|
||||
style: TextStyle(fontSize: 16),
|
||||
).toAlign(Alignment.centerLeft).paddingLeft(16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlign: TextAlign.start,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.all(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
try {
|
||||
var res = JsEngine().runCode(controller.text);
|
||||
setState(() {
|
||||
result = res.toString();
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
result = e.toString();
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text("Run"),
|
||||
).toAlign(Alignment.centerRight).paddingRight(16),
|
||||
const Text(
|
||||
"Result",
|
||||
style: TextStyle(fontSize: 16),
|
||||
).toAlign(Alignment.centerLeft).paddingLeft(16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: context.colorScheme.outline),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(result).paddingAll(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@@ -25,8 +25,8 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
||||
title: "Size of comic tile".tl,
|
||||
settingsIndex: "comicTileScale",
|
||||
interval: 0.05,
|
||||
min: 0.75,
|
||||
max: 1.25,
|
||||
min: 0.5,
|
||||
max: 1.5,
|
||||
).toSliver(),
|
||||
_PopupWindowSetting(
|
||||
title: "Explore Pages".tl,
|
||||
@@ -80,6 +80,24 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
||||
'japanese': "Japanese",
|
||||
},
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Initial Page".tl,
|
||||
settingKey: "initialPage",
|
||||
optionTranslation: {
|
||||
'0': "Home Page".tl,
|
||||
'1': "Favorites Page".tl,
|
||||
'2': "Explore Page".tl,
|
||||
'3': "Categories Page".tl,
|
||||
},
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Display mode of comic list".tl,
|
||||
settingKey: "comicListDisplayMode",
|
||||
optionTranslation: {
|
||||
"paging": "Paging".tl,
|
||||
"Continuous": "Continuous".tl,
|
||||
},
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@@ -48,10 +48,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
"continuousTopToBottom": "Continuous (Top to Bottom)".tl,
|
||||
},
|
||||
onChanged: () {
|
||||
setState(() {});
|
||||
var readerMode = appdata.settings['readerMode'];
|
||||
if (readerMode?.toLowerCase().startsWith('continuous') ?? false) {
|
||||
appdata.settings['readerScreenPicNumber'] = 1;
|
||||
widget.onChanged?.call('readerScreenPicNumber');
|
||||
appdata.settings['readerScreenPicNumberForLandscape'] = 1;
|
||||
widget.onChanged?.call('readerScreenPicNumberForLandscape');
|
||||
appdata.settings['readerScreenPicNumberForPortrait'] = 1;
|
||||
widget.onChanged?.call('readerScreenPicNumberForPortrait');
|
||||
}
|
||||
widget.onChanged?.call("readerMode");
|
||||
},
|
||||
@@ -63,43 +66,72 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
min: 1,
|
||||
max: 20,
|
||||
onChanged: () {
|
||||
setState(() {});
|
||||
widget.onChanged?.call("autoPageTurningInterval");
|
||||
},
|
||||
).toSliver(),
|
||||
SliverToBoxAdapter(
|
||||
child: AbsorbPointer(
|
||||
absorbing: (appdata.settings['readerMode']
|
||||
?.toLowerCase()
|
||||
.startsWith('continuous') ??
|
||||
false),
|
||||
child: AnimatedOpacity(
|
||||
opacity: (appdata.settings['readerMode']
|
||||
?.toLowerCase()
|
||||
.startsWith('continuous') ??
|
||||
false)
|
||||
? 0.5
|
||||
: 1.0,
|
||||
duration: Duration(milliseconds: 300),
|
||||
child: _SliderSetting(
|
||||
title: "The number of pic in screen (Only Gallery Mode)".tl,
|
||||
settingsIndex: "readerScreenPicNumber",
|
||||
interval: 1,
|
||||
min: 1,
|
||||
max: 5,
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("readerScreenPicNumber");
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['readerMode']!.startsWith('gallery'),
|
||||
child: _SliderSetting(
|
||||
title:
|
||||
"The number of pic in screen for landscape (Only Gallery Mode)"
|
||||
.tl,
|
||||
settingsIndex: "readerScreenPicNumberForLandscape",
|
||||
interval: 1,
|
||||
min: 1,
|
||||
max: 5,
|
||||
onChanged: () {
|
||||
setState(() {});
|
||||
widget.onChanged?.call("readerScreenPicNumberForLandscape");
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['readerMode']!.startsWith('gallery'),
|
||||
child: _SliderSetting(
|
||||
title:
|
||||
"The number of pic in screen for portrait (Only Gallery Mode)"
|
||||
.tl,
|
||||
settingsIndex: "readerScreenPicNumberForPortrait",
|
||||
interval: 1,
|
||||
min: 1,
|
||||
max: 5,
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("readerScreenPicNumberForPortrait");
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['readerMode']!.startsWith('gallery') &&
|
||||
(appdata.settings['readerScreenPicNumberForLandscape'] > 1 ||
|
||||
appdata.settings['readerScreenPicNumberForPortrait'] > 1),
|
||||
child: _SwitchSetting(
|
||||
title: "Show single image on first page".tl,
|
||||
settingKey: "showSingleImageOnFirstPage",
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("showSingleImageOnFirstPage");
|
||||
},
|
||||
),
|
||||
),
|
||||
_SwitchSetting(
|
||||
title: 'Long press to zoom'.tl,
|
||||
settingKey: 'enableLongPressToZoom',
|
||||
onChanged: () {
|
||||
setState(() {});
|
||||
widget.onChanged?.call('enableLongPressToZoom');
|
||||
},
|
||||
).toSliver(),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['enableLongPressToZoom'] == true,
|
||||
child: SelectSetting(
|
||||
title: "Long press zoom position".tl,
|
||||
settingKey: "longPressZoomPosition",
|
||||
optionTranslation: {
|
||||
"press": "Press position".tl,
|
||||
"center": "Screen center".tl,
|
||||
},
|
||||
),
|
||||
),
|
||||
_SwitchSetting(
|
||||
title: 'Limit image width'.tl,
|
||||
subtitle: 'When using Continuous(Top to Bottom) mode'.tl,
|
||||
@@ -150,6 +182,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
min: 1,
|
||||
max: 16,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Show Page Number".tl,
|
||||
settingKey: "showPageNumberInReader",
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("showPageNumberInReader");
|
||||
},
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@@ -30,6 +30,7 @@ part 'local_favorites.dart';
|
||||
part 'app.dart';
|
||||
part 'about.dart';
|
||||
part 'network.dart';
|
||||
part 'debug.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({this.initialPage = -1, super.key});
|
||||
@@ -55,6 +56,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
"APP",
|
||||
"Network",
|
||||
"About",
|
||||
"Debug"
|
||||
];
|
||||
|
||||
final icons = <IconData>[
|
||||
@@ -64,7 +66,8 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
Icons.collections_bookmark_rounded,
|
||||
Icons.apps,
|
||||
Icons.public,
|
||||
Icons.info
|
||||
Icons.info,
|
||||
Icons.bug_report,
|
||||
];
|
||||
|
||||
double offset = 0;
|
||||
@@ -246,6 +249,9 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
}
|
||||
|
||||
void handlePointerDown(PointerDownEvent event) {
|
||||
if (!App.isIOS) {
|
||||
return;
|
||||
}
|
||||
if (event.position.dx < 20) {
|
||||
gestureRecognizer.addPointer(event);
|
||||
}
|
||||
@@ -350,6 +356,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
4 => const AppSettings(),
|
||||
5 => const NetworkSettings(),
|
||||
6 => const AboutSettings(),
|
||||
7 => const DebugPage(),
|
||||
_ => throw UnimplementedError()
|
||||
};
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@ import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/proxy.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'dart:io' as io;
|
||||
@@ -308,7 +308,7 @@ class DesktopWebview {
|
||||
useWindowPositionAndSize: true,
|
||||
userDataFolderWindows: "${App.dataPath}\\webview",
|
||||
title: "webview",
|
||||
proxy: AppDio.proxy,
|
||||
proxy: await getProxy(),
|
||||
));
|
||||
_webview!.addOnWebMessageReceivedCallback(onMessage);
|
||||
_webview!.setOnNavigation((s) {
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_7zip/flutter_7zip.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
@@ -111,7 +112,7 @@ abstract class CBZ {
|
||||
var ext = e.path.split('.').last;
|
||||
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
|
||||
});
|
||||
if(files.isEmpty) {
|
||||
if (files.isEmpty) {
|
||||
cache.deleteSync(recursive: true);
|
||||
throw Exception('No images found in the archive');
|
||||
}
|
||||
@@ -140,8 +141,7 @@ abstract class CBZ {
|
||||
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
|
||||
);
|
||||
dest.createSync();
|
||||
coverFile.copyMem(
|
||||
FilePath.join(dest.path, 'cover.${coverFile.extension}'));
|
||||
coverFile.copyMem(FilePath.join(dest.path, 'cover.${coverFile.extension}'));
|
||||
if (metaData.chapters == null) {
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
var src = files[i];
|
||||
@@ -176,7 +176,7 @@ abstract class CBZ {
|
||||
tags: metaData.tags,
|
||||
comicType: ComicType.local,
|
||||
directory: dest.name,
|
||||
chapters: cpMap,
|
||||
chapters: ComicChapters.fromJsonOrNull(cpMap),
|
||||
downloadedChapters: cpMap?.keys.toList() ?? [],
|
||||
cover: 'cover.${coverFile.extension}',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -232,17 +232,19 @@ abstract class CBZ {
|
||||
}
|
||||
}
|
||||
var cover = comic.coverFile;
|
||||
await cover
|
||||
.copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
|
||||
await cover.copyMem(
|
||||
FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
|
||||
final metaData = ComicMetaData(
|
||||
title: comic.title,
|
||||
author: comic.subtitle,
|
||||
tags: comic.tags,
|
||||
chapters: chapters,
|
||||
);
|
||||
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
|
||||
jsonEncode(
|
||||
ComicMetaData(
|
||||
title: comic.title,
|
||||
author: comic.subtitle,
|
||||
tags: comic.tags,
|
||||
chapters: chapters,
|
||||
).toJson(),
|
||||
),
|
||||
jsonEncode(metaData),
|
||||
);
|
||||
await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString(
|
||||
_buildComicInfoXml(metaData),
|
||||
);
|
||||
var cbz = File(outFilePath);
|
||||
if (cbz.existsSync()) cbz.deleteSync();
|
||||
@@ -251,7 +253,54 @@ abstract class CBZ {
|
||||
return cbz;
|
||||
}
|
||||
|
||||
static String _buildComicInfoXml(ComicMetaData data) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('<?xml version="1.0" encoding="utf-8"?>');
|
||||
buffer.writeln('<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">');
|
||||
|
||||
buffer.writeln(' <Title>${_escapeXml(data.title)}</Title>');
|
||||
buffer.writeln(' <Series>${_escapeXml(data.title)}</Series>');
|
||||
|
||||
if (data.author.isNotEmpty) {
|
||||
buffer.writeln(' <Writer>${_escapeXml(data.author)}</Writer>');
|
||||
}
|
||||
|
||||
if (data.tags.isNotEmpty) {
|
||||
var tags = data.tags;
|
||||
if (tags.length > 5) {
|
||||
tags = tags.sublist(0, 5);
|
||||
}
|
||||
buffer.writeln(' <Genre>${_escapeXml(tags.join(', '))}</Genre>');
|
||||
}
|
||||
|
||||
if (data.chapters != null && data.chapters!.isNotEmpty) {
|
||||
final chaptersInfo = data.chapters!.map((chapter) =>
|
||||
'${_escapeXml(chapter.title)}: ${chapter.start}-${chapter.end}'
|
||||
).join('; ');
|
||||
buffer.writeln(' <Notes>Chapters: $chaptersInfo</Notes>');
|
||||
}
|
||||
|
||||
buffer.writeln(' <Manga>Unknown</Manga>');
|
||||
buffer.writeln(' <BlackAndWhite>Unknown</BlackAndWhite>');
|
||||
|
||||
final now = DateTime.now();
|
||||
buffer.writeln(' <Year>${now.year}</Year>');
|
||||
|
||||
buffer.writeln('</ComicInfo>');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
static String _escapeXml(String text) {
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
static _compress(String src, String dst) async {
|
||||
await ZipFile.compressFolderAsync(src, dst, 4);
|
||||
}
|
||||
}
|
||||
|
||||
|
25
lib/utils/clipboard_image.dart
Normal file
25
lib/utils/clipboard_image.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
Future<void> writeImageToClipboard(Uint8List imageBytes) async {
|
||||
const channel = MethodChannel("venera/clipboard");
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
var image = await instantiateImageCodec(imageBytes);
|
||||
var frame = await image.getNextFrame();
|
||||
var data = await frame.image.toByteData(format: ImageByteFormat.rawRgba);
|
||||
await channel.invokeMethod("writeImageToClipboard", {
|
||||
"width": frame.image.width,
|
||||
"height": frame.image.height,
|
||||
"data": Uint8List.view(data!.buffer)
|
||||
});
|
||||
image.dispose();
|
||||
} else if (Platform.isMacOS) {
|
||||
await channel.invokeMethod("writeImageToClipboard", {
|
||||
"data": imageBytes,
|
||||
});
|
||||
} else {
|
||||
throw UnsupportedError("Clipboard image is not supported on this platform");
|
||||
}
|
||||
}
|
@@ -95,15 +95,17 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
|
||||
}
|
||||
var comicSourceDir = FilePath.join(cacheDirPath, "comic_source");
|
||||
if (Directory(comicSourceDir).existsSync()) {
|
||||
Directory(FilePath.join(App.dataPath, "comic_source"))
|
||||
.deleteIfExistsSync(recursive: true);
|
||||
Directory(FilePath.join(App.dataPath, "comic_source")).createSync();
|
||||
for (var file in Directory(comicSourceDir).listSync()) {
|
||||
if (file is File) {
|
||||
var targetFile =
|
||||
FilePath.join(App.dataPath, "comic_source", file.name);
|
||||
File(targetFile).deleteIfExistsSync();
|
||||
await file.copy(targetFile);
|
||||
}
|
||||
}
|
||||
await ComicSource.reload();
|
||||
await ComicSourceManager().reload();
|
||||
}
|
||||
} finally {
|
||||
cacheDir.deleteIgnoreError(recursive: true);
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/components/window_frame.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/utils/data.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:webdav_client/webdav_client.dart' hide File;
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import 'io.dart';
|
||||
|
||||
@@ -19,7 +21,11 @@ class DataSync with ChangeNotifier {
|
||||
downloadData();
|
||||
}
|
||||
LocalFavoritesManager().addListener(onDataChanged);
|
||||
ComicSource.addListener(onDataChanged);
|
||||
ComicSourceManager().addListener(onDataChanged);
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
var controller = WindowFrame.of(App.rootContext);
|
||||
controller.addCloseListener(_handleWindowClose);
|
||||
});
|
||||
}
|
||||
|
||||
void onDataChanged() {
|
||||
@@ -28,27 +34,61 @@ class DataSync with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
bool _handleWindowClose() {
|
||||
if (_isUploading) {
|
||||
_showWindowCloseDialog();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _showWindowCloseDialog() async {
|
||||
showLoadingDialog(
|
||||
App.rootContext,
|
||||
cancelButtonText: "Shut Down".tl,
|
||||
onCancel: () => exit(0),
|
||||
barrierDismissible: false,
|
||||
message: "Uploading data...".tl,
|
||||
);
|
||||
while (_isUploading) {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
exit(0);
|
||||
}
|
||||
|
||||
static DataSync? instance;
|
||||
|
||||
factory DataSync() => instance ?? (instance = DataSync._());
|
||||
|
||||
bool isDownloading = false;
|
||||
bool _isDownloading = false;
|
||||
|
||||
bool isUploading = false;
|
||||
bool get isDownloading => _isDownloading;
|
||||
|
||||
bool haveWaitingTask = false;
|
||||
bool _isUploading = false;
|
||||
|
||||
bool get isUploading => _isUploading;
|
||||
|
||||
bool _haveWaitingTask = false;
|
||||
|
||||
String? _lastError;
|
||||
|
||||
String? get lastError => _lastError;
|
||||
|
||||
bool get isEnabled {
|
||||
var config = appdata.settings['webdav'];
|
||||
return config is List && config.isNotEmpty;
|
||||
var autoSync = appdata.implicitData['webdavAutoSync'] ?? false;
|
||||
return autoSync && config is List && config.isNotEmpty;
|
||||
}
|
||||
|
||||
List<String>? _validateConfig() {
|
||||
var config = appdata.settings['webdav'];
|
||||
if (config is! List || (config.isNotEmpty && config.length != 3)) {
|
||||
if (config is! List) {
|
||||
return null;
|
||||
}
|
||||
if (config.whereType<String>().length != 3) {
|
||||
if (config.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
if (config.length != 3 || config.whereType<String>().length != 3) {
|
||||
return null;
|
||||
}
|
||||
return List.from(config);
|
||||
@@ -56,17 +96,19 @@ class DataSync with ChangeNotifier {
|
||||
|
||||
Future<Res<bool>> uploadData() async {
|
||||
if (isDownloading) return const Res(true);
|
||||
if (haveWaitingTask) return const Res(true);
|
||||
if (_haveWaitingTask) return const Res(true);
|
||||
while (isUploading) {
|
||||
haveWaitingTask = true;
|
||||
_haveWaitingTask = true;
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
haveWaitingTask = false;
|
||||
isUploading = true;
|
||||
_haveWaitingTask = false;
|
||||
_isUploading = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
var config = _validateConfig();
|
||||
if (config == null) {
|
||||
_lastError = 'Invalid WebDAV configuration';
|
||||
return const Res.error('Invalid WebDAV configuration');
|
||||
}
|
||||
if (config.isEmpty) {
|
||||
@@ -76,27 +118,13 @@ class DataSync with ChangeNotifier {
|
||||
String user = config[1];
|
||||
String pass = config[2];
|
||||
|
||||
var proxy = await AppDio.getProxy();
|
||||
|
||||
var client = newClient(
|
||||
url,
|
||||
user: user,
|
||||
password: pass,
|
||||
adapter: IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
return HttpClient()
|
||||
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
|
||||
},
|
||||
),
|
||||
adapter: RHttpAdapter(),
|
||||
);
|
||||
|
||||
try {
|
||||
await client.ping();
|
||||
} catch (e) {
|
||||
Log.error("Upload Data", 'Failed to connect to WebDAV server');
|
||||
return const Res.error('Failed to connect to WebDAV server');
|
||||
}
|
||||
|
||||
try {
|
||||
appdata.settings['dataVersion']++;
|
||||
await appdata.saveData(false);
|
||||
@@ -123,26 +151,29 @@ class DataSync with ChangeNotifier {
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Upload Data", e, s);
|
||||
_lastError = e.toString();
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
} finally {
|
||||
isUploading = false;
|
||||
_isUploading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<bool>> downloadData() async {
|
||||
if (haveWaitingTask) return const Res(true);
|
||||
if (_haveWaitingTask) return const Res(true);
|
||||
while (isDownloading || isUploading) {
|
||||
haveWaitingTask = true;
|
||||
_haveWaitingTask = true;
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
haveWaitingTask = false;
|
||||
isDownloading = true;
|
||||
_haveWaitingTask = false;
|
||||
_isDownloading = true;
|
||||
_lastError = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
var config = _validateConfig();
|
||||
if (config == null) {
|
||||
_lastError = 'Invalid WebDAV configuration';
|
||||
return const Res.error('Invalid WebDAV configuration');
|
||||
}
|
||||
if (config.isEmpty) {
|
||||
@@ -152,27 +183,13 @@ class DataSync with ChangeNotifier {
|
||||
String user = config[1];
|
||||
String pass = config[2];
|
||||
|
||||
var proxy = await AppDio.getProxy();
|
||||
|
||||
var client = newClient(
|
||||
url,
|
||||
user: user,
|
||||
password: pass,
|
||||
adapter: IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
return HttpClient()
|
||||
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
|
||||
},
|
||||
),
|
||||
adapter: RHttpAdapter(),
|
||||
);
|
||||
|
||||
try {
|
||||
await client.ping();
|
||||
} catch (e) {
|
||||
Log.error("Data Sync", 'Failed to connect to WebDAV server');
|
||||
return const Res.error('Failed to connect to WebDAV server');
|
||||
}
|
||||
|
||||
try {
|
||||
var files = await client.readDir('/');
|
||||
files.sort((a, b) => b.name!.compareTo(a.name!));
|
||||
@@ -198,10 +215,11 @@ class DataSync with ChangeNotifier {
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Data Sync", e, s);
|
||||
_lastError = e.toString();
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
_isDownloading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
22
lib/utils/handle_text_share.dart
Normal file
22
lib/utils/handle_text_share.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/pages/aggregated_search_page.dart';
|
||||
|
||||
bool _isHandling = false;
|
||||
|
||||
/// Handle text share event.
|
||||
/// App will navigate to [AggregatedSearchPage] with the shared text as keyword.
|
||||
void handleTextShare() async {
|
||||
if (_isHandling) return;
|
||||
_isHandling = true;
|
||||
|
||||
var channel = EventChannel('venera/text_share');
|
||||
await for (var event in channel.receiveBroadcastStream()) {
|
||||
if (App.mainNavigatorKey == null) {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
if (event is String) {
|
||||
App.rootContext.to(() => AggregatedSearchPage(keyword: event));
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
@@ -262,7 +263,9 @@ class ImportComic {
|
||||
subtitle: subtitle ?? '',
|
||||
tags: tags ?? [],
|
||||
directory: directory.path,
|
||||
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
|
||||
chapters: hasChapters
|
||||
? ComicChapters(Map.fromIterables(chapters, chapters))
|
||||
: null,
|
||||
cover: coverPath,
|
||||
comicType: ComicType.local,
|
||||
downloadedChapters: chapters,
|
||||
|
40
lib/utils/init.dart
Normal file
40
lib/utils/init.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// A mixin class that provides a way to ensure the class is initialized.
|
||||
abstract mixin class Init {
|
||||
bool _isInit = false;
|
||||
|
||||
final _initCompleter = <Completer<void>>[];
|
||||
|
||||
/// Ensure the class is initialized.
|
||||
Future<void> ensureInit() async {
|
||||
if (_isInit) {
|
||||
return;
|
||||
}
|
||||
var completer = Completer<void>();
|
||||
_initCompleter.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
Future<void> _markInit() async {
|
||||
_isInit = true;
|
||||
for (var completer in _initCompleter) {
|
||||
completer.complete();
|
||||
}
|
||||
_initCompleter.clear();
|
||||
}
|
||||
|
||||
@protected
|
||||
Future<void> doInit();
|
||||
|
||||
/// Initialize the class.
|
||||
Future<void> init() async {
|
||||
if (_isInit) {
|
||||
return;
|
||||
}
|
||||
await doInit();
|
||||
await _markInit();
|
||||
}
|
||||
}
|
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
@@ -40,6 +39,7 @@ extension FileSystemEntityExt on FileSystemEntity {
|
||||
return p.basename(path);
|
||||
}
|
||||
|
||||
/// Delete the file or directory and ignore errors.
|
||||
Future<void> deleteIgnoreError({bool recursive = false}) async {
|
||||
try {
|
||||
await delete(recursive: recursive);
|
||||
@@ -48,12 +48,14 @@ extension FileSystemEntityExt on FileSystemEntity {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the file or directory if it exists.
|
||||
Future<void> deleteIfExists({bool recursive = false}) async {
|
||||
if (existsSync()) {
|
||||
await delete(recursive: recursive);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the file or directory if it exists.
|
||||
void deleteIfExistsSync({bool recursive = false}) {
|
||||
if (existsSync()) {
|
||||
deleteSync(recursive: recursive);
|
||||
@@ -74,12 +76,14 @@ extension FileExtension on File {
|
||||
await newFile.writeAsBytes(await readAsBytes());
|
||||
}
|
||||
|
||||
/// Get the base name of the file without the extension.
|
||||
String get basenameWithoutExt {
|
||||
return p.basenameWithoutExtension(path);
|
||||
}
|
||||
}
|
||||
|
||||
extension DirectoryExtension on Directory {
|
||||
/// Calculate the size of the directory.
|
||||
Future<int> get size async {
|
||||
if (!existsSync()) return 0;
|
||||
int total = 0;
|
||||
@@ -91,6 +95,7 @@ extension DirectoryExtension on Directory {
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Change the base name of the directory.
|
||||
Directory renameX(String newName) {
|
||||
newName = sanitizeFileName(newName);
|
||||
return renameSync(path.replaceLast(name, newName));
|
||||
@@ -100,6 +105,7 @@ extension DirectoryExtension on Directory {
|
||||
return File(FilePath.join(path, name));
|
||||
}
|
||||
|
||||
/// Delete the contents of the directory.
|
||||
void deleteContentsSync({recursive = true}) {
|
||||
if (!existsSync()) return;
|
||||
for (var f in listSync()) {
|
||||
@@ -107,33 +113,46 @@ extension DirectoryExtension on Directory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the contents of the directory.
|
||||
Future<void> deleteContents({recursive = true}) async {
|
||||
if (!existsSync()) return;
|
||||
for (var f in listSync()) {
|
||||
await f.deleteIfExists(recursive: recursive);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the directory. If the directory already exists, delete it first.
|
||||
void forceCreateSync() {
|
||||
if (existsSync()) {
|
||||
deleteSync(recursive: true);
|
||||
}
|
||||
createSync(recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
String sanitizeFileName(String fileName) {
|
||||
if (fileName.endsWith('.')) {
|
||||
/// Sanitize the file name. Remove invalid characters and trim the file name.
|
||||
String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
|
||||
while (fileName.endsWith('.')) {
|
||||
fileName = fileName.substring(0, fileName.length - 1);
|
||||
}
|
||||
const maxLength = 255;
|
||||
var length = maxLength ?? 255;
|
||||
if (dir != null) {
|
||||
if (!dir.endsWith('/') && !dir.endsWith('\\')) {
|
||||
dir = "$dir/";
|
||||
}
|
||||
length -= dir.length;
|
||||
}
|
||||
final invalidChars = RegExp(r'[<>:"/\\|?*]');
|
||||
final sanitizedFileName = fileName.replaceAll(invalidChars, ' ');
|
||||
var trimmedFileName = sanitizedFileName.trim();
|
||||
if (trimmedFileName.isEmpty) {
|
||||
throw Exception('Invalid File Name: Empty length.');
|
||||
}
|
||||
while (true) {
|
||||
final bytes = utf8.encode(trimmedFileName);
|
||||
if (bytes.length > maxLength) {
|
||||
trimmedFileName =
|
||||
trimmedFileName.substring(0, trimmedFileName.length - 1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if (length <= 0) {
|
||||
throw Exception('Invalid File Name: Max length is less than 0.');
|
||||
}
|
||||
if (trimmedFileName.length > length) {
|
||||
trimmedFileName = trimmedFileName.substring(0, length);
|
||||
}
|
||||
return trimmedFileName;
|
||||
}
|
||||
@@ -157,6 +176,8 @@ Future<void> copyDirectory(Directory source, Directory destination) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the **contents** of the source directory to the destination directory.
|
||||
/// This function is executed in an isolate to prevent the UI from freezing.
|
||||
Future<void> copyDirectoryIsolate(
|
||||
Directory source, Directory destination) async {
|
||||
await Isolate.run(() => overrideIO(() => copyDirectory(source, destination)));
|
||||
|
@@ -5,15 +5,45 @@
|
||||
#include <gdk/gdkx.h>
|
||||
#endif
|
||||
|
||||
#include <iostream>
|
||||
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
|
||||
struct _MyApplication {
|
||||
GtkApplication parent_instance;
|
||||
char** dart_entrypoint_arguments;
|
||||
FlMethodChannel* clipboard_channel;
|
||||
};
|
||||
|
||||
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
|
||||
|
||||
static void handle_clipboard_call(FlMethodChannel* channel, FlMethodCall* call, gpointer user_data) {
|
||||
if (strcmp(fl_method_call_get_name(call), "writeImageToClipboard") == 0) {
|
||||
const auto args = fl_method_call_get_args(call);
|
||||
const auto width = fl_value_get_int(fl_value_get_map_value(args, 0));
|
||||
const auto height = fl_value_get_int(fl_value_get_map_value(args, 1));
|
||||
const auto data = fl_value_get_uint8_list(fl_value_get_map_value(args, 2));
|
||||
|
||||
std::cout << width << " " << height << " " << data[0] << " " << data[1] << std::endl;
|
||||
|
||||
GBytes* bytes = g_bytes_new(data, width * height * 4);
|
||||
|
||||
GdkDisplay* display = gdk_display_get_default();
|
||||
GtkClipboard* clipboard = gtk_clipboard_get_default(display);
|
||||
GdkPixbuf* pixbuf = gdk_pixbuf_new_from_bytes(
|
||||
bytes,
|
||||
GDK_COLORSPACE_RGB,
|
||||
true,
|
||||
8,
|
||||
width,
|
||||
height,
|
||||
width * 4
|
||||
);
|
||||
gtk_clipboard_set_image(clipboard, pixbuf);
|
||||
fl_method_call_respond_success(call, fl_value_new_string("Ok"), nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
// Implements GApplication::activate.
|
||||
static void my_application_activate(GApplication* application) {
|
||||
MyApplication* self = MY_APPLICATION(application);
|
||||
@@ -48,6 +78,13 @@ static void my_application_activate(GApplication* application) {
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
GdkVisual* visual;
|
||||
gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE);
|
||||
gtk_window_set_decorated(window, FALSE);
|
||||
visual = gdk_screen_get_rgba_visual(screen);
|
||||
if (visual != NULL && gdk_screen_is_composited(screen)) {
|
||||
gtk_widget_set_visual(GTK_WIDGET(window), visual);
|
||||
}
|
||||
gtk_widget_show(GTK_WIDGET(window));
|
||||
|
||||
g_autoptr(FlDartProject) project = fl_dart_project_new();
|
||||
@@ -59,6 +96,15 @@ static void my_application_activate(GApplication* application) {
|
||||
|
||||
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
|
||||
|
||||
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
|
||||
self->clipboard_channel = fl_method_channel_new(
|
||||
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
|
||||
"venera/clipboard", FL_METHOD_CODEC(codec));
|
||||
fl_method_channel_set_method_call_handler(
|
||||
self->clipboard_channel, handle_clipboard_call, self, nullptr);
|
||||
|
||||
gtk_widget_hide(GTK_WIDGET(window));
|
||||
|
||||
gtk_widget_grab_focus(GTK_WIDGET(view));
|
||||
}
|
||||
|
||||
@@ -103,6 +149,7 @@ static void my_application_shutdown(GApplication* application) {
|
||||
static void my_application_dispose(GObject* object) {
|
||||
MyApplication* self = MY_APPLICATION(object);
|
||||
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
|
||||
g_clear_object(&self->clipboard_channel);
|
||||
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
|
||||
}
|
||||
|
||||
|
@@ -38,6 +38,31 @@ class AppDelegate: FlutterAppDelegate {
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
let clipboardChannel = FlutterMethodChannel(name: "venera/clipboard", binaryMessenger: controller.engine.binaryMessenger)
|
||||
|
||||
clipboardChannel.setMethodCallHandler { (call, result) in
|
||||
switch call.method {
|
||||
case "writeImageToClipboard":
|
||||
guard let arguments = call.arguments as? [String: Any],
|
||||
let data = arguments["data"] as? FlutterStandardTypedData else {
|
||||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = NSImage(data: data.data) else {
|
||||
result(FlutterError(code: "INVALID_IMAGE", message: "Could not create image from data", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.writeObjects([image])
|
||||
result(true)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getDirectoryPath() {
|
||||
|
183
pubspec.lock
183
pubspec.lock
@@ -5,10 +5,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_links
|
||||
sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950"
|
||||
sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "6.4.0"
|
||||
app_links_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -141,16 +141,16 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
|
||||
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
version: "0.7.11"
|
||||
desktop_webview_window:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/desktop_webview_window"
|
||||
ref: HEAD
|
||||
resolved-ref: b8f7e94c576acf4ca3dce5b9f8fb8076e5eaca5e
|
||||
ref: "7801fc582ecf5a7351632887891ecf309a7b2583"
|
||||
resolved-ref: "7801fc582ecf5a7351632887891ecf309a7b2583"
|
||||
url: "https://github.com/wgh136/flutter_desktop_webview"
|
||||
source: git
|
||||
version: "0.2.4"
|
||||
@@ -158,18 +158,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
|
||||
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.7.0"
|
||||
version: "5.8.0+1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
|
||||
sha256: e485c7a39ff2b384fa1d7e09b4e25f755804de8384358049124830b04fc4f93a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -190,10 +190,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
version: "2.1.4"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -262,10 +262,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_windows
|
||||
sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4"
|
||||
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3+3"
|
||||
version: "0.9.3+4"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -308,18 +308,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: flutter_inappwebview
|
||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
||||
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||
source: git
|
||||
version: "6.2.0-beta.3"
|
||||
flutter_inappwebview_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: flutter_inappwebview_android
|
||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
||||
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||
source: git
|
||||
version: "1.2.0-beta.3"
|
||||
flutter_inappwebview_internal_annotations:
|
||||
@@ -334,45 +334,45 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: flutter_inappwebview_ios
|
||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
||||
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||
source: git
|
||||
version: "1.2.0-beta.3"
|
||||
flutter_inappwebview_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: flutter_inappwebview_macos
|
||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
||||
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||
source: git
|
||||
version: "1.2.0-beta.3"
|
||||
flutter_inappwebview_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: flutter_inappwebview_platform_interface
|
||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
||||
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||
source: git
|
||||
version: "1.4.0-beta.3"
|
||||
flutter_inappwebview_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: flutter_inappwebview_web
|
||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
||||
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||
source: git
|
||||
version: "1.2.0-beta.3"
|
||||
flutter_inappwebview_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: flutter_inappwebview_windows
|
||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
||||
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||
source: git
|
||||
version: "0.7.0-beta.3"
|
||||
flutter_lints:
|
||||
@@ -400,16 +400,16 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398"
|
||||
sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.23"
|
||||
version: "2.0.24"
|
||||
flutter_qjs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
|
||||
resolved-ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
|
||||
ref: "8feae95df7fb00455df129ad7a0dfec1d0e8d8e4"
|
||||
resolved-ref: "8feae95df7fb00455df129ad7a0dfec1d0e8d8e4"
|
||||
url: "https://github.com/wgh136/flutter_qjs"
|
||||
source: git
|
||||
version: "0.3.7"
|
||||
@@ -417,25 +417,25 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_reorderable_grid_view
|
||||
sha256: "732bcb1b29d5130c11a70e6acec512941fafe241f0e80bffd93ca6e415819915"
|
||||
sha256: a7e0f9d5ba12fd232eb07fbb7f570ae35491045a6bba1858f6eb50c675526dfe
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.4.0"
|
||||
version: "5.4.1"
|
||||
flutter_rust_bridge:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_rust_bridge
|
||||
sha256: "35c257fc7f98e34c1314d6c145e5ed54e7c94e8a9f469947e31c9298177d546f"
|
||||
sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
version: "2.9.0"
|
||||
flutter_saf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "7637b8b67d0a831f3cd7e702b8173e300880d32e"
|
||||
resolved-ref: "7637b8b67d0a831f3cd7e702b8173e300880d32e"
|
||||
url: "https://github.com/pkuislm/flutter_saf.git"
|
||||
ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
|
||||
resolved-ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
|
||||
url: "https://github.com/venera-app/flutter_saf"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
flutter_test:
|
||||
@@ -492,18 +492,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
|
||||
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
version: "1.3.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360"
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
version: "4.1.2"
|
||||
http_profile:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -528,14 +528,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.1"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -572,10 +564,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "4a16b3f03741e1252fda5de3ce712666d010ba2122f8e912c94f9f7b90e1a4c3"
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
version: "5.1.1"
|
||||
local_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -588,18 +580,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_android
|
||||
sha256: "6763aaf8965f21822624cb2fd3c03d2a8b3791037b5efb0fe4b13e110f5afc92"
|
||||
sha256: "0abe4e72f55c785b28900de52a2522c86baba0988838b5dc22241b072ecccd74"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.46"
|
||||
version: "1.0.48"
|
||||
local_auth_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_darwin
|
||||
sha256: "6d2950da311d26d492a89aeb247c72b4653ddc93601ea36a84924a396806d49c"
|
||||
sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.3"
|
||||
local_auth_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -620,8 +612,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "9a784b193af5d55b2a35e58fa390bda3e4f35d00"
|
||||
resolved-ref: "9a784b193af5d55b2a35e58fa390bda3e4f35d00"
|
||||
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
||||
resolved-ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
||||
url: "https://github.com/venera-app/lodepng_flutter"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
@@ -725,16 +717,16 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
|
||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
version: "6.1.0"
|
||||
photo_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "94724a0b"
|
||||
resolved-ref: "94724a0b7f94167fd1ae061f84e14ae04cae5c39"
|
||||
ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
|
||||
resolved-ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
|
||||
url: "https://github.com/wgh136/photo_view"
|
||||
source: git
|
||||
version: "0.14.0"
|
||||
@@ -758,18 +750,19 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
||||
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.1"
|
||||
version: "4.0.0"
|
||||
rhttp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: rhttp
|
||||
sha256: "8212cbc816cc3e761eecb8d4dbbaa1eca95de715428320a198a4e7a89acdcd2e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.8"
|
||||
path: rhttp
|
||||
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
||||
resolved-ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
||||
url: "https://github.com/wgh136/rhttp"
|
||||
source: git
|
||||
version: "0.11.0"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -823,10 +816,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400"
|
||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.1.3"
|
||||
version: "10.1.4"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -876,18 +869,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: cb7f4e9dc1b52b1fa350f7b3d41c662e75fc3d399555fa4e5efcf267e9a4fbb5
|
||||
sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
version: "2.7.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: "73016db8419f019e807b7a5e5fbf2a7bd45c165fed403b8e7681230f3a102785"
|
||||
sha256: "57fafacd815c981735406215966ff7caaa8eab984b094f52e692accefcbd9233"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.28"
|
||||
version: "0.5.30"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1004,18 +997,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e"
|
||||
sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
version: "2.4.0"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4"
|
||||
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1052,8 +1045,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
|
||||
resolved-ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
|
||||
ref: "2f669c98fb81cff1c64fee93466a1475c77e4273"
|
||||
resolved-ref: "2f669c98fb81cff1c64fee93466a1475c77e4273"
|
||||
url: "https://github.com/wgh136/webdav_client"
|
||||
source: git
|
||||
version: "1.2.2"
|
||||
@@ -1061,10 +1054,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69"
|
||||
sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
version: "5.11.0"
|
||||
window_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1101,10 +1094,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: zip_flutter
|
||||
sha256: bbf3160062610a43901b7ebbc6f6dd46519540f03a84027dc7b1fff399dda1ac
|
||||
sha256: c4d5a34c5803def866bc550926bb16fe89717c9b7304695d5b2ede30964eb8a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.10"
|
||||
version: "0.0.12"
|
||||
sdks:
|
||||
dart: ">=3.7.0-0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
dart: ">=3.7.0 <4.0.0"
|
||||
flutter: ">=3.29.3"
|
||||
|
41
pubspec.yaml
41
pubspec.yaml
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.0+130
|
||||
version: 1.4.1+141
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
flutter: 3.29.0
|
||||
flutter: 3.29.3
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -14,24 +14,24 @@ dependencies:
|
||||
path_provider: any
|
||||
intl: any
|
||||
window_manager: ^0.4.3
|
||||
sqlite3: ^2.4.7
|
||||
sqlite3_flutter_libs: ^0.5.28
|
||||
sqlite3: ^2.7.4
|
||||
sqlite3_flutter_libs: ^0.5.30
|
||||
flutter_qjs:
|
||||
git:
|
||||
url: https://github.com/wgh136/flutter_qjs
|
||||
ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3
|
||||
ref: 8feae95df7fb00455df129ad7a0dfec1d0e8d8e4
|
||||
crypto: ^3.0.6
|
||||
dio: ^5.7.0
|
||||
dio: ^5.8.0+1
|
||||
html: ^0.15.5
|
||||
pointycastle: ^3.9.1
|
||||
pointycastle: ^4.0.0
|
||||
url_launcher: ^6.3.0
|
||||
path: ^1.9.0
|
||||
photo_view:
|
||||
git:
|
||||
url: https://github.com/wgh136/photo_view
|
||||
ref: 94724a0b
|
||||
ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
|
||||
mime: ^2.0.0
|
||||
share_plus: ^10.1.3
|
||||
share_plus: ^10.1.4
|
||||
scrollable_positioned_list:
|
||||
git:
|
||||
url: https://github.com/venera-app/flutter.widgets
|
||||
@@ -43,31 +43,36 @@ dependencies:
|
||||
git:
|
||||
url: https://github.com/wgh136/flutter_desktop_webview
|
||||
path: packages/desktop_webview_window
|
||||
ref: 7801fc582ecf5a7351632887891ecf309a7b2583
|
||||
flutter_inappwebview:
|
||||
git:
|
||||
url: https://github.com/pichillilorenzo/flutter_inappwebview
|
||||
url: https://github.com/venera-app/flutter_inappwebview
|
||||
path: flutter_inappwebview
|
||||
ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676
|
||||
app_links: ^6.3.3
|
||||
ref: 3ef899b3db57c911b080979f1392253b835f98ab
|
||||
app_links: ^6.4.0
|
||||
sliver_tools: ^0.2.12
|
||||
flutter_file_dialog: ^3.0.2
|
||||
file_selector: ^1.0.3
|
||||
zip_flutter: ^0.0.10
|
||||
zip_flutter: ^0.0.12
|
||||
lodepng_flutter:
|
||||
git:
|
||||
url: https://github.com/venera-app/lodepng_flutter
|
||||
ref: 9a784b193af5d55b2a35e58fa390bda3e4f35d00
|
||||
rhttp: 0.9.8
|
||||
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
||||
rhttp:
|
||||
git:
|
||||
url: https://github.com/wgh136/rhttp
|
||||
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
||||
path: rhttp
|
||||
webdav_client:
|
||||
git:
|
||||
url: https://github.com/wgh136/webdav_client
|
||||
ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1
|
||||
ref: 2f669c98fb81cff1c64fee93466a1475c77e4273
|
||||
battery_plus: ^6.2.1
|
||||
local_auth: ^2.3.0
|
||||
flutter_saf:
|
||||
git:
|
||||
url: https://github.com/pkuislm/flutter_saf.git
|
||||
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
|
||||
url: https://github.com/venera-app/flutter_saf
|
||||
ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
|
||||
dynamic_color: ^1.7.0
|
||||
shimmer_animation: ^2.1.0
|
||||
flutter_memory_info: ^0.0.1
|
||||
|
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.85.1"
|
||||
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
73
windows/build_arm64.iss
Normal file
73
windows/build_arm64.iss
Normal file
@@ -0,0 +1,73 @@
|
||||
; Script generated by the Inno Setup Script Wizard.
|
||||
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||
|
||||
#define MyAppName "Venera"
|
||||
#define MyAppVersion "{{version}}"
|
||||
#define MyAppPublisher "nyne"
|
||||
#define MyAppURL "https://github.com/venera-app/venera"
|
||||
#define MyAppExeName "venera.exe"
|
||||
#define RootPath "{{root_path}}"
|
||||
|
||||
[Setup]
|
||||
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
|
||||
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
|
||||
AppId={{1A39CB64-0A5B-478E-9590-978614C804A8}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
;AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
; Uncomment the following line to run in non administrative install mode (install for current user only.)
|
||||
;PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
OutputDir={#RootPath}\build\windows
|
||||
OutputBaseFilename=Venera-{#MyAppVersion}-windows-arm64-installer
|
||||
SetupIconFile={#RootPath}\windows\runner\resources\app_icon.ico
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
ArchitecturesInstallIn64BitMode=arm64
|
||||
ArchitecturesAllowed=arm64
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "chinesesimplified"; MessagesFile: "{#RootPath}\windows\ChineseSimplified.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\flutter_inappwebview_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\file_selector_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\app_links_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\sqlite3.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\sqlite3_flutter_libs_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\flutter_qjs_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\desktop_webview_window_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\WebView2Loader.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\battery_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\screen_retriever_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\local_auth_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\zip_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\dynamic_color_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\flutter_7zip.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\arm64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall
|
43
windows/build_arm64.py
Normal file
43
windows/build_arm64.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import platform
|
||||
import subprocess
|
||||
import os
|
||||
import httpx
|
||||
|
||||
file = open('pubspec.yaml', 'r')
|
||||
content = file.read()
|
||||
file.close()
|
||||
|
||||
subprocess.run(["flutter", "build", "windows"], shell=True)
|
||||
|
||||
if os.path.exists("build/app-windows.zip"):
|
||||
os.remove("build/app-windows.zip")
|
||||
|
||||
version = str.split(str.split(content, 'version: ')[1], '+')[0]
|
||||
|
||||
subprocess.run(["tar", "-a", "-c", "-f", f"build/windows/Venera-{version}-windows-arm64.zip", "-C", "build/windows/x64/runner/Release", "*"]
|
||||
, shell=True)
|
||||
|
||||
issPath = "windows/build_arm64.iss"
|
||||
|
||||
issContent = ""
|
||||
file = open(issPath, 'r')
|
||||
issContent = file.read()
|
||||
newContent = issContent
|
||||
newContent = newContent.replace("{{version}}", version)
|
||||
newContent = newContent.replace("{{root_path}}", os.getcwd())
|
||||
file.close()
|
||||
file = open(issPath, 'w')
|
||||
file.write(newContent)
|
||||
file.close()
|
||||
|
||||
if not os.path.exists("windows/ChineseSimplified.isl"):
|
||||
# download ChineseSimplified.isl
|
||||
url = "https://cdn.jsdelivr.net/gh/kira-96/Inno-Setup-Chinese-Simplified-Translation@latest/ChineseSimplified.isl"
|
||||
response = httpx.get(url)
|
||||
with open('windows/ChineseSimplified.isl', 'wb') as file:
|
||||
file.write(response.content)
|
||||
|
||||
subprocess.run(["iscc", issPath], shell=True)
|
||||
|
||||
with open(issPath, 'w') as file:
|
||||
file.write(issContent)
|
@@ -10,11 +10,16 @@
|
||||
#include <flutter/event_stream_handler_functions.h>
|
||||
#include <flutter/standard_method_codec.h>
|
||||
#include "flutter/generated_plugin_registrant.h"
|
||||
#include <thread>
|
||||
|
||||
#define _CRT_SECURE_NO_WARNINGS
|
||||
|
||||
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&& mouseEvents = nullptr;
|
||||
|
||||
std::atomic<bool> mainThreadAlive(true);
|
||||
std::atomic<std::chrono::steady_clock::time_point> lastHeartbeat(std::chrono::steady_clock::now());
|
||||
std::thread* monitorThread = nullptr;
|
||||
|
||||
char* wideCharToMultiByte(wchar_t* pWCStrKey)
|
||||
{
|
||||
size_t pSize = WideCharToMultiByte(CP_OEMCP, 0, pWCStrKey, wcslen(pWCStrKey), NULL, 0, NULL, NULL);
|
||||
@@ -45,6 +50,22 @@ FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
||||
|
||||
FlutterWindow::~FlutterWindow() {}
|
||||
|
||||
void monitorUIThread() {
|
||||
const auto timeout = std::chrono::seconds(5);
|
||||
|
||||
while (mainThreadAlive.load()) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto duration = now - lastHeartbeat.load();
|
||||
|
||||
if (duration > timeout) {
|
||||
std::cerr << "The UI thread is dead. Terminate the application.";
|
||||
std::exit(0);
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
bool FlutterWindow::OnCreate() {
|
||||
if (!Win32Window::OnCreate()) {
|
||||
return false;
|
||||
@@ -78,6 +99,13 @@ bool FlutterWindow::OnCreate() {
|
||||
result->Success(flutter::EncodableValue("No Proxy"));
|
||||
delete(res);
|
||||
}
|
||||
else if (call.method_name() == "heartBeat") {
|
||||
if (monitorThread == nullptr) {
|
||||
monitorThread = new std::thread{ monitorUIThread };
|
||||
}
|
||||
lastHeartbeat = std::chrono::steady_clock::now();
|
||||
result->Success();
|
||||
}
|
||||
});
|
||||
|
||||
flutter::EventChannel<> channel2(
|
||||
@@ -102,6 +130,47 @@ bool FlutterWindow::OnCreate() {
|
||||
|
||||
channel2.SetStreamHandler(std::move(eventHandler));
|
||||
|
||||
const flutter::MethodChannel<> channel3(
|
||||
flutter_controller_->engine()->messenger(), "venera/clipboard",
|
||||
&flutter::StandardMethodCodec::GetInstance()
|
||||
);
|
||||
channel3.SetMethodCallHandler(
|
||||
[](const flutter::MethodCall<>& call,const std::unique_ptr<flutter::MethodResult<>>& result) {
|
||||
if(call.method_name() == "writeImageToClipboard"){
|
||||
flutter::EncodableMap arguments = std::get<flutter::EncodableMap>(*call.arguments());
|
||||
std::vector<uint8_t> data = std::get<std::vector<uint8_t>>(arguments["data"]);
|
||||
std::int32_t width = std::get<std::int32_t>(arguments["width"]);
|
||||
std::int32_t height = std::get<std::int32_t>(arguments["height"]);
|
||||
|
||||
// convert rgba to bgra
|
||||
for (int i = 0; i < data.size()/4; i++) {
|
||||
uint8_t temp = data[i * 4];
|
||||
data[i * 4] = data[i * 4 + 2];
|
||||
data[i * 4 + 2] = temp;
|
||||
}
|
||||
|
||||
auto bitmap = CreateBitmap((int)width, (int)height, 1, 32, data.data());
|
||||
|
||||
if (!bitmap) {
|
||||
result->Error("0", "Invalid Image Data");
|
||||
return;
|
||||
}
|
||||
|
||||
if (OpenClipboard(NULL))
|
||||
{
|
||||
EmptyClipboard();
|
||||
SetClipboardData(CF_BITMAP, bitmap);
|
||||
CloseClipboard();
|
||||
result->Success();
|
||||
}
|
||||
else {
|
||||
result->Error("Failed to open clipboard");
|
||||
}
|
||||
|
||||
DeleteObject(bitmap);
|
||||
}
|
||||
});
|
||||
|
||||
SetChildContent(flutter_controller_->view()->GetNativeWindow());
|
||||
|
||||
flutter_controller_->engine()->SetNextFrameCallback([&]() {
|
||||
@@ -122,6 +191,10 @@ void FlutterWindow::OnDestroy() {
|
||||
}
|
||||
|
||||
Win32Window::OnDestroy();
|
||||
if (monitorThread != nullptr) {
|
||||
mainThreadAlive = false;
|
||||
monitorThread->join();
|
||||
}
|
||||
}
|
||||
|
||||
void mouse_side_button_listener(unsigned int input)
|
||||
|
Reference in New Issue
Block a user