mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Compare commits
162 Commits
v1.3.2
...
17b8b9ea8f
Author | SHA1 | Date | |
---|---|---|---|
![]() |
17b8b9ea8f | ||
![]() |
951bcae603 | ||
![]() |
0b9de68c86 | ||
![]() |
81b27fd941 | ||
![]() |
d5d72911ed | ||
![]() |
838d5c9c3e | ||
![]() |
85baac657a | ||
![]() |
cceca6b96f | ||
![]() |
b5b0dc85e3 | ||
![]() |
50044c4372 | ||
![]() |
5fd7f1b880 | ||
![]() |
058fde3f5a | ||
![]() |
a2d46123dd | ||
![]() |
01acc4f9de | ||
![]() |
856aae0769 | ||
![]() |
8eda8adcc8 | ||
defd4b8624 | |||
b2a164e066 | |||
a46ceebf19 | |||
cc08445f13 | |||
93f7f72d07 | |||
20f7ab4866 | |||
54363919cd | |||
182a821fc5 | |||
8868c6edb3 | |||
![]() |
fffbb4ed23 | ||
![]() |
b057be0311 | ||
![]() |
fc5fed1707 | ||
![]() |
8525f5318f | ||
![]() |
d58cafc4a0 | ||
23afafd1d6 | |||
![]() |
3b6e0adbbb | ||
20a57c7a36 | |||
665f50ed2a | |||
55733ef505 | |||
0c46214619 | |||
749a1a47fb | |||
76e9ef87d4 | |||
dcd6466547 | |||
ed70fdba93 | |||
ded0068ea6 | |||
![]() |
7dc6be622a | ||
![]() |
88f093f7e5 | ||
8f357b3e6c | |||
9ee82975e8 | |||
![]() |
9f048685e4 | ||
![]() |
bc1f5e11b5 | ||
1f2147ef72 | |||
fba365fd93 | |||
a5e3fbaee5 | |||
190e645a12 | |||
![]() |
8a83ff5367 | ||
6e14942dab | |||
146fc70143 | |||
b37ea01aca | |||
bf7b90313a | |||
929c1a9d91 | |||
9ff68d0701 | |||
dfd15ed34a | |||
![]() |
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 |
4
.github/workflows/fastlane.yml
vendored
4
.github/workflows/fastlane.yml
vendored
@@ -4,8 +4,12 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'fastlane/**'
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
paths:
|
||||
- 'fastlane/**'
|
||||
|
||||
jobs:
|
||||
go:
|
||||
|
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"
|
9
.github/workflows/main.yml
vendored
9
.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
|
||||
|
76
.github/workflows/update_alt_store.yml
vendored
Normal file
76
.github/workflows/update_alt_store.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Update AltStore Source
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build ALL"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-source:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Record job start time
|
||||
id: job_start_time
|
||||
run: echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update AltStore source
|
||||
id: update_source
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python update_alt_store.py
|
||||
git config --global user.name 'GitHub Action'
|
||||
git config --global user.email 'action@github.com'
|
||||
git add alt_store.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
git commit -m "Updated source with latest release"
|
||||
git push
|
||||
echo "changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Calculate job duration
|
||||
id: duration
|
||||
if: always()
|
||||
run: |
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - ${{ steps.job_start_time.outputs.start_time }}))
|
||||
echo "duration=$duration seconds" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create job summary
|
||||
run: |
|
||||
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
|
||||
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ Changes Detected and Applied" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The alt_store.json file has been updated with the latest release information." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🔍 No Changes Detected" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The alt_store.json file is up to date. No changes were necessary." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🕐 Execution Time" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "This job took ${{ steps.duration.outputs.duration }} to complete." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "📆 Next Scheduled Run" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The next scheduled run will be tomorrow at midnight UTC." >> $GITHUB_STEP_SUMMARY
|
@@ -3,7 +3,7 @@
|
||||
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
||||
[](https://github.com/venera-app/venera/releases)
|
||||
[](https://github.com/venera-app/venera/stargazers)
|
||||
[](https://t.me/+Ws-IpmUutzkxMjhl)
|
||||
[](https://t.me/venera_release)
|
||||
|
||||
A comic reader that support reading local and network comics.
|
||||
|
||||
|
64
alt_store.json
Normal file
64
alt_store.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "Venera",
|
||||
"identifier": "com.github.wgh136.venera.source",
|
||||
"website": "https://github.com/venera-app/venera",
|
||||
"subtitle": "Venera official AltStore Source.",
|
||||
"description": "This is the official AltStore Source for Venera.\n\n A comic reader that supports reading local and network comics",
|
||||
"tintColor": "#0784FC",
|
||||
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||
"apps": [
|
||||
{
|
||||
"beta": false,
|
||||
"name": "Venera",
|
||||
"bundleIdentifier": "com.github.wgh136.venera",
|
||||
"developerName": "wgh136",
|
||||
"subtitle": "A comic reader that supports reading local and network comics",
|
||||
"version": "1.4.5",
|
||||
"versionDate": "2025-06-18",
|
||||
"versionDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||
"localizedDescription": "A comic reader that supports reading local and network comics",
|
||||
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||
"tintColor": "#0784FC",
|
||||
"category": "utilities",
|
||||
"size": 14960268,
|
||||
"appPermissions": {
|
||||
"entitlements": [
|
||||
"application-identifier",
|
||||
"com.apple.security.application-groups",
|
||||
"get-task-allow",
|
||||
"keychain-access-groups",
|
||||
"com.apple.developer.kernel.extended-virtual-addressing",
|
||||
"com.apple.developer.kernel.increased-memory-limit",
|
||||
"com.apple.developer.healthkit.background-delivery"
|
||||
],
|
||||
"privacy": {
|
||||
"NSFaceIDUsageDescription": "Face ID or Touch ID is used to protect your privacy when opening the app, ensuring secure access to your reading content.",
|
||||
"NSPhotoLibraryAddUsageDescription": "Used to save comic images you've favorited or downloaded to your photo library for easy access and sharing.",
|
||||
"NSPhotoLibraryUsageDescription": "Used to select images from your photo library when needed, and to save comic images you've collected to your device."
|
||||
}
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.4.5",
|
||||
"date": "2025-06-18",
|
||||
"localizedDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||
"size": 14960268
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"news": [
|
||||
{
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": "Update of Venera just got released!",
|
||||
"date": "2025-06-18T09:02:01Z",
|
||||
"identifier": "release-v1.4.5",
|
||||
"notify": true,
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.4.5 - Venera 18/06/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
|
||||
}
|
||||
]
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -39,6 +39,32 @@ let Convert = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param str {string}
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
encodeGbk: (str) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "gbk",
|
||||
value: str,
|
||||
isEncode: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param value {ArrayBuffer}
|
||||
* @returns {string}
|
||||
*/
|
||||
decodeGbk: (value) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "gbk",
|
||||
value: value,
|
||||
isEncode: false
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} value
|
||||
* @returns {string}
|
||||
@@ -176,7 +202,7 @@ let Convert = {
|
||||
decryptAesCbc: (value, key, iv) => {
|
||||
return sendMessage({
|
||||
method: "convert",
|
||||
type: "aes-ecb",
|
||||
type: "aes-cbc",
|
||||
value: value,
|
||||
key: key,
|
||||
iv: iv,
|
||||
@@ -1358,3 +1384,29 @@ let APP = {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
})
|
||||
}
|
@@ -234,8 +234,10 @@
|
||||
"Please add some sources": "请添加一些源",
|
||||
"Please check your settings": "请检查您的设置",
|
||||
"No Category Pages": "没有分类页面",
|
||||
"Group @group": "第 @group 组",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
"Page @page": "第 @page 页",
|
||||
"Remove local favorite and history": "删除本地收藏和历史记录",
|
||||
"Also remove files on disk": "同时删除磁盘上的文件",
|
||||
"Copy to app local path": "将漫画复制到本地存储目录中",
|
||||
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
|
||||
@@ -358,7 +360,7 @@
|
||||
"Once the operation is successful, app will automatically sync data with the server.": "操作成功后, APP将自动与服务器同步数据",
|
||||
"Cache cleared": "缓存已清除",
|
||||
"Disabled": "已禁用",
|
||||
"WebDAV Auto Sync": "WebDAV 自动同步",
|
||||
"Auto Sync Data": "自动同步数据",
|
||||
"Mark all as read": "全部标记为已读",
|
||||
"Do you want to mark all as read?": "您要全部标记为已读吗?",
|
||||
"Swipe down for previous chapter": "向下滑动查看上一章",
|
||||
@@ -367,7 +369,38 @@
|
||||
"Home Page": "主页",
|
||||
"Favorites Page": "收藏页面",
|
||||
"Explore Page": "探索页面",
|
||||
"Categories 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": "在首页显示单张图片",
|
||||
"Show system status bar": "显示系统状态栏",
|
||||
"Click to select an image": "点击选择一张图片",
|
||||
"Repo URL": "仓库地址",
|
||||
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
|
||||
"Double tap to zoom": "双击缩放",
|
||||
"Clear Unfavorited": "清除未收藏",
|
||||
"Reverse": "反转",
|
||||
"Delete Chapters": "删除章节",
|
||||
"Open Folder": "打开文件夹",
|
||||
"Path copied to clipboard": "路径已复制到剪贴板",
|
||||
"Reverse default chapter order": "反转默认章节顺序"
|
||||
},
|
||||
"zh_TW": {
|
||||
"Home": "首頁",
|
||||
@@ -604,8 +637,10 @@
|
||||
"Please add some sources": "請添加一些源",
|
||||
"Please check your settings": "請檢查您的設定",
|
||||
"No Category Pages": "沒有分類頁面",
|
||||
"Group @group": "第 @group 組",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
"Page @page": "第 @page 頁",
|
||||
"Remove local favorite and history": "刪除本機收藏和歷史記錄",
|
||||
"Also remove files on disk": "同時刪除磁碟上的文件",
|
||||
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
|
||||
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
|
||||
@@ -725,18 +760,49 @@
|
||||
"All Comics": "全部漫畫",
|
||||
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
|
||||
"Disable": "停用",
|
||||
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與服務器同步數據",
|
||||
"Cache cleared": "緩存已清除",
|
||||
"Disabled": "已禁用",
|
||||
"WebDAV Auto Sync": "WebDAV 自動同步",
|
||||
"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": "主頁",
|
||||
"Home Page": "首頁",
|
||||
"Favorites Page": "收藏頁面",
|
||||
"Explore Page": "探索頁面",
|
||||
"Categories 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": "在首頁顯示單張圖片",
|
||||
"Show system status bar": "顯示系統狀態欄",
|
||||
"Click to select an image": "點擊選擇一張圖片",
|
||||
"Repo URL": "倉庫地址",
|
||||
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
|
||||
"Double tap to zoom": "雙擊縮放",
|
||||
"Clear Unfavorited": "清除未收藏",
|
||||
"Reverse": "反轉",
|
||||
"Delete Chapters": "刪除章節",
|
||||
"Open Folder": "打開資料夾",
|
||||
"Path copied to clipboard": "路徑已複製到剪貼簿",
|
||||
"Reverse default chapter order": "反轉預設章節順序"
|
||||
}
|
||||
}
|
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 |
@@ -9,13 +9,45 @@ Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine wh
|
||||
|
||||
This document will describe how to write a comic source for Venera.
|
||||
|
||||
## Preparation
|
||||
## Comic Source List
|
||||
|
||||
Venera can display a list of comic sources in the app.
|
||||
|
||||
You should provide a repository url to let the app load the comic source list.
|
||||
The url should point to a JSON file that contains the list of comic sources.
|
||||
|
||||
The JSON file should have the following format:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Source Name",
|
||||
"url": "https://example.com/source.js",
|
||||
"filename": "Relative path to the source file",
|
||||
"version": "1.0.0",
|
||||
"description": "A brief description of the source"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Only one of `url` and `filename` should be provided.
|
||||
The description field is optional.
|
||||
|
||||
Currently, you can use the following repo url:
|
||||
```
|
||||
https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/index.json
|
||||
```
|
||||
The repo is maintained by the Venera team, and you can submit a pull request to add your comic source.
|
||||
|
||||
## Create a Comic Source
|
||||
|
||||
### Preparation
|
||||
|
||||
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
|
||||
- An editor that supports javascript.
|
||||
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
|
||||
|
||||
## Start Writing
|
||||
### Start Writing
|
||||
|
||||
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
|
||||
|
||||
@@ -23,7 +55,7 @@ Here is a brief introduction to the template:
|
||||
|
||||
> Note: Javascript api document is [here](js_api.md).
|
||||
|
||||
### Write basic information
|
||||
#### Write basic information
|
||||
|
||||
```javascript
|
||||
class NewComicSource extends ComicSource {
|
||||
@@ -49,7 +81,7 @@ In this part, you need to do the following:
|
||||
- Change the class name to your source name.
|
||||
- Fill in the name, key, version, minAppVersion, and url fields.
|
||||
|
||||
### init function
|
||||
#### init function
|
||||
|
||||
```javascript
|
||||
/**
|
||||
@@ -64,7 +96,7 @@ The function will be called when the source is initialized. You can do some init
|
||||
|
||||
Remove this function if not used.
|
||||
|
||||
### Account
|
||||
#### Account
|
||||
|
||||
```javascript
|
||||
// [Optional] account related
|
||||
@@ -140,7 +172,7 @@ In this part, you can implement login, logout, and register functions.
|
||||
|
||||
Remove this part if not used.
|
||||
|
||||
### Explore page
|
||||
#### Explore page
|
||||
|
||||
```javascript
|
||||
// explore page list
|
||||
@@ -185,7 +217,7 @@ There are three types of explore pages:
|
||||
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
|
||||
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
|
||||
|
||||
### Category Page
|
||||
#### Category Page
|
||||
|
||||
```javascript
|
||||
// categories
|
||||
@@ -227,7 +259,7 @@ Category page is a static page that contains multiple parts, each part contains
|
||||
|
||||
A comic source can only have one category page.
|
||||
|
||||
### Category Comics Page
|
||||
#### Category Comics Page
|
||||
|
||||
```javascript
|
||||
/// category comic loading related
|
||||
@@ -280,7 +312,7 @@ When user clicks on a category, the category comics page will be displayed.
|
||||
|
||||
This part is used to load comics of a category.
|
||||
|
||||
### Search
|
||||
#### Search
|
||||
|
||||
```javascript
|
||||
/// search related
|
||||
@@ -331,6 +363,11 @@ This part is used to load comics of a category.
|
||||
|
||||
// enable tags suggestions
|
||||
enableTagsSuggestions: false,
|
||||
// [Optional] handle tag suggestion click
|
||||
onTagSuggestionSelected: (namespace, tag) => {
|
||||
// return the text to insert into search box
|
||||
return `${namespace}:${tag}`
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -339,7 +376,7 @@ This part is used to load search results.
|
||||
`load` and `loadNext` functions are used to load search results.
|
||||
If `load` function is implemented, `loadNext` function will be ignored.
|
||||
|
||||
### Favorites
|
||||
#### Favorites
|
||||
|
||||
```javascript
|
||||
// favorite related
|
||||
@@ -411,7 +448,7 @@ This part is used to manage network favorites of the source.
|
||||
`load` and `loadNext` functions are used to load search results.
|
||||
If `load` function is implemented, `loadNext` function will be ignored.
|
||||
|
||||
### Comic Details
|
||||
#### Comic Details
|
||||
|
||||
```javascript
|
||||
/// single comic related
|
||||
@@ -576,7 +613,7 @@ If `load` function is implemented, `loadNext` function will be ignored.
|
||||
|
||||
This part is used to load comic details.
|
||||
|
||||
### Settings
|
||||
#### Settings
|
||||
|
||||
```javascript
|
||||
/*
|
||||
@@ -635,7 +672,7 @@ This part is used to load comic details.
|
||||
This part is used to provide settings for the source.
|
||||
|
||||
|
||||
### Translations
|
||||
#### Translations
|
||||
|
||||
```javascript
|
||||
// [Optional] translations for the strings in this config
|
||||
|
@@ -53,5 +53,7 @@
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Ensure that the operation is being performed by the user themselves.</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.books</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
);
|
||||
|
||||
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,
|
||||
|
@@ -290,7 +290,8 @@ class ContentDialog extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var content = Column(
|
||||
var content = SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -312,6 +313,7 @@ class ContentDialog extends StatelessWidget {
|
||||
).paddingRight(12),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -82,7 +82,7 @@ class _WindowFrameState extends State<WindowFrame> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
windowManager.close();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -147,7 +147,8 @@ class _WindowFrameState extends State<WindowFrame> {
|
||||
onPressed: debug,
|
||||
child: Text('Debug'),
|
||||
),
|
||||
if (!App.isMacOS) _WindowButtons(
|
||||
if (!App.isMacOS)
|
||||
_WindowButtons(
|
||||
onClose: _onClose,
|
||||
)
|
||||
],
|
||||
@@ -559,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,
|
||||
color: Colors.black.toOpacity(_isFocused ? 0.4 : 0.2),
|
||||
blurRadius: 4,
|
||||
)
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -583,7 +580,10 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
|
||||
Widget build(BuildContext context) {
|
||||
return DragToResizeArea(
|
||||
enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(_isMaximized ? 0 : 4),
|
||||
child: _buildVirtualWindowFrame(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.3.2";
|
||||
final version = "1.4.5";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
@@ -47,6 +47,7 @@ class _App {
|
||||
|
||||
late String dataPath;
|
||||
late String cachePath;
|
||||
String? externalStoragePath;
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
@@ -77,6 +78,9 @@ class _App {
|
||||
Future<void> init() async {
|
||||
cachePath = (await getApplicationCacheDirectory()).path;
|
||||
dataPath = (await getApplicationSupportDirectory()).path;
|
||||
if (isAndroid) {
|
||||
externalStoragePath = (await getExternalStorageDirectory())!.path;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initComponents() async {
|
||||
|
@@ -3,10 +3,12 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/init.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
class Appdata {
|
||||
class Appdata with Init {
|
||||
Appdata._create();
|
||||
|
||||
final Settings settings = Settings._create();
|
||||
@@ -16,17 +18,18 @@ class Appdata {
|
||||
bool _isSavingData = false;
|
||||
|
||||
Future<void> saveData([bool sync = true]) async {
|
||||
if (_isSavingData) {
|
||||
await Future.doWhile(() async {
|
||||
while (_isSavingData) {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
return _isSavingData;
|
||||
});
|
||||
}
|
||||
_isSavingData = true;
|
||||
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();
|
||||
}
|
||||
@@ -53,28 +56,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,
|
||||
@@ -106,9 +87,56 @@ class Appdata {
|
||||
|
||||
var implicitData = <String, dynamic>{};
|
||||
|
||||
void writeImplicitData() {
|
||||
void writeImplicitData() async {
|
||||
while (_isSavingData) {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
_isSavingData = true;
|
||||
try {
|
||||
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
|
||||
file.writeAsString(jsonEncode(implicitData));
|
||||
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;
|
||||
}
|
||||
try {
|
||||
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']);
|
||||
}
|
||||
catch(e) {
|
||||
Log.error("Appdata", "Failed to load appdata", e);
|
||||
Log.info("Appdata", "Resetting appdata");
|
||||
file.deleteIgnoreError();
|
||||
}
|
||||
try {
|
||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||
if (await implicitDataFile.exists()) {
|
||||
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
Log.error("Appdata", "Failed to load implicit data", e);
|
||||
Log.info("Appdata", "Resetting implicit data");
|
||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||
implicitDataFile.deleteIgnoreError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +172,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
|
||||
@@ -160,10 +189,16 @@ 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': '',
|
||||
'preloadImageCount': 4,
|
||||
'followUpdatesFolder': null,
|
||||
'initialPage': '0',
|
||||
'comicListDisplayMode': 'paging', // paging, continuous
|
||||
'showPageNumberInReader': true,
|
||||
'showSingleImageOnFirstPage': false,
|
||||
'enableDoubleTapToZoom': true,
|
||||
'reverseChapterOrder': false,
|
||||
'showSystemStatusBar': false,
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
@@ -172,8 +207,10 @@ class Settings with ChangeNotifier {
|
||||
|
||||
operator []=(String key, dynamic value) {
|
||||
_data[key] = value;
|
||||
if (key != "dataVersion") {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
@@ -21,6 +23,51 @@ class CacheManager {
|
||||
|
||||
int _limitSize = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
static Future<int> _scanDir(Pointer<void> dbP, String dir) async {
|
||||
var res = await Isolate.run(() async {
|
||||
int totalSize = 0;
|
||||
List<String> unmanagedFiles = [];
|
||||
var db = sqlite3.fromPointer(dbP);
|
||||
await for (var file in Directory(dir).list(recursive: true)) {
|
||||
if (file is File) {
|
||||
var size = await file.length();
|
||||
var segments = file.uri.pathSegments;
|
||||
var name = segments.last;
|
||||
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
|
||||
var res = db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE dir = ? AND name = ?
|
||||
''', [dir, name]);
|
||||
if (res.isEmpty) {
|
||||
unmanagedFiles.add(file.path);
|
||||
} else {
|
||||
totalSize += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
'totalSize': totalSize,
|
||||
'unmanagedFiles': unmanagedFiles,
|
||||
};
|
||||
});
|
||||
// delete unmanaged files
|
||||
// Only modify the database in the main isolate to avoid deadlock
|
||||
for (var filePath in res['unmanagedFiles'] as List<String>) {
|
||||
var file = File(filePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
var segments = file.uri.pathSegments;
|
||||
var name = segments.last;
|
||||
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
|
||||
CacheManager()._db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE dir = ? AND name = ?
|
||||
''', [dir, name]);
|
||||
}
|
||||
return res['totalSize'] as int;
|
||||
}
|
||||
|
||||
CacheManager._create() {
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db = sqlite3.open('${App.dataPath}/cache.db');
|
||||
@@ -33,10 +80,13 @@ class CacheManager {
|
||||
type TEXT
|
||||
)
|
||||
''');
|
||||
compute((path) => Directory(path).size, cachePath)
|
||||
.then((value) => _currentSize = value);
|
||||
_scanDir(_db.handle, cachePath).then((value) {
|
||||
_currentSize = value;
|
||||
checkCache();
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the singleton instance of CacheManager.
|
||||
factory CacheManager() => instance ??= CacheManager._create();
|
||||
|
||||
/// set cache size limit in MB
|
||||
@@ -44,35 +94,15 @@ class CacheManager {
|
||||
_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 {
|
||||
await delete(key);
|
||||
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;
|
||||
@@ -85,20 +115,10 @@ class CacheManager {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -110,21 +130,51 @@ class CacheManager {
|
||||
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');
|
||||
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) {
|
||||
checkCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -139,28 +189,31 @@ class CacheManager {
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if (await file.exists()) {
|
||||
var size = await file.length();
|
||||
_currentSize = _currentSize! - size;
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
if (res.isNotEmpty) {
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
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
|
||||
''');
|
||||
if (res.isEmpty) {
|
||||
// There are many files unmanaged by the cache manager.
|
||||
// Clear all cache.
|
||||
await Directory(cachePath).delete(recursive: true);
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
break;
|
||||
}
|
||||
for (var row in res) {
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as String;
|
||||
@@ -183,12 +236,12 @@ class CacheManager {
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
}
|
||||
count--;
|
||||
}
|
||||
}
|
||||
_isChecking = false;
|
||||
}
|
||||
|
||||
/// Delete cache by key.
|
||||
Future<void> delete(String key) async {
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
@@ -215,6 +268,7 @@ class CacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all cache.
|
||||
Future<void> clear() async {
|
||||
await Directory(cachePath).delete(recursive: true);
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
@@ -223,75 +277,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,67 +78,59 @@ 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) {
|
||||
|
@@ -11,6 +11,8 @@ 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';
|
||||
@@ -182,6 +184,9 @@ class ComicSource {
|
||||
|
||||
final HandleClickTagEvent? handleClickTagEvent;
|
||||
|
||||
/// Callback when a tag suggestion is selected in search.
|
||||
final TagSuggestionSelectFunc? onTagSuggestionSelected;
|
||||
|
||||
final LinkHandler? linkHandler;
|
||||
|
||||
final bool enableTagsSuggestions;
|
||||
@@ -257,6 +262,7 @@ class ComicSource {
|
||||
this.idMatcher,
|
||||
this.translations,
|
||||
this.handleClickTagEvent,
|
||||
this.onTagSuggestionSelected,
|
||||
this.linkHandler,
|
||||
this.enableTagsSuggestions,
|
||||
this.enableTagsTranslate,
|
||||
@@ -349,7 +355,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);
|
||||
}
|
||||
|
@@ -111,6 +111,29 @@ class Comic {
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ sourceKey.hashCode;
|
||||
|
||||
@override
|
||||
toString() => "$sourceKey@$id";
|
||||
}
|
||||
|
||||
class ComicID {
|
||||
final ComicType type;
|
||||
|
||||
final String id;
|
||||
|
||||
const ComicID(this.type, this.id);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ComicID) return false;
|
||||
return other.type == type && other.id == id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => type.hashCode ^ id.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => "$type@$id";
|
||||
}
|
||||
|
||||
class ComicDetails with HistoryMixin {
|
||||
@@ -166,7 +189,9 @@ class ComicDetails with HistoryMixin {
|
||||
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
|
||||
var res = <String, List<String>>{};
|
||||
map.forEach((key, value) {
|
||||
if (value is List) {
|
||||
res[key] = List<String>.from(value);
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}
|
||||
@@ -339,7 +364,8 @@ class ComicChapters {
|
||||
} else if (groupedChapters.isNotEmpty) {
|
||||
return ComicChapters.grouped(groupedChapters);
|
||||
} else {
|
||||
throw ArgumentError("Empty chapter list");
|
||||
// return a empty list.
|
||||
return ComicChapters(chapters);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,3 +452,110 @@ class ComicChapters {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")) {
|
||||
@@ -149,6 +148,7 @@ class ComicSourceParser {
|
||||
_parseIdMatch(),
|
||||
_parseTranslation(),
|
||||
_parseClickTagEvent(),
|
||||
_parseTagSuggestionSelectFunc(),
|
||||
_parseLinkHandler(),
|
||||
_getValue("search.enableTagsSuggestions") ?? false,
|
||||
_getValue("comic.enableTagsTranslate") ?? false,
|
||||
@@ -336,7 +336,7 @@ class ComicSourceParser {
|
||||
(e['comics'] as List).map((e) {
|
||||
return Comic.fromJson(e, _key!);
|
||||
}).toList(),
|
||||
e['viewMore'],
|
||||
PageJumpTarget.parse(_key!, e['viewMore']),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -404,6 +404,43 @@ class ComicSourceParser {
|
||||
var categoryParts = <BaseCategoryPart>[];
|
||||
|
||||
for (var c in doc["parts"]) {
|
||||
if (c["categories"] != null && c["categories"] is! List) {
|
||||
continue;
|
||||
}
|
||||
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"]);
|
||||
@@ -413,12 +450,45 @@ class ComicSourceParser {
|
||||
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, tags, itemType, categoryParams));
|
||||
categoryParts.add(FixedCategoryPart(name, cs));
|
||||
} else if (type == "random") {
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, tags, c["randomNumber"] ?? 1, itemType));
|
||||
categoryParts
|
||||
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +690,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 +1049,25 @@ 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);
|
||||
};
|
||||
}
|
||||
|
||||
TagSuggestionSelectFunc? _parseTagSuggestionSelectFunc() {
|
||||
if (!_checkExists("search.onTagSuggestionSelected")) {
|
||||
return null;
|
||||
}
|
||||
return (namespace, tag) {
|
||||
var res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.search.onTagSuggestionSelected(
|
||||
${jsonEncode(namespace)}, ${jsonEncode(tag)})
|
||||
""");
|
||||
return res is String ? res : "$namespace:$tag";
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -41,7 +41,12 @@ typedef LikeCommentFunc = Future<Res<int?>> Function(
|
||||
typedef VoteCommentFunc = Future<Res<int?>> Function(
|
||||
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
|
||||
|
||||
typedef HandleClickTagEvent = Map<String, String> Function(
|
||||
typedef HandleClickTagEvent = PageJumpTarget? Function(
|
||||
String namespace, String tag);
|
||||
|
||||
/// Handle tag suggestion selection event. Should return the text to insert
|
||||
/// into the search field.
|
||||
typedef TagSuggestionSelectFunc = String Function(
|
||||
String namespace, String tag);
|
||||
|
||||
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
@@ -209,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
|
||||
late Database _db;
|
||||
|
||||
late Map<String, int> counts;
|
||||
|
||||
int get totalComics {
|
||||
int total = 0;
|
||||
for (var t in counts.values) {
|
||||
total += t;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
int folderComics(String folder) {
|
||||
return counts[folder] ?? 0;
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
counts = {};
|
||||
_db = sqlite3.open("${App.dataPath}/local_favorite.db");
|
||||
_db.execute("""
|
||||
create table if not exists folder_order (
|
||||
@@ -224,7 +241,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");
|
||||
""");
|
||||
@@ -233,7 +251,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("""
|
||||
@@ -246,6 +264,22 @@ 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;
|
||||
}
|
||||
initCounts();
|
||||
}
|
||||
|
||||
void initCounts() {
|
||||
for (var folder in folderNames) {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> find(String id, ComicType type) {
|
||||
@@ -339,7 +373,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;
|
||||
@@ -347,6 +381,54 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
|
||||
}
|
||||
|
||||
static Future<List<FavoriteItem>> _getFolderComicsAsync(
|
||||
String folder, Pointer<void> p) {
|
||||
return Isolate.run(() {
|
||||
var db = sqlite3.fromPointer(p);
|
||||
var rows = db.select("""
|
||||
select * from "$folder"
|
||||
ORDER BY display_order;
|
||||
""");
|
||||
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Start a new isolate to get the comics in the folder
|
||||
Future<List<FavoriteItem>> getFolderComicsAsync(String folder) {
|
||||
return _getFolderComicsAsync(folder, _db.handle);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
static Future<List<FavoriteItem>> _getAllComicsAsync(
|
||||
List<String> folders, Pointer<void> p) {
|
||||
return Isolate.run(() {
|
||||
var db = sqlite3.fromPointer(p);
|
||||
var res = <FavoriteItem>{};
|
||||
for (final folder in folders) {
|
||||
var comics = db.select("""
|
||||
select * from "$folder";
|
||||
""");
|
||||
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
|
||||
}
|
||||
return res.toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Start a new isolate to get all the comics
|
||||
Future<List<FavoriteItem>> getAllComicsAsync() {
|
||||
return _getAllComicsAsync(folderNames, _db.handle);
|
||||
}
|
||||
|
||||
void addTagTo(String folder, String id, String tag) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
@@ -412,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
);
|
||||
""");
|
||||
notifyListeners();
|
||||
counts[name] = 0;
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -526,6 +609,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
""", [updateTime, comic.id, comic.type.value]);
|
||||
}
|
||||
}
|
||||
if (counts[folder] == null) {
|
||||
counts[folder] = count(folder);
|
||||
} else {
|
||||
counts[folder] = counts[folder]! + 1;
|
||||
}
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
@@ -565,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchMoveFavorites(
|
||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
if (!existsFolder(targetFolder)) {
|
||||
throw Exception("Target folder does not exist");
|
||||
}
|
||||
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
var displayOrder = maxValue(targetFolder) + 1;
|
||||
try {
|
||||
for (var item in items) {
|
||||
_db.execute("""
|
||||
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
select id, name, author, type, tags, cover_path, time, ?
|
||||
from "$sourceFolder"
|
||||
where id == ? and type == ?;
|
||||
""", [displayOrder, item.id, item.type.value]);
|
||||
|
||||
_db.execute("""
|
||||
delete from "$sourceFolder"
|
||||
where id == ? and type == ?;
|
||||
""", [item.id, item.type.value]);
|
||||
|
||||
displayOrder++;
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
Log.error("Batch Move Favorites", e.toString());
|
||||
_db.execute("ROLLBACK");
|
||||
return;
|
||||
}
|
||||
_db.execute("COMMIT");
|
||||
|
||||
// Update counts
|
||||
if (counts[targetFolder] == null) {
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
} else {
|
||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||
}
|
||||
|
||||
if (counts[sourceFolder] != null) {
|
||||
counts[sourceFolder] = counts[sourceFolder]! - items.length;
|
||||
} else {
|
||||
counts[sourceFolder] = count(sourceFolder);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchCopyFavorites(
|
||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
if (!existsFolder(targetFolder)) {
|
||||
throw Exception("Target folder does not exist");
|
||||
}
|
||||
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
var displayOrder = maxValue(targetFolder) + 1;
|
||||
try {
|
||||
for (var item in items) {
|
||||
_db.execute("""
|
||||
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
select id, name, author, type, tags, cover_path, time, ?
|
||||
from "$sourceFolder"
|
||||
where id == ? and type == ?;
|
||||
""", [displayOrder, item.id, item.type.value]);
|
||||
|
||||
displayOrder++;
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
Log.error("Batch Copy Favorites", e.toString());
|
||||
_db.execute("ROLLBACK");
|
||||
return;
|
||||
}
|
||||
|
||||
_db.execute("COMMIT");
|
||||
|
||||
// Update counts
|
||||
if (counts[targetFolder] == null) {
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
} else {
|
||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// delete a folder
|
||||
void deleteFolder(String name) {
|
||||
_modifiedAfterLastCache = true;
|
||||
@@ -575,14 +759,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
delete from folder_order
|
||||
where folder_name == ?;
|
||||
""", [name]);
|
||||
counts.remove(name);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteComic(String folder, FavoriteItem comic) {
|
||||
_modifiedAfterLastCache = true;
|
||||
deleteComicWithId(folder, comic.id, comic.type);
|
||||
}
|
||||
|
||||
void deleteComicWithId(String folder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
LocalFavoriteImageProvider.delete(id, type.value);
|
||||
@@ -590,6 +770,60 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
delete from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
if (counts[folder] != null) {
|
||||
counts[folder] = counts[folder]! - 1;
|
||||
} else {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
try {
|
||||
for (var comic in comics) {
|
||||
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
|
||||
_db.execute("""
|
||||
delete from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [comic.id, comic.type.value]);
|
||||
}
|
||||
if (counts[folder] != null) {
|
||||
counts[folder] = counts[folder]! - comics.length;
|
||||
} else {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error("Batch Delete Comics", e.toString());
|
||||
_db.execute("ROLLBACK");
|
||||
return;
|
||||
}
|
||||
_db.execute("COMMIT");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
var folderNames = _getFolderNamesWithDB();
|
||||
try {
|
||||
for (var comic in comics) {
|
||||
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
|
||||
for (var folder in folderNames) {
|
||||
_db.execute("""
|
||||
delete from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [comic.id, comic.type.value]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error("Batch Delete Comics in All Folders", e.toString());
|
||||
_db.execute("ROLLBACK");
|
||||
return;
|
||||
}
|
||||
initCounts();
|
||||
_db.execute("COMMIT");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -620,11 +854,26 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
if (!existsFolder(folder)) {
|
||||
throw Exception("Failed to reorder: folder not found");
|
||||
}
|
||||
deleteFolder(folder);
|
||||
createFolder(folder);
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
try {
|
||||
for (int i = 0; i < newFolder.length; i++) {
|
||||
addComic(folder, newFolder[i], i);
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set display_order = ?
|
||||
where id == ? and type == ?;
|
||||
""", [
|
||||
i,
|
||||
newFolder[i].id,
|
||||
newFolder[i].type.value
|
||||
]);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
Log.error("Reorder", e.toString());
|
||||
_db.execute("ROLLBACK");
|
||||
return;
|
||||
}
|
||||
_db.execute("COMMIT");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -649,6 +898,8 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
set folder_name = ?
|
||||
where folder_name == ?;
|
||||
""", [after, before]);
|
||||
counts[after] = counts[before] ?? 0;
|
||||
counts.remove(before);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -726,10 +977,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("""
|
||||
@@ -737,15 +988,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)) {
|
||||
@@ -756,12 +1010,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
return comics.where((element) {
|
||||
for (var i = 1; i < keywordList.length; i++) {
|
||||
comics =
|
||||
comics.where((element) => test(element, keywordList[i])).toList();
|
||||
if (!test(element, keywordList[i])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return comics;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void editTags(String id, String folder, List<String> tags) {
|
||||
@@ -849,7 +1105,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");
|
||||
@@ -866,10 +1122,12 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
add column has_new_update int;
|
||||
""");
|
||||
}
|
||||
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"
|
||||
|
@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:sqlite3/sqlite3.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/image_provider/image_favorites_provider.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
@@ -132,6 +133,11 @@ class History implements Comic {
|
||||
@override
|
||||
String get description {
|
||||
var res = "";
|
||||
if (group != null){
|
||||
res += "${"Group @group".tlParams({
|
||||
"group": group!,
|
||||
})} - ";
|
||||
}
|
||||
if (ep >= 1) {
|
||||
res += "Chapter @ep".tlParams({
|
||||
"ep": ep,
|
||||
@@ -305,6 +311,31 @@ class HistoryManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearUnfavoritedHistory() {
|
||||
_db.execute('BEGIN TRANSACTION;');
|
||||
try {
|
||||
final idAndTypes = _db.select("""
|
||||
select id, type from history;
|
||||
""");
|
||||
for (var element in idAndTypes) {
|
||||
final id = element["id"] as String;
|
||||
final type = ComicType(element["type"] as int);
|
||||
if (!LocalFavoritesManager().isExist(id, type)) {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
}
|
||||
}
|
||||
_db.execute('COMMIT;');
|
||||
} catch (e) {
|
||||
_db.execute('ROLLBACK;');
|
||||
rethrow;
|
||||
}
|
||||
updateCache();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(String id, ComicType type) async {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
@@ -380,4 +411,23 @@ class HistoryManager with ChangeNotifier {
|
||||
isInitialized = false;
|
||||
_db.dispose();
|
||||
}
|
||||
|
||||
void batchDeleteHistories(List<ComicID> histories) {
|
||||
if (histories.isEmpty) return;
|
||||
_db.execute('BEGIN TRANSACTION;');
|
||||
try {
|
||||
for (var history in histories) {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where id == ? and type == ?;
|
||||
""", [history.id, history.type.value]);
|
||||
}
|
||||
_db.execute('COMMIT;');
|
||||
} catch (e) {
|
||||
_db.execute('ROLLBACK;');
|
||||
rethrow;
|
||||
}
|
||||
updateCache();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:enough_convert/enough_convert.dart';
|
||||
import 'package:flutter/foundation.dart' show protected;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
@@ -25,6 +26,7 @@ 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';
|
||||
@@ -163,6 +165,13 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
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;
|
||||
@@ -187,7 +196,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
||||
responseType: ResponseType.plain,
|
||||
validateStatus: (status) => true,
|
||||
));
|
||||
var proxy = await AppDio.getProxy();
|
||||
var proxy = await getProxy();
|
||||
dio.httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
return HttpClient()
|
||||
@@ -364,6 +373,11 @@ mixin class _JSEngineApi {
|
||||
switch (type) {
|
||||
case "utf8":
|
||||
return isEncode ? utf8.encode(value) : utf8.decode(value);
|
||||
case "gbk":
|
||||
final codec = const GbkCodec();
|
||||
return isEncode
|
||||
? Uint8List.fromList(codec.encode(value))
|
||||
: codec.decode(value);
|
||||
case "base64":
|
||||
return isEncode ? base64Encode(value) : base64Decode(value);
|
||||
case "md5":
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
@@ -107,15 +109,42 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
|
||||
void read() {
|
||||
var history = HistoryManager().find(id, comicType);
|
||||
int? firstDownloadedChapter;
|
||||
int? firstDownloadedChapterGroup;
|
||||
if (downloadedChapters.isNotEmpty && chapters != null) {
|
||||
final chapters = this.chapters!;
|
||||
if (chapters.isGrouped) {
|
||||
for (int i=0; i<chapters.groupCount; i++) {
|
||||
var group = chapters.getGroupByIndex(i);
|
||||
var keys = group.keys.toList();
|
||||
for (int j=0; j<keys.length; j++) {
|
||||
var chapterId = keys[j];
|
||||
if (downloadedChapters.contains(chapterId)) {
|
||||
firstDownloadedChapter = j + 1;
|
||||
firstDownloadedChapterGroup = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var keys = chapters.allChapters.keys;
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
if (downloadedChapters.contains(keys.elementAt(i))) {
|
||||
firstDownloadedChapter = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
App.rootContext.to(
|
||||
() => Reader(
|
||||
type: comicType,
|
||||
cid: id,
|
||||
name: title,
|
||||
chapters: chapters,
|
||||
initialChapter: history?.ep,
|
||||
initialChapter: history?.ep ?? firstDownloadedChapter,
|
||||
initialPage: history?.page,
|
||||
initialChapterGroup: history?.group,
|
||||
initialChapterGroup: history?.group ?? firstDownloadedChapterGroup,
|
||||
history: history ??
|
||||
History.fromModel(
|
||||
model: this,
|
||||
@@ -422,12 +451,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!.ids.elementAt(ep - 1));
|
||||
.contains((chapters ?? comic.chapters)!.ids.elementAtOrNull(ep - 1));
|
||||
}
|
||||
|
||||
List<DownloadTask> downloadingTasks = [];
|
||||
@@ -443,6 +490,10 @@ class LocalManager with ChangeNotifier {
|
||||
if (comic != null) {
|
||||
return Directory(FilePath.join(path, comic.directory));
|
||||
}
|
||||
const comicDirectoryMaxLength = 80;
|
||||
if (name.length > comicDirectoryMaxLength) {
|
||||
name = name.substring(0, comicDirectoryMaxLength);
|
||||
}
|
||||
var dir = findValidDirectoryName(path, name);
|
||||
return Directory(FilePath.join(path, dir)).create().then((value) => value);
|
||||
}
|
||||
@@ -524,6 +575,99 @@ class LocalManager with ChangeNotifier {
|
||||
remove(c.id, c.comicType);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteComicChapters(LocalComic c, List<String> chapters) {
|
||||
if (chapters.isEmpty) {
|
||||
return;
|
||||
}
|
||||
var newDownloadedChapters = c.downloadedChapters
|
||||
.where((e) => !chapters.contains(e))
|
||||
.toList();
|
||||
if (newDownloadedChapters.isNotEmpty) {
|
||||
_db.execute(
|
||||
'UPDATE comics SET downloadedChapters = ? WHERE id = ? AND comic_type = ?;',
|
||||
[
|
||||
jsonEncode(newDownloadedChapters),
|
||||
c.id,
|
||||
c.comicType.value,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
_db.execute(
|
||||
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[c.id, c.comicType.value],
|
||||
);
|
||||
}
|
||||
var shouldRemovedDirs = <Directory>[];
|
||||
for (var chapter in chapters) {
|
||||
var dir = Directory(FilePath.join(c.baseDir, chapter));
|
||||
if (dir.existsSync()) {
|
||||
shouldRemovedDirs.add(dir);
|
||||
}
|
||||
}
|
||||
if (shouldRemovedDirs.isNotEmpty) {
|
||||
_deleteDirectories(shouldRemovedDirs);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComics(List<LocalComic> comics, [bool removeFileOnDisk = true, bool removeFavoriteAndHistory = true]) {
|
||||
if (comics.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldRemovedDirs = <Directory>[];
|
||||
_db.execute('BEGIN TRANSACTION;');
|
||||
try {
|
||||
for (var c in comics) {
|
||||
if (removeFileOnDisk) {
|
||||
var dir = Directory(FilePath.join(path, c.directory));
|
||||
if (dir.existsSync()) {
|
||||
shouldRemovedDirs.add(dir);
|
||||
}
|
||||
}
|
||||
_db.execute(
|
||||
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[c.id, c.comicType.value],
|
||||
);
|
||||
}
|
||||
}
|
||||
catch(e, s) {
|
||||
Log.error("LocalManager", "Failed to batch delete comics: $e", s);
|
||||
_db.execute('ROLLBACK;');
|
||||
return;
|
||||
}
|
||||
_db.execute('COMMIT;');
|
||||
|
||||
var comicIDs = comics.map((e) => ComicID(e.comicType, e.id)).toList();
|
||||
|
||||
if (removeFavoriteAndHistory) {
|
||||
LocalFavoritesManager().batchDeleteComicsInAllFolders(comicIDs);
|
||||
HistoryManager().batchDeleteHistories(comicIDs);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
if (removeFileOnDisk) {
|
||||
_deleteDirectories(shouldRemovedDirs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the directories in a separate isolate to avoid blocking the UI thread.
|
||||
static void _deleteDirectories(List<Directory> directories) {
|
||||
Isolate.run(() async {
|
||||
await SAFTaskWorker().init();
|
||||
for (var dir in directories) {
|
||||
try {
|
||||
if (dir.existsSync()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalSortType {
|
||||
|
@@ -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,4 +1,7 @@
|
||||
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';
|
||||
@@ -11,6 +14,7 @@ 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';
|
||||
@@ -45,10 +49,19 @@ Future<void> init() async {
|
||||
_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() {
|
||||
@@ -82,8 +95,7 @@ Future<void> _checkAppUpdates() async {
|
||||
appdata.writeImplicitData();
|
||||
ComicSourcePage.checkComicSourceUpdate();
|
||||
if (appdata.settings['checkUpdateOnStart']) {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
await checkUpdateUi(false);
|
||||
await checkUpdateUi(false, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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();
|
||||
if (App.isLinux) {
|
||||
await windowManager.show();
|
||||
await placement.applyToWindow();
|
||||
} else {
|
||||
await placement.applyToWindow();
|
||||
await windowManager.show();
|
||||
WindowPlacement.loop();
|
||||
}
|
||||
|
||||
WindowPlacement.loop();
|
||||
});
|
||||
}
|
||||
}, (error, stack) {
|
||||
@@ -141,24 +144,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
) {
|
||||
String? font;
|
||||
List<String>? fallback;
|
||||
if (App.isWindows) {
|
||||
font = 'Segoe UI';
|
||||
fallback = [
|
||||
'Segoe UI',
|
||||
'Microsoft YaHei',
|
||||
'PingFang SC',
|
||||
'Noto Sans CJK',
|
||||
'Arial',
|
||||
'sans-serif'
|
||||
];
|
||||
}
|
||||
if (App.isLinux) {
|
||||
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'
|
||||
];
|
||||
@@ -210,6 +204,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
'dark' => ThemeMode.dark,
|
||||
_ => ThemeMode.system
|
||||
},
|
||||
color: Colors.transparent,
|
||||
localizationsDelegates: [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
@@ -257,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,7 +96,9 @@ class MyLogInterceptor implements Interceptor {
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
Log.info("Network", "${options.method} ${options.uri}\n"
|
||||
Log.info(
|
||||
"Network",
|
||||
"${options.method} ${options.uri}\n"
|
||||
"headers:\n${options.headers}\n"
|
||||
"data:\n${options.data}");
|
||||
options.connectTimeout = 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,10 +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: 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(
|
||||
@@ -282,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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -482,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(),
|
||||
);
|
||||
}
|
||||
@@ -552,7 +552,7 @@ class _ImageDownloadWrapper {
|
||||
void start() async {
|
||||
int lastBytes = 0;
|
||||
try {
|
||||
await for (var p in ImageDownloader.loadComicImage(
|
||||
await for (var p in ImageDownloader.loadComicImageUnwrapped(
|
||||
image, task.source.key, task.comicId, chapter)) {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
|
@@ -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,40 @@ 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> loadComicImageUnwrapped(
|
||||
String imageKey, String? sourceKey, String cid, String eid) {
|
||||
return _loadComicImage(imageKey, sourceKey, cid, eid);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -189,6 +223,74 @@ 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 {
|
||||
try {
|
||||
await for (var data in _stream) {
|
||||
if (isClosed) {
|
||||
break;
|
||||
}
|
||||
for (var controller in controllers) {
|
||||
if (!controller.isClosed) {
|
||||
controller.add(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
for (var controller in controllers) {
|
||||
if (!controller.isClosed) {
|
||||
controller.addError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
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;
|
||||
|
||||
|
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();
|
||||
}
|
||||
|
@@ -294,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() {
|
||||
|
@@ -27,7 +27,7 @@ class _NormalComicChapters extends StatefulWidget {
|
||||
class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||
late _ComicPageState state;
|
||||
|
||||
bool reverse = false;
|
||||
late bool reverse;
|
||||
|
||||
bool showAll = false;
|
||||
|
||||
@@ -38,6 +38,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
reverse = appdata.settings["reverseChapterOrder"] ?? false;
|
||||
history = widget.history;
|
||||
}
|
||||
|
||||
@@ -105,7 +106,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||
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 +114,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 +135,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||
},
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithFixedHeight(
|
||||
maxCrossAxisExtent: 200,
|
||||
maxCrossAxisExtent: 250,
|
||||
itemHeight: 48,
|
||||
),
|
||||
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
|
||||
@@ -176,7 +177,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late _ComicPageState state;
|
||||
|
||||
bool reverse = false;
|
||||
late bool reverse;
|
||||
|
||||
bool showAll = false;
|
||||
|
||||
@@ -186,12 +187,18 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
|
||||
late TabController tabController;
|
||||
|
||||
int index = 0;
|
||||
late int index;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
reverse = appdata.settings["reverseChapterOrder"] ?? false;
|
||||
history = widget.history;
|
||||
if (history?.group != null) {
|
||||
index = history!.group! - 1;
|
||||
} else {
|
||||
index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -199,6 +206,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
state = context.findAncestorStateOfType<_ComicPageState>()!;
|
||||
chapters = state.comic.chapters!;
|
||||
tabController = TabController(
|
||||
initialIndex: index,
|
||||
length: chapters.ids.length,
|
||||
vsync: this,
|
||||
);
|
||||
@@ -294,15 +302,15 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
||||
history!.readEpisode.contains(rawIndex);
|
||||
}
|
||||
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,
|
||||
@@ -323,7 +331,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,7 +141,18 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
|
||||
@override
|
||||
Widget buildContent(BuildContext context, ComicDetails data) {
|
||||
return SmoothCustomScrollView(
|
||||
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(),
|
||||
@@ -144,8 +163,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
buildComments(),
|
||||
buildThumbnails(),
|
||||
buildRecommend(),
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -387,15 +409,27 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
var group = history!.group;
|
||||
String text;
|
||||
if (haveChapter) {
|
||||
var epName = group == null
|
||||
? comic.chapters!.titles.elementAt(
|
||||
var epName = "E$ep";
|
||||
String? groupName;
|
||||
try {
|
||||
if (group == null){
|
||||
epName = comic.chapters!.titles.elementAt(
|
||||
math.min(ep - 1, comic.chapters!.length - 1),
|
||||
)
|
||||
: comic.chapters!
|
||||
);
|
||||
} else {
|
||||
groupName = comic.chapters!.groups.elementAt(group - 1);
|
||||
epName = comic.chapters!
|
||||
.getGroupByIndex(group - 1)
|
||||
.values
|
||||
.elementAt(ep - 1);
|
||||
text = "${"Last Reading".tl}: $epName P$page";
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
// ignore
|
||||
}
|
||||
text = groupName == null
|
||||
? "${"Last Reading".tl}: $epName P$page"
|
||||
: "${"Last Reading".tl}: $groupName $epName P$page";
|
||||
} else {
|
||||
text = "${"Last Reading".tl}: P$page";
|
||||
}
|
||||
@@ -437,7 +471,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);
|
||||
}
|
||||
|
||||
@@ -601,6 +636,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(),
|
||||
],
|
||||
|
@@ -99,7 +99,11 @@ class _CommentsPageState extends State<CommentsPage> {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
child: SmoothScrollProvider(
|
||||
builder: (context, controller, physics) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
primary: false,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _comments!.length + 2,
|
||||
@@ -156,6 +160,8 @@ class _CommentsPageState extends State<CommentsPage> {
|
||||
showAvatar: showAvatar,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
buildBottom(context)
|
||||
|
@@ -51,9 +51,7 @@ class ComicSourcePage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: const _Body(),
|
||||
);
|
||||
return Scaffold(body: const _Body());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,10 +85,7 @@ class _BodyState extends State<_Body> {
|
||||
Widget build(BuildContext context) {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(
|
||||
title: Text('Comic Source'.tl),
|
||||
style: AppbarStyle.shadow,
|
||||
),
|
||||
SliverAppbar(title: Text('Comic Source'.tl), style: AppbarStyle.shadow),
|
||||
buildCard(context),
|
||||
for (var source in ComicSource.all())
|
||||
_SliverComicSource(
|
||||
@@ -109,9 +104,7 @@ class _BodyState extends State<_Body> {
|
||||
showConfirmDialog(
|
||||
context: App.rootContext,
|
||||
title: "Delete".tl,
|
||||
content: "Delete comic source '@n' ?".tlParams({
|
||||
"n": source.name,
|
||||
}),
|
||||
content: "Delete comic source '@n' ?".tlParams({"n": source.name}),
|
||||
btnColor: context.colorScheme.error,
|
||||
onConfirm: () {
|
||||
var file = File(source.filePath);
|
||||
@@ -134,13 +127,15 @@ class _BodyState extends State<_Body> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text("cancel")),
|
||||
child: const Text("cancel"),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ComicSourceManager().reload();
|
||||
App.forceRebuild();
|
||||
},
|
||||
child: const Text("continue")),
|
||||
child: const Text("continue"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -157,8 +152,10 @@ class _BodyState extends State<_Body> {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> update(ComicSource source,
|
||||
[bool showLoading = true]) async {
|
||||
static Future<void> update(
|
||||
ComicSource source, [
|
||||
bool showLoading = true,
|
||||
]) async {
|
||||
if (!source.url.isURL) {
|
||||
App.rootContext.showMessage(message: "Invalid url config");
|
||||
return;
|
||||
@@ -174,8 +171,10 @@ class _BodyState extends State<_Body> {
|
||||
);
|
||||
}
|
||||
try {
|
||||
var res = await AppDio().get<String>(source.url,
|
||||
options: Options(responseType: ResponseType.plain));
|
||||
var res = await AppDio().get<String>(
|
||||
source.url,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
if (cancel) return;
|
||||
controller?.close();
|
||||
await ComicSourceParser().parse(res.data!, source.filePath);
|
||||
@@ -192,12 +191,11 @@ class _BodyState extends State<_Body> {
|
||||
}
|
||||
|
||||
Widget buildCard(BuildContext context) {
|
||||
Widget buildButton(
|
||||
{required Widget child, required VoidCallback onPressed}) {
|
||||
return Button.normal(
|
||||
onPressed: onPressed,
|
||||
child: child,
|
||||
).fixHeight(32);
|
||||
Widget buildButton({
|
||||
required Widget child,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Button.normal(onPressed: onPressed, child: child).fixHeight(32);
|
||||
}
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
@@ -218,7 +216,9 @@ class _BodyState extends State<_Body> {
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
suffix: IconButton(
|
||||
onPressed: () => handleAddSource(url),
|
||||
icon: const Icon(Icons.check))),
|
||||
icon: const Icon(Icons.check),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
url = value;
|
||||
},
|
||||
@@ -245,10 +245,7 @@ class _BodyState extends State<_Body> {
|
||||
),
|
||||
ListTile(
|
||||
title: Text("Help".tl),
|
||||
trailing: buildButton(
|
||||
onPressed: help,
|
||||
child: Text("Open".tl),
|
||||
),
|
||||
trailing: buildButton(onPressed: help, child: Text("Open".tl)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text("Check updates".tl),
|
||||
@@ -277,7 +274,8 @@ class _BodyState extends State<_Body> {
|
||||
|
||||
void help() {
|
||||
launchUrlString(
|
||||
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
|
||||
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleAddSource(String url) async {
|
||||
@@ -288,11 +286,16 @@ class _BodyState extends State<_Body> {
|
||||
splits.removeWhere((element) => element == "");
|
||||
var fileName = splits.last;
|
||||
bool cancel = false;
|
||||
var controller = showLoadingDialog(App.rootContext,
|
||||
onCancel: () => cancel = true, barrierDismissible: false);
|
||||
var controller = showLoadingDialog(
|
||||
App.rootContext,
|
||||
onCancel: () => cancel = true,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
try {
|
||||
var res = await AppDio()
|
||||
.get<String>(url, options: Options(responseType: ResponseType.plain));
|
||||
var res = await AppDio().get<String>(
|
||||
url,
|
||||
options: Options(responseType: ResponseType.plain),
|
||||
);
|
||||
if (cancel) return;
|
||||
controller.close();
|
||||
await addSource(res.data!, fileName);
|
||||
@@ -322,60 +325,139 @@ 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);
|
||||
if (json != null) {
|
||||
setState(() {
|
||||
json = null;
|
||||
});
|
||||
}
|
||||
if (controller.text.isEmpty) {
|
||||
setState(() {
|
||||
json = [];
|
||||
});
|
||||
return;
|
||||
}
|
||||
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!);
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
@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(),
|
||||
);
|
||||
return PopUpWidgetScaffold(title: "Comic Source".tl, 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,
|
||||
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("Repo 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: () {
|
||||
launchUrlString(
|
||||
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
|
||||
);
|
||||
},
|
||||
child: Text("Help".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(
|
||||
strokeWidth: 2,
|
||||
).fixWidth(24).fixHeight(24),
|
||||
);
|
||||
}
|
||||
|
||||
index--;
|
||||
|
||||
var key = json![index]["key"];
|
||||
var action = currentKey.contains(key)
|
||||
? const Icon(Icons.check, size: 20).paddingRight(8)
|
||||
@@ -403,16 +485,20 @@ class _ComicSourceListState extends State<_ComicSourceList> {
|
||||
},
|
||||
).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(json![index]["version"]),
|
||||
subtitle: Text(description),
|
||||
trailing: action,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _validatePages() {
|
||||
List explorePages = appdata.settings['explore_pages'];
|
||||
@@ -461,6 +547,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) {
|
||||
@@ -477,10 +564,14 @@ 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();
|
||||
}
|
||||
@@ -515,15 +606,10 @@ class __EditFilePageState extends State<_EditFilePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
title: Text("Edit".tl),
|
||||
),
|
||||
appBar: Appbar(title: Text("Edit".tl)),
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 0.6,
|
||||
color: context.colorScheme.outlineVariant,
|
||||
),
|
||||
Container(height: 0.6, color: context.colorScheme.outlineVariant),
|
||||
Expanded(
|
||||
child: CodeEditor(
|
||||
initialValue: current,
|
||||
@@ -564,9 +650,11 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
||||
}
|
||||
|
||||
void showUpdateDialog() async {
|
||||
var text = ComicSourceManager().availableUpdates.entries.map((e) {
|
||||
var text = ComicSourceManager().availableUpdates.entries
|
||||
.map((e) {
|
||||
return "${ComicSource.find(e.key)!.name}: ${e.value}";
|
||||
}).join("\n");
|
||||
})
|
||||
.join("\n");
|
||||
bool doUpdate = false;
|
||||
await showDialog(
|
||||
context: App.rootContext,
|
||||
@@ -704,10 +792,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
child: ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(
|
||||
source.name,
|
||||
style: ts.s18,
|
||||
),
|
||||
Text(source.name, style: ts.s18),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -740,7 +825,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
).paddingLeft(4)
|
||||
).paddingLeft(4),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
@@ -785,15 +870,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: buildSourceSettings().toList(),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: _buildAccount().toList(),
|
||||
),
|
||||
child: Column(children: buildSourceSettings().toList()),
|
||||
),
|
||||
SliverToBoxAdapter(child: Column(children: _buildAccount().toList())),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -819,8 +898,10 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current = item.value['options']
|
||||
.firstWhere((e) => e['value'] == current)['text'] ??
|
||||
current =
|
||||
item.value['options'].firstWhere(
|
||||
(e) => e['value'] == current,
|
||||
)['text'] ??
|
||||
current;
|
||||
}
|
||||
yield ListTile(
|
||||
@@ -828,8 +909,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
trailing: Select(
|
||||
current: (current as String).ts(source.key),
|
||||
values: (item.value['options'] as List)
|
||||
.map<String>((e) =>
|
||||
((e['text'] ?? e['value']) as String).ts(source.key))
|
||||
.map<String>(
|
||||
(e) => ((e['text'] ?? e['value']) as String).ts(source.key),
|
||||
)
|
||||
.toList(),
|
||||
onTap: (i) {
|
||||
source.data['settings'][key] =
|
||||
@@ -857,8 +939,11 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
source.data['settings'][key] ?? item.value['default'] ?? '';
|
||||
yield ListTile(
|
||||
title: Text((item.value['title'] as String).ts(source.key)),
|
||||
subtitle:
|
||||
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
current,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
@@ -899,10 +984,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () async {
|
||||
await context.to(
|
||||
() => _LoginPage(
|
||||
config: source.account!,
|
||||
source: source,
|
||||
),
|
||||
() => _LoginPage(config: source.account!, source: source),
|
||||
);
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
@@ -948,9 +1030,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
||||
trailing: loading
|
||||
? const SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
);
|
||||
@@ -991,9 +1071,7 @@ class _LoginPageState extends State<_LoginPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: const Appbar(
|
||||
title: Text(''),
|
||||
),
|
||||
appBar: const Appbar(title: Text('')),
|
||||
body: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -1121,8 +1199,9 @@ class _LoginPageState extends State<_LoginPage> {
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
var cookies =
|
||||
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
|
||||
var cookies = widget.config.cookieFields!
|
||||
.map((e) => _cookies[e] ?? '')
|
||||
.toList();
|
||||
widget.config.validateCookies!(cookies).then((value) {
|
||||
if (value) {
|
||||
widget.source.data['account'] = 'ok';
|
||||
|
@@ -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;
|
||||
|
@@ -18,14 +18,15 @@ import 'package:venera/network/download.dart';
|
||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/tags_translation.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
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;
|
||||
|
||||
@@ -65,6 +66,11 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
folder = data['name'];
|
||||
isNetwork = data['isNetwork'] ?? false;
|
||||
}
|
||||
if (folder != null
|
||||
&& !isNetwork
|
||||
&& !LocalFavoritesManager().existsFolder(folder!)) {
|
||||
folder = null;
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
|
@@ -1,5 +1,11 @@
|
||||
part of 'favorites_page.dart';
|
||||
|
||||
const _localAllFolderLabel = '^_^[%local_all%]^_^';
|
||||
|
||||
/// If the number of comics in a folder exceeds this limit, it will be
|
||||
/// fetched asynchronously.
|
||||
const _asyncDataFetchLimit = 500;
|
||||
|
||||
class _LocalFavoritesPage extends StatefulWidget {
|
||||
const _LocalFavoritesPage({required this.folder, super.key});
|
||||
|
||||
@@ -31,25 +37,112 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
|
||||
int? lastSelectedIndex;
|
||||
|
||||
void updateComics() {
|
||||
if (keyword.isEmpty) {
|
||||
bool get isAllFolder => widget.folder == _localAllFolderLabel;
|
||||
|
||||
LocalFavoritesManager get manager => LocalFavoritesManager();
|
||||
|
||||
bool isLoading = false;
|
||||
|
||||
var searchResults = <FavoriteItem>[];
|
||||
|
||||
void updateSearchResult() {
|
||||
setState(() {
|
||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
||||
});
|
||||
if (keyword.trim().isEmpty) {
|
||||
searchResults = comics;
|
||||
} else {
|
||||
setState(() {
|
||||
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword);
|
||||
searchResults = [];
|
||||
for (var comic in comics) {
|
||||
if (matchKeyword(keyword, comic)) {
|
||||
searchResults.add(comic);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void updateComics() {
|
||||
if (isLoading) return;
|
||||
if (isAllFolder) {
|
||||
var totalComics = manager.totalComics;
|
||||
if (totalComics < _asyncDataFetchLimit) {
|
||||
comics = manager.getAllComics();
|
||||
} else {
|
||||
isLoading = true;
|
||||
manager
|
||||
.getAllComicsAsync()
|
||||
.minTime(const Duration(milliseconds: 200))
|
||||
.then((value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
comics = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var folderComics = manager.folderComics(widget.folder);
|
||||
if (folderComics < _asyncDataFetchLimit) {
|
||||
comics = manager.getFolderComics(widget.folder);
|
||||
} else {
|
||||
isLoading = true;
|
||||
manager
|
||||
.getFolderComicsAsync(widget.folder)
|
||||
.minTime(const Duration(milliseconds: 200))
|
||||
.then((value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
comics = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
bool matchKeyword(String keyword, FavoriteItem comic) {
|
||||
var list = keyword.split(" ");
|
||||
for (var k in list) {
|
||||
if (k.isEmpty) continue;
|
||||
if (comic.title.contains(k)) {
|
||||
continue;
|
||||
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) {
|
||||
continue;
|
||||
} else if (comic.tags.any((tag) {
|
||||
if (tag == k) {
|
||||
return true;
|
||||
} else if (tag.contains(':') && tag.split(':')[1] == k) {
|
||||
return true;
|
||||
} else if (App.locale.languageCode != 'en' &&
|
||||
tag.translateTagsToCN == k) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})) {
|
||||
continue;
|
||||
} else if (comic.author == k) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
|
||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
||||
if (!isAllFolder) {
|
||||
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
||||
networkSource = a;
|
||||
networkFolder = b;
|
||||
} else {
|
||||
networkSource = null;
|
||||
networkFolder = null;
|
||||
}
|
||||
comics = [];
|
||||
updateComics();
|
||||
LocalFavoritesManager().addListener(updateComics);
|
||||
super.initState();
|
||||
}
|
||||
@@ -62,16 +155,33 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
|
||||
void selectAll() {
|
||||
setState(() {
|
||||
if (searchMode) {
|
||||
selectedComics = searchResults.asMap().map((k, v) => MapEntry(v, true));
|
||||
} else {
|
||||
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void invertSelection() {
|
||||
setState(() {
|
||||
comics.asMap().forEach((k, v) {
|
||||
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
|
||||
});
|
||||
selectedComics.removeWhere((k, v) => !v);
|
||||
if (searchMode) {
|
||||
for (var c in searchResults) {
|
||||
if (selectedComics.containsKey(c)) {
|
||||
selectedComics.remove(c);
|
||||
} else {
|
||||
selectedComics[c] = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (var c in comics) {
|
||||
if (selectedComics.containsKey(c)) {
|
||||
selectedComics.remove(c);
|
||||
} else {
|
||||
selectedComics[c] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +223,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 +250,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(
|
||||
@@ -191,11 +306,14 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
keyword = "";
|
||||
searchMode = true;
|
||||
updateSearchResult();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!isAllFolder)
|
||||
MenuButton(
|
||||
entries: [
|
||||
MenuEntry(
|
||||
@@ -220,7 +338,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.reorder,
|
||||
text: "Reorder".tl,
|
||||
@@ -241,7 +360,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.upload_file,
|
||||
text: "Export".tl,
|
||||
@@ -253,7 +373,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
data: utf8.encode(json),
|
||||
filename: "${widget.folder}.json",
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.update,
|
||||
text: "Update Comics Info".tl,
|
||||
@@ -265,7 +386,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete Folder".tl,
|
||||
@@ -284,7 +406,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
favPage.folderList?.updateFolders();
|
||||
},
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -310,10 +433,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
"Selected @c comics".tlParams({"c": selectedComics.length})),
|
||||
actions: [
|
||||
MenuButton(entries: [
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.drive_file_move,
|
||||
text: "Move to folder".tl,
|
||||
onClick: () => favoriteOption('move')),
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
text: "Copy to folder".tl,
|
||||
@@ -330,6 +455,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
icon: Icons.flip,
|
||||
text: "Invert Selection".tl,
|
||||
onClick: invertSelection),
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete Comic".tl,
|
||||
@@ -379,10 +505,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
keyword = "";
|
||||
updateComics();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -391,19 +517,30 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search".tl,
|
||||
border: InputBorder.none,
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onChanged: (v) {
|
||||
keyword = v;
|
||||
updateComics();
|
||||
updateSearchResult();
|
||||
},
|
||||
).paddingBottom(8).paddingRight(8),
|
||||
),
|
||||
if (isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
comics: searchMode ? searchResults : comics,
|
||||
selections: selectedComics,
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.delete,
|
||||
text: "Delete".tl,
|
||||
@@ -518,11 +655,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,
|
||||
@@ -640,32 +775,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
return;
|
||||
}
|
||||
if (option == 'move') {
|
||||
for (var c in selectedComics.keys) {
|
||||
for (var s in selectedLocalFolders) {
|
||||
LocalFavoritesManager().moveFavorite(
|
||||
var comics = selectedComics.keys
|
||||
.map((e) => e as FavoriteItem)
|
||||
.toList();
|
||||
for (var f in selectedLocalFolders) {
|
||||
LocalFavoritesManager().batchMoveFavorites(
|
||||
favPage.folder as String,
|
||||
s,
|
||||
c.id,
|
||||
(c as FavoriteItem).type);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (var c in selectedComics.keys) {
|
||||
for (var s in selectedLocalFolders) {
|
||||
LocalFavoritesManager().addComic(
|
||||
s,
|
||||
FavoriteItem(
|
||||
id: c.id,
|
||||
name: c.title,
|
||||
coverPath: c.cover,
|
||||
author: c.subtitle ?? '',
|
||||
type: ComicType((c.sourceKey == 'local'
|
||||
? 0
|
||||
: c.sourceKey.hashCode)),
|
||||
tags: c.tags ?? [],
|
||||
),
|
||||
f,
|
||||
comics,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
var comics = selectedComics.keys
|
||||
.map((e) => e as FavoriteItem)
|
||||
.toList();
|
||||
for (var f in selectedLocalFolders) {
|
||||
LocalFavoritesManager().batchCopyFavorites(
|
||||
favPage.folder as String,
|
||||
f,
|
||||
comics,
|
||||
);
|
||||
}
|
||||
}
|
||||
App.rootContext.pop();
|
||||
@@ -701,13 +830,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
}
|
||||
|
||||
void _deleteComicWithId() {
|
||||
for (var c in selectedComics.keys) {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
widget.folder,
|
||||
c.id,
|
||||
(c as FavoriteItem).type,
|
||||
);
|
||||
}
|
||||
var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList();
|
||||
LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted);
|
||||
_cancel();
|
||||
}
|
||||
}
|
||||
@@ -727,7 +851,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) {
|
||||
@@ -748,7 +872,10 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
if (changed) {
|
||||
// Delay to ensure navigation is completed
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
LocalFavoritesManager().reorder(comics, widget.name);
|
||||
});
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
@@ -783,7 +910,9 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
||||
appBar: Appbar(
|
||||
title: Text("Reorder".tl),
|
||||
actions: [
|
||||
IconButton(
|
||||
Tooltip(
|
||||
message: "Information".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () {
|
||||
showInfoDialog(
|
||||
@@ -793,17 +922,19 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
),
|
||||
Tooltip(
|
||||
message: "Reverse".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.swap_vert),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
comics = comics.reversed.toList();
|
||||
changed = true;
|
||||
showToast(
|
||||
message: "Reversed successfully".tl, context: context);
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ReorderableBuilder<FavoriteItem>(
|
||||
|
@@ -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,
|
||||
|
@@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
folders = LocalFavoritesManager().folderNames;
|
||||
findNetworkFolders();
|
||||
appdata.settings.addListener(updateFolders);
|
||||
LocalFavoritesManager().addListener(updateFolders);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
appdata.settings.removeListener(updateFolders);
|
||||
LocalFavoritesManager().removeListener(updateFolders);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -86,9 +88,34 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
padding: widget.withAppbar
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.only(top: context.padding.top),
|
||||
itemCount: folders.length + networkFolders.length + 2,
|
||||
itemCount: folders.length + networkFolders.length + 3,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return buildLocalTitle();
|
||||
}
|
||||
index--;
|
||||
if (index == 0) {
|
||||
return buildLocalFolder(_localAllFolderLabel);
|
||||
}
|
||||
index--;
|
||||
if (index < folders.length) {
|
||||
return buildLocalFolder(folders[index]);
|
||||
}
|
||||
index -= folders.length;
|
||||
if (index == 0) {
|
||||
return buildNetworkTitle();
|
||||
}
|
||||
index--;
|
||||
return buildNetworkFolder(networkFolders[index]);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLocalTitle() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
@@ -102,21 +129,13 @@ 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,
|
||||
onClick: () {
|
||||
newFolder().then((value) {
|
||||
setState(() {
|
||||
folders =
|
||||
LocalFavoritesManager().folderNames;
|
||||
folders = LocalFavoritesManager().folderNames;
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -127,8 +146,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
onClick: () {
|
||||
sortFolders().then((value) {
|
||||
setState(() {
|
||||
folders =
|
||||
LocalFavoritesManager().folderNames;
|
||||
folders = LocalFavoritesManager().folderNames;
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -139,12 +157,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
).paddingHorizontal(16),
|
||||
);
|
||||
}
|
||||
index--;
|
||||
if (index < folders.length) {
|
||||
return buildLocalFolder(folders[index]);
|
||||
}
|
||||
index -= folders.length;
|
||||
if (index == 0) {
|
||||
|
||||
Widget buildNetworkTitle() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
@@ -178,18 +192,18 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
).paddingHorizontal(16),
|
||||
);
|
||||
}
|
||||
index--;
|
||||
return buildNetworkFolder(networkFolders[index]);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLocalFolder(String name) {
|
||||
bool isSelected = name == favPage.folder && !favPage.isNetwork;
|
||||
int count = 0;
|
||||
if (name == _localAllFolderLabel) {
|
||||
count = LocalFavoritesManager().totalComics;
|
||||
} else {
|
||||
count = LocalFavoritesManager().folderComics(name);
|
||||
}
|
||||
var folderName = name == _localAllFolderLabel
|
||||
? "All".tl
|
||||
: getFavoriteDataOrNull(name)?.title ?? name;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (isSelected) {
|
||||
@@ -214,7 +228,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Text(name),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(folderName),
|
||||
),
|
||||
Container(
|
||||
margin: EdgeInsets.only(right: 8),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(count.toString()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -140,6 +140,14 @@ class _HistoryPageState extends State<HistoryPage> {
|
||||
title: 'Clear History'.tl,
|
||||
content: Text('Are you sure you want to clear your history?'.tl),
|
||||
actions: [
|
||||
Button.outlined(
|
||||
onPressed: () {
|
||||
HistoryManager().clearUnfavoritedHistory();
|
||||
context.pop();
|
||||
},
|
||||
child: Text('Clear Unfavorited'.tl),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Button.filled(
|
||||
color: context.colorScheme.error,
|
||||
onPressed: () {
|
||||
|
@@ -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(
|
||||
@@ -297,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,
|
||||
@@ -400,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),
|
||||
@@ -941,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';
|
||||
@@ -13,6 +14,7 @@ import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/pdf.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'package:zip_flutter/zip_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class LocalComicsPage extends StatefulWidget {
|
||||
const LocalComicsPage({super.key});
|
||||
@@ -142,6 +144,14 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
addFavorite(selectedComics.keys.toList());
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
MenuEntry(
|
||||
icon: Icons.folder_open,
|
||||
text: "Open Folder".tl,
|
||||
onClick: () {
|
||||
openComicFolder(selectedComics.keys.first);
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
MenuEntry(
|
||||
icon: Icons.chrome_reader_mode_outlined,
|
||||
@@ -304,11 +314,21 @@ 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) {
|
||||
return [
|
||||
MenuEntry(
|
||||
icon: Icons.folder_open,
|
||||
text: "Open Folder".tl,
|
||||
onClick: () {
|
||||
openComicFolder(c as LocalComic);
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.delete,
|
||||
text: "Delete".tl,
|
||||
@@ -357,10 +377,22 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
bool removeComicFile = true;
|
||||
bool removeFavoriteAndHistory = true;
|
||||
return StatefulBuilder(builder: (context, state) {
|
||||
return ContentDialog(
|
||||
title: "Delete".tl,
|
||||
content: CheckboxListTile(
|
||||
content: Column(
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
title: Text("Remove local favorite and history".tl),
|
||||
value: removeFavoriteAndHistory,
|
||||
onChanged: (v) {
|
||||
state(() {
|
||||
removeFavoriteAndHistory = !removeFavoriteAndHistory;
|
||||
});
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
title: Text("Also remove files on disk".tl),
|
||||
value: removeComicFile,
|
||||
onChanged: (v) {
|
||||
@@ -368,17 +400,26 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
removeComicFile = !removeComicFile;
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (comics.length == 1 && comics.first.hasChapters)
|
||||
TextButton(
|
||||
child: Text("Delete Chapters".tl),
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
showDeleteChaptersPopWindow(context, comics.first);
|
||||
},
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
context.pop();
|
||||
for (var comic in comics) {
|
||||
LocalManager().deleteComic(
|
||||
comic,
|
||||
LocalManager().batchDeleteComics(
|
||||
comics,
|
||||
removeComicFile,
|
||||
removeFavoriteAndHistory,
|
||||
);
|
||||
}
|
||||
isDeleted = true;
|
||||
},
|
||||
child: Text("Confirm".tl),
|
||||
@@ -441,7 +482,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) {
|
||||
@@ -490,3 +534,102 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
|
||||
typedef ExportComicFunc = Future<File> Function(
|
||||
LocalComic comic, String outFilePath);
|
||||
|
||||
/// Opens the folder containing the comic in the system file explorer
|
||||
Future<void> openComicFolder(LocalComic comic) async {
|
||||
try {
|
||||
final folderPath = comic.baseDir;
|
||||
|
||||
if (App.isWindows) {
|
||||
await Process.run('explorer', [folderPath]);
|
||||
} else if (App.isMacOS) {
|
||||
await Process.run('open', [folderPath]);
|
||||
} else if (App.isLinux) {
|
||||
// Try different file managers commonly found on Linux
|
||||
try {
|
||||
await Process.run('xdg-open', [folderPath]);
|
||||
} catch (e) {
|
||||
// Fallback to other common file managers
|
||||
try {
|
||||
await Process.run('nautilus', [folderPath]);
|
||||
} catch (e) {
|
||||
try {
|
||||
await Process.run('dolphin', [folderPath]);
|
||||
} catch (e) {
|
||||
try {
|
||||
await Process.run('thunar', [folderPath]);
|
||||
} catch (e) {
|
||||
// Last resort: use the URL launcher with file:// protocol
|
||||
await launchUrlString('file://$folderPath');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For mobile platforms, use the URL launcher with file:// protocol
|
||||
await launchUrlString('file://$folderPath');
|
||||
}
|
||||
} catch (e, s) {
|
||||
Log.error("Open Folder", "Failed to open comic folder: $e", s);
|
||||
// Show error message to user
|
||||
if (App.rootContext.mounted) {
|
||||
App.rootContext.showMessage(message: "Failed to open folder: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void showDeleteChaptersPopWindow(BuildContext context, LocalComic comic) {
|
||||
var chapters = <String>[];
|
||||
|
||||
showPopUpWidget(
|
||||
context,
|
||||
PopUpWidgetScaffold(
|
||||
title: "Delete Chapters".tl,
|
||||
body: StatefulBuilder(builder: (context, setState) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: comic.downloadedChapters.length,
|
||||
itemBuilder: (context, index) {
|
||||
var id = comic.downloadedChapters[index];
|
||||
var chapter = comic.chapters![id] ?? "Unknown Chapter";
|
||||
return CheckboxListTile(
|
||||
title: Text(chapter),
|
||||
value: chapters.contains(id),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
if (v == true) {
|
||||
chapters.add(id);
|
||||
} else {
|
||||
chapters.remove(id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
LocalManager().deleteComicChapters(comic, chapters);
|
||||
});
|
||||
App.rootContext.pop();
|
||||
},
|
||||
child: Text("Submit".tl),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -152,12 +152,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
||||
|
||||
bool _dragInProgress = false;
|
||||
|
||||
bool get _enableDoubleTapToZoom => appdata.settings["enableDoubleTapToZoom"];
|
||||
|
||||
void onTapUp(TapUpDetails event) {
|
||||
if (_longPressInProgress) {
|
||||
_longPressInProgress = false;
|
||||
return;
|
||||
}
|
||||
final location = event.globalPosition;
|
||||
if (!_enableDoubleTapToZoom) {
|
||||
onTap(location);
|
||||
return;
|
||||
}
|
||||
final previousLocation = _previousEvent?.globalPosition;
|
||||
if (previousLocation != null) {
|
||||
if ((location - previousLocation).distanceSquared <
|
||||
@@ -281,6 +287,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 +321,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 {
|
||||
|
@@ -21,12 +21,18 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
ImageDownloader.cancelAllLoadingImages();
|
||||
}
|
||||
|
||||
void load() async {
|
||||
if (inProgress) return;
|
||||
inProgress = true;
|
||||
if (reader.type == ComicType.local ||
|
||||
(LocalManager()
|
||||
.isDownloaded(reader.cid, reader.type, reader.chapter))) {
|
||||
(LocalManager().isDownloaded(
|
||||
reader.cid, reader.type, reader.chapter, reader.widget.chapters))) {
|
||||
try {
|
||||
var images = await LocalManager()
|
||||
.getImages(reader.cid, reader.type, reader.chapter);
|
||||
@@ -34,6 +40,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
||||
reader.images = images;
|
||||
reader.isLoading = false;
|
||||
inProgress = false;
|
||||
Future.microtask(() {
|
||||
reader.updateHistory();
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
@@ -43,9 +52,10 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
var cp = reader.widget.chapters?.ids.elementAtOrNull(reader.chapter - 1);
|
||||
var res = await reader.type.comicSource!.loadComicPages!(
|
||||
reader.widget.cid,
|
||||
reader.widget.chapters?.ids.elementAt(reader.chapter - 1),
|
||||
cp,
|
||||
);
|
||||
if (res.error) {
|
||||
setState(() {
|
||||
@@ -58,6 +68,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
||||
reader.images = res.data;
|
||||
reader.isLoading = false;
|
||||
inProgress = false;
|
||||
Future.microtask(() {
|
||||
reader.updateHistory();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -103,46 +116,107 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
implements _ImageViewController {
|
||||
late PageController controller;
|
||||
|
||||
late List<bool> cached;
|
||||
|
||||
int get preCacheCount => appdata.settings["preloadImageCount"];
|
||||
|
||||
var photoViewControllers = <int, PhotoViewController>{};
|
||||
|
||||
late _ReaderState reader;
|
||||
|
||||
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
|
||||
/// [totalPages] is the total number of pages in the current chapter.
|
||||
/// More than one images can be displayed on one page.
|
||||
int get totalPages {
|
||||
if (!reader.showSingleImageOnFirstPage) {
|
||||
return (reader.images!.length / reader.imagesPerPage).ceil();
|
||||
} else {
|
||||
return 1 +
|
||||
((reader.images!.length - 1) / reader.imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
var imageStates = <State<ComicImage>>{};
|
||||
|
||||
bool isLongPressing = false;
|
||||
|
||||
int fingers = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
reader = context.reader;
|
||||
controller = PageController(initialPage: reader.page);
|
||||
reader._imageViewController = this;
|
||||
cached = List.filled(reader.maxPage + 2, false);
|
||||
Future.microtask(() {
|
||||
context.readerScaffold.setFloatingButton(0);
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void cache(int current) {
|
||||
for (int i = current + 1; i <= current + preCacheCount; i++) {
|
||||
if (i <= totalPages && !cached[i]) {
|
||||
int startIndex = (i - 1) * reader.imagesPerPage;
|
||||
int endIndex =
|
||||
math.min(startIndex + reader.imagesPerPage, reader.images!.length);
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
precacheImage(
|
||||
_createImageProviderFromKey(reader.images![i], context), context);
|
||||
/// Get the range of images for the given page. [page] is 1-based.
|
||||
(int start, int end) getPageImagesRange(int page) {
|
||||
if (reader.showSingleImageOnFirstPage) {
|
||||
if (page == 1) {
|
||||
return (0, 1);
|
||||
} else {
|
||||
int startIndex = (page - 2) * reader.imagesPerPage + 1;
|
||||
int endIndex = math.min(
|
||||
startIndex + reader.imagesPerPage, reader.images!.length);
|
||||
return (startIndex, endIndex);
|
||||
}
|
||||
cached[i] = true;
|
||||
} else {
|
||||
int startIndex = (page - 1) * reader.imagesPerPage;
|
||||
int endIndex = math.min(
|
||||
startIndex + reader.imagesPerPage, reader.images!.length);
|
||||
return (startIndex, endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// [cache] is used to cache the images.
|
||||
/// The count of images to cache is determined by the [preCacheCount] setting.
|
||||
/// For previous page and next page, it will do a memory cache.
|
||||
/// For current page, it will do nothing because it is already on the screen.
|
||||
/// For other pages, it will do a pre-download cache.
|
||||
void cache(int startPage) {
|
||||
for (int i = startPage - 1; i <= startPage + preCacheCount; i++) {
|
||||
if (i == startPage || i <= 0 || i > totalPages) continue;
|
||||
bool shouldPreCache = i == startPage + 1 || i == startPage - 1;
|
||||
_cachePage(i, shouldPreCache);
|
||||
}
|
||||
}
|
||||
|
||||
void _cachePage(int page, bool shouldPreCache) {
|
||||
var (startIndex, endIndex) = getPageImagesRange(page);
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
if (shouldPreCache) {
|
||||
_precacheImage(i+1, context);
|
||||
} else {
|
||||
_preDownloadImage(i+1, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PhotoViewGallery.builder(
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
fingers++;
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
fingers--;
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
fingers--;
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if (isLongPressing) {
|
||||
var controller = photoViewControllers[reader.page]!;
|
||||
Offset value = event.delta;
|
||||
if (isLongPressing) {
|
||||
controller.updateMultiple(
|
||||
position: controller.position + value,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: PhotoViewGallery.builder(
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
),
|
||||
@@ -157,24 +231,23 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
child: const SizedBox(),
|
||||
);
|
||||
} else {
|
||||
int pageIndex = index - 1;
|
||||
int startIndex = pageIndex * reader.imagesPerPage;
|
||||
int endIndex = math.min(
|
||||
startIndex + reader.imagesPerPage, reader.images!.length);
|
||||
var (startIndex, endIndex) = getPageImagesRange(index);
|
||||
List<String> pageImages =
|
||||
reader.images!.sublist(startIndex, endIndex);
|
||||
|
||||
cached[index] = true;
|
||||
cache(index);
|
||||
|
||||
photoViewControllers[index] ??= PhotoViewController();
|
||||
|
||||
if (reader.imagesPerPage == 1) {
|
||||
if (reader.imagesPerPage == 1 || pageImages.length == 1) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
filterQuality: FilterQuality.medium,
|
||||
controller: photoViewControllers[index],
|
||||
imageProvider:
|
||||
_createImageProviderFromKey(pageImages[0], context),
|
||||
imageProvider: _createImageProviderFromKey(
|
||||
pageImages[0],
|
||||
context,
|
||||
startIndex + 1,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, error, s, retry) {
|
||||
return NetworkError(message: error.toString(), retry: retry);
|
||||
@@ -183,10 +256,11 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
}
|
||||
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
childSize: reader.size * 2,
|
||||
controller: photoViewControllers[index],
|
||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||
maxScale: PhotoViewComputedScale.covered * 10.0,
|
||||
child: buildPageImages(pageImages),
|
||||
child: buildPageImages(pageImages, startIndex),
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -216,30 +290,81 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
reader.setPage(i);
|
||||
context.readerScaffold.update();
|
||||
}
|
||||
// Remove other pages' controllers to reset their state.
|
||||
var keys = photoViewControllers.keys.toList();
|
||||
for (var key in keys) {
|
||||
if (key != i) {
|
||||
photoViewControllers.remove(key);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPageImages(List<String> images) {
|
||||
Widget buildPageImages(List<String> images, int startIndex) {
|
||||
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
|
||||
? Axis.vertical
|
||||
: Axis.horizontal;
|
||||
|
||||
bool reverse = reader.mode == ReaderMode.galleryRightToLeft;
|
||||
if (reverse) {
|
||||
images = images.reversed.toList();
|
||||
}
|
||||
|
||||
List<Widget> imageWidgets = images.map((imageKey) {
|
||||
List<Widget> imageWidgets;
|
||||
|
||||
if (images.length == 2) {
|
||||
imageWidgets = [
|
||||
Expanded(
|
||||
child: ComicImage(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
image: _createImageProviderFromKey(
|
||||
images[0],
|
||||
context,
|
||||
startIndex + 1,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
alignment: axis == Axis.vertical
|
||||
? Alignment.bottomCenter
|
||||
: Alignment.centerRight,
|
||||
onInit: (state) => imageStates.add(state),
|
||||
onDispose: (state) => imageStates.remove(state),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ComicImage(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
image: _createImageProviderFromKey(
|
||||
images[1],
|
||||
context,
|
||||
startIndex + 2,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
alignment: axis == Axis.vertical
|
||||
? Alignment.topCenter
|
||||
: Alignment.centerLeft,
|
||||
onInit: (state) => imageStates.add(state),
|
||||
onDispose: (state) => imageStates.remove(state),
|
||||
),
|
||||
)
|
||||
];
|
||||
} else {
|
||||
imageWidgets = images.map((imageKey) {
|
||||
startIndex++;
|
||||
ImageProvider imageProvider =
|
||||
_createImageProviderFromKey(imageKey, context);
|
||||
_createImageProviderFromKey(imageKey, context, startIndex);
|
||||
return Expanded(
|
||||
child: ComicImage(
|
||||
image: imageProvider,
|
||||
fit: BoxFit.contain,
|
||||
onInit: (state) => imageStates.add(state),
|
||||
onDispose: (state) => imageStates.remove(state),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
|
||||
if (reverse) {
|
||||
imageWidgets = imageWidgets.reversed.toList();
|
||||
}
|
||||
|
||||
return axis == Axis.vertical
|
||||
@@ -276,28 +401,41 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
@override
|
||||
void handleLongPressDown(Offset location) {
|
||||
if (!appdata.settings['enableLongPressToZoom']) {
|
||||
if (!appdata.settings['enableLongPressToZoom'] || fingers != 1) {
|
||||
return;
|
||||
}
|
||||
var photoViewController = photoViewControllers[reader.page]!;
|
||||
double target = photoViewController.getInitialScale!.call()! * 1.75;
|
||||
var size = MediaQuery.of(context).size;
|
||||
var size = reader.size;
|
||||
Offset zoomPosition;
|
||||
if (appdata.settings['longPressZoomPosition'] != 'center') {
|
||||
zoomPosition = Offset(
|
||||
size.width / 2 - location.dx,
|
||||
size.height / 2 - location.dy,
|
||||
);
|
||||
} else {
|
||||
zoomPosition = Offset(0, 0);
|
||||
}
|
||||
photoViewController.animateScale?.call(
|
||||
target,
|
||||
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy),
|
||||
zoomPosition,
|
||||
);
|
||||
isLongPressing = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void handleLongPressUp(Offset location) {
|
||||
if (!appdata.settings['enableLongPressToZoom']) {
|
||||
if (!appdata.settings['enableLongPressToZoom'] || !isLongPressing) {
|
||||
return;
|
||||
}
|
||||
var photoViewController = photoViewControllers[reader.page]!;
|
||||
double target = photoViewController.getInitialScale!.call()!;
|
||||
photoViewController.animateScale?.call(target);
|
||||
isLongPressing = false;
|
||||
}
|
||||
|
||||
Timer? keyRepeatTimer;
|
||||
|
||||
@override
|
||||
void handleKeyEvent(KeyEvent event) {
|
||||
bool? forward;
|
||||
@@ -320,18 +458,37 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
forward = false;
|
||||
}
|
||||
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (keyRepeatTimer != null) {
|
||||
keyRepeatTimer!.cancel();
|
||||
keyRepeatTimer = null;
|
||||
}
|
||||
if (forward == true) {
|
||||
controller.nextPage(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
reader.toPage(reader.page+1);
|
||||
} else if (forward == false) {
|
||||
controller.previousPage(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
reader.toPage(reader.page-1);
|
||||
}
|
||||
}
|
||||
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
|
||||
keyRepeatTimer = Timer.periodic(
|
||||
reader.enablePageAnimation
|
||||
? const Duration(milliseconds: 200)
|
||||
: const Duration(milliseconds: 50),
|
||||
(timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
} else if (forward == true) {
|
||||
reader.toPage(reader.page+1);
|
||||
} else if (forward == false) {
|
||||
reader.toPage(reader.page-1);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (event is KeyUpEvent && keyRepeatTimer != null) {
|
||||
keyRepeatTimer!.cancel();
|
||||
keyRepeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,6 +496,34 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
bool handleOnTap(Offset location) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
||||
var imageKey = getImageKeyByOffset(offset);
|
||||
if (imageKey == null) return null;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String? getImageKeyByOffset(Offset offset) {
|
||||
String? imageKey;
|
||||
if (reader.imagesPerPage == 1) {
|
||||
imageKey = reader.images![reader.page - 1];
|
||||
} else {
|
||||
for (var imageState in imageStates) {
|
||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageKey;
|
||||
}
|
||||
}
|
||||
|
||||
const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
|
||||
@@ -383,6 +568,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
/// To handle the tap event, we need to know if the user was scrolling before the delay.
|
||||
bool delayedIsScrolling = false;
|
||||
|
||||
var imageStates = <State<ComicImage>>{};
|
||||
|
||||
void delayedSetIsScrolling(bool value) {
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 300),
|
||||
@@ -395,6 +582,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
bool jumpToNextChapter = false;
|
||||
bool jumpToPrevChapter = false;
|
||||
|
||||
bool isZoomedIn = false;
|
||||
bool isLongPressing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
reader = context.reader;
|
||||
@@ -467,7 +657,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
void cacheImages(int current) {
|
||||
for (int i = current + 1; i <= current + preCacheCount; i++) {
|
||||
if (i <= reader.maxPage && !cached[i]) {
|
||||
_precacheImage(i, context);
|
||||
_preDownloadImage(i, context);
|
||||
cached[i] = true;
|
||||
}
|
||||
}
|
||||
@@ -485,6 +675,23 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
}
|
||||
}
|
||||
|
||||
bool onScaleUpdate([double? scale]) {
|
||||
if (prepareToNextChapter || prepareToPrevChapter) {
|
||||
setState(() {
|
||||
prepareToPrevChapter = false;
|
||||
prepareToNextChapter = false;
|
||||
});
|
||||
context.readerScaffold.setFloatingButton(0);
|
||||
}
|
||||
var isZoomedIn = (scale ?? photoViewController.scale) != 1.0;
|
||||
if (isZoomedIn != this.isZoomedIn) {
|
||||
setState(() {
|
||||
this.isZoomedIn = isZoomedIn;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget widget = ScrollablePositionedList.builder(
|
||||
@@ -506,6 +713,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
reverse: reader.mode == ReaderMode.continuousRightToLeft,
|
||||
physics: isCTRLPressed || _isMouseScrolling || disableScroll
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: isZoomedIn
|
||||
? const ClampingScrollPhysics()
|
||||
: const BouncingScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0 || index == reader.maxPage + 1) {
|
||||
@@ -529,6 +738,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.contain,
|
||||
onInit: (state) => imageStates.add(state),
|
||||
onDispose: (state) => imageStates.remove(state),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -593,18 +804,24 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
if (photoViewController.scale == 1 || fingers != 1) {
|
||||
return;
|
||||
}
|
||||
if (scrollController.offset !=
|
||||
scrollController.position.maxScrollExtent &&
|
||||
scrollController.offset !=
|
||||
scrollController.position.minScrollExtent) {
|
||||
if (reader.mode == ReaderMode.continuousTopToBottom) {
|
||||
value = Offset(value.dx, 0);
|
||||
Offset offset;
|
||||
var sp = scrollController.position;
|
||||
if (sp.pixels <= sp.minScrollExtent ||
|
||||
sp.pixels >= sp.maxScrollExtent) {
|
||||
offset = Offset(value.dx, value.dy);
|
||||
} else {
|
||||
value = Offset(0, value.dy);
|
||||
if (reader.mode == ReaderMode.continuousTopToBottom) {
|
||||
offset = Offset(value.dx, 0);
|
||||
} else {
|
||||
offset = Offset(0, value.dy);
|
||||
}
|
||||
}
|
||||
if (isLongPressing) {
|
||||
offset += value;
|
||||
}
|
||||
photoViewController.updateMultiple(
|
||||
position: photoViewController.position + value);
|
||||
position: photoViewController.position + offset,
|
||||
);
|
||||
},
|
||||
onPointerSignal: onPointerSignal,
|
||||
child: widget,
|
||||
@@ -618,7 +835,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
delayedSetIsScrolling(false);
|
||||
}
|
||||
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
var scale = photoViewController.scale ?? 1.0;
|
||||
|
||||
if (notification is ScrollUpdateNotification &&
|
||||
(scale - 1).abs() < 0.05) {
|
||||
if (!scrollController.hasClients) return false;
|
||||
if (scrollController.position.pixels <=
|
||||
scrollController.position.minScrollExtent &&
|
||||
@@ -659,8 +879,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
},
|
||||
child: widget,
|
||||
);
|
||||
var width = MediaQuery.of(context).size.width;
|
||||
var height = MediaQuery.of(context).size.height;
|
||||
var width = reader.size.width;
|
||||
var height = reader.size.height;
|
||||
if (appdata.settings['limitImageWidth'] &&
|
||||
width / height > 0.7 &&
|
||||
reader.mode == ReaderMode.continuousTopToBottom) {
|
||||
@@ -676,6 +896,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
maxScale: 2.5,
|
||||
strictScale: true,
|
||||
controller: photoViewController,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
@@ -731,6 +952,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
target,
|
||||
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy),
|
||||
);
|
||||
onScaleUpdate(target);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -739,11 +961,22 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
return;
|
||||
}
|
||||
double target = photoViewController.getInitialScale!.call()! * 1.75;
|
||||
var size = MediaQuery.of(context).size;
|
||||
var size = reader.size;
|
||||
Offset zoomPosition;
|
||||
if (appdata.settings['longPressZoomPosition'] != 'center') {
|
||||
zoomPosition = Offset(
|
||||
size.width / 2 - location.dx,
|
||||
size.height / 2 - location.dy,
|
||||
);
|
||||
} else {
|
||||
zoomPosition = Offset(0, 0);
|
||||
}
|
||||
photoViewController.animateScale?.call(
|
||||
target,
|
||||
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy),
|
||||
zoomPosition,
|
||||
);
|
||||
onScaleUpdate(target);
|
||||
isLongPressing = true;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -753,6 +986,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
}
|
||||
double target = photoViewController.getInitialScale!.call()!;
|
||||
photoViewController.animateScale?.call(target);
|
||||
onScaleUpdate(target);
|
||||
isLongPressing = false;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -798,13 +1033,13 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
}
|
||||
if (forward == true) {
|
||||
scrollController.animateTo(
|
||||
scrollController.offset + context.height,
|
||||
scrollController.offset + context.height * 0.25,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
} else if (forward == false) {
|
||||
scrollController.animateTo(
|
||||
scrollController.offset - context.height,
|
||||
scrollController.offset - context.height * 0.25,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
@@ -818,10 +1053,37 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
||||
var imageKey = getImageKeyByOffset(offset);
|
||||
if (imageKey == null) return null;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String? getImageKeyByOffset(Offset offset) {
|
||||
String? imageKey;
|
||||
for (var imageState in imageStates) {
|
||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||
}
|
||||
}
|
||||
return imageKey;
|
||||
}
|
||||
}
|
||||
|
||||
ImageProvider _createImageProviderFromKey(
|
||||
String imageKey, BuildContext context) {
|
||||
String imageKey,
|
||||
BuildContext context,
|
||||
int page,
|
||||
) {
|
||||
var reader = context.reader;
|
||||
return ReaderImageProvider(
|
||||
imageKey,
|
||||
@@ -835,16 +1097,39 @@ ImageProvider _createImageProviderFromKey(
|
||||
ImageProvider _createImageProvider(int page, BuildContext context) {
|
||||
var reader = context.reader;
|
||||
var imageKey = reader.images![page - 1];
|
||||
return _createImageProviderFromKey(imageKey, context);
|
||||
return _createImageProviderFromKey(imageKey, context, page);
|
||||
}
|
||||
|
||||
/// [_precacheImage] is used to precache the image for the given page.
|
||||
/// The image is cached using the flutter's [precacheImage] method.
|
||||
/// The image will be downloaded and decoded into memory.
|
||||
void _precacheImage(int page, BuildContext context) {
|
||||
if (page <= 0 || page > context.reader.images!.length) {
|
||||
return;
|
||||
}
|
||||
precacheImage(
|
||||
_createImageProvider(page, context),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
/// [_preDownloadImage] is used to download the image for the given page.
|
||||
/// The image is downloaded using the [CacheManager] and saved to the local storage.
|
||||
void _preDownloadImage(int page, BuildContext context) {
|
||||
if (page <= 0 || page > context.reader.images!.length) {
|
||||
return;
|
||||
}
|
||||
var reader = context.reader;
|
||||
var imageKey = reader.images![page - 1];
|
||||
if (imageKey.startsWith("file://")) {
|
||||
return;
|
||||
}
|
||||
var cid = reader.cid;
|
||||
var eid = reader.eid;
|
||||
var sourceKey = reader.type.comicSource?.key;
|
||||
ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid);
|
||||
}
|
||||
|
||||
class _SwipeChangeChapterProgress extends StatefulWidget {
|
||||
const _SwipeChangeChapterProgress({
|
||||
this.controller,
|
||||
|
@@ -29,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';
|
||||
@@ -109,13 +111,22 @@ class _ReaderState extends State<Reader>
|
||||
}
|
||||
|
||||
@override
|
||||
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
|
||||
int get maxPage {
|
||||
if (images == null) {
|
||||
return 1;
|
||||
}
|
||||
if (!showSingleImageOnFirstPage) {
|
||||
return (images!.length / imagesPerPage).ceil();
|
||||
} else {
|
||||
return 1 + ((images!.length - 1) / imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
ComicType get type => widget.type;
|
||||
|
||||
String get cid => widget.cid;
|
||||
|
||||
String get eid => widget.chapters?.ids.elementAt(chapter - 1) ?? '0';
|
||||
String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
|
||||
|
||||
List<String>? images;
|
||||
|
||||
@@ -123,7 +134,8 @@ class _ReaderState extends State<Reader>
|
||||
late ReaderMode mode;
|
||||
|
||||
@override
|
||||
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait;
|
||||
bool get isPortrait =>
|
||||
MediaQuery.of(context).orientation == Orientation.portrait;
|
||||
|
||||
History? history;
|
||||
|
||||
@@ -152,10 +164,9 @@ class _ReaderState extends State<Reader>
|
||||
}
|
||||
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||
history = widget.history;
|
||||
Future.microtask(() {
|
||||
updateHistory();
|
||||
});
|
||||
if (!appdata.settings['showSystemStatusBar']) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
if (appdata.settings['enableTurnPageByVolumeKey']) {
|
||||
handleVolumeEvent();
|
||||
}
|
||||
@@ -215,10 +226,16 @@ class _ReaderState extends State<Reader>
|
||||
focusNode: focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: onKeyEvent,
|
||||
child: _ReaderScaffold(
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return _ReaderScaffold(
|
||||
child: _ReaderGestureDetector(
|
||||
child: _ReaderImages(key: Key(chapter.toString())),
|
||||
),
|
||||
);
|
||||
})
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -249,7 +266,15 @@ class _ReaderState extends State<Reader>
|
||||
history!.page = images?.length ?? 1;
|
||||
} else {
|
||||
/// Record the first image of the page
|
||||
if (!showSingleImageOnFirstPage || imagesPerPage == 1) {
|
||||
history!.page = (page - 1) * imagesPerPage + 1;
|
||||
} else {
|
||||
if (page == 1) {
|
||||
history!.page = 1;
|
||||
} else {
|
||||
history!.page = (page - 2) * imagesPerPage + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
history!.maxPage = images?.length ?? 1;
|
||||
if (widget.chapters?.isGrouped ?? false) {
|
||||
@@ -308,6 +333,13 @@ class _ReaderState extends State<Reader>
|
||||
}
|
||||
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 {
|
||||
@@ -324,9 +356,16 @@ abstract mixin class _ImagePerPageHandler {
|
||||
void initImagesPerPage(int initialPage) {
|
||||
_lastImagesPerPage = imagesPerPage;
|
||||
if (imagesPerPage != 1) {
|
||||
if (showSingleImageOnFirstPage) {
|
||||
page = ((initialPage - 1) / imagesPerPage).ceil() + 1;
|
||||
} else {
|
||||
page = (initialPage / imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get showSingleImageOnFirstPage =>
|
||||
appdata.settings["showSingleImageOnFirstPage"];
|
||||
|
||||
/// The number of images displayed on one screen
|
||||
int get imagesPerPage {
|
||||
@@ -362,8 +401,24 @@ abstract mixin class _VolumeListener {
|
||||
|
||||
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
|
||||
@@ -373,8 +428,8 @@ abstract mixin class _VolumeListener {
|
||||
volumeListener?.cancel();
|
||||
}
|
||||
volumeListener = VolumeListener(
|
||||
onDown: toNextPage,
|
||||
onUp: toPrevPage,
|
||||
onDown: onDown,
|
||||
onUp: onUp,
|
||||
)..listen();
|
||||
}
|
||||
|
||||
@@ -577,4 +632,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);
|
||||
}
|
||||
|
@@ -107,7 +107,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
if (!_isOpen) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
if (!appdata.settings['showSystemStatusBar']) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_isOpen = !_isOpen;
|
||||
@@ -127,6 +131,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
Positioned.fill(
|
||||
child: widget.child,
|
||||
),
|
||||
if (appdata.settings['showPageNumberInReader'] == true)
|
||||
buildPageInfoText(),
|
||||
buildStatusInfo(),
|
||||
AnimatedPositioned(
|
||||
@@ -161,7 +166,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),
|
||||
@@ -207,7 +212,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
);
|
||||
}
|
||||
|
||||
void addImageFavorite() {
|
||||
void addImageFavorite() async {
|
||||
try {
|
||||
if (context.reader.images![0].contains('file://')) {
|
||||
showToast(
|
||||
@@ -221,7 +226,9 @@ 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;
|
||||
@@ -377,11 +384,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(
|
||||
@@ -475,7 +483,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(
|
||||
@@ -569,94 +577,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -666,7 +588,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
|
||||
void share() async {
|
||||
var data = await _getCurrentImageData();
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
@@ -749,9 +671,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
? Icons.arrow_forward_ios
|
||||
: Icons.arrow_back_ios_outlined,
|
||||
size: 24,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onPrimaryContainer,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -760,6 +680,74 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
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 {
|
||||
@@ -940,3 +928,69 @@ class _ClockWidgetState extends State<_ClockWidget> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectImageOverlayContent extends StatefulWidget {
|
||||
const _SelectImageOverlayContent({
|
||||
required this.onTap,
|
||||
required this.onDispose,
|
||||
});
|
||||
|
||||
final void Function(Offset) onTap;
|
||||
|
||||
final void Function() onDispose;
|
||||
|
||||
@override
|
||||
State<_SelectImageOverlayContent> createState() => _SelectImageOverlayContentState();
|
||||
}
|
||||
|
||||
class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent> {
|
||||
@override
|
||||
void dispose() {
|
||||
widget.onDispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -376,11 +376,16 @@ class _SearchPageState extends State<SearchPage> {
|
||||
controller.text =
|
||||
controller.text.replaceLast(words[words.length - 1], "");
|
||||
}
|
||||
if (type != null) {
|
||||
controller.text += "${type.name}:$text ";
|
||||
final source = ComicSource.find(searchTarget);
|
||||
String insert;
|
||||
if (source?.onTagSuggestionSelected != null) {
|
||||
insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
|
||||
} else {
|
||||
controller.text += "$text ";
|
||||
var t = text;
|
||||
if (t.contains(' ')) t = "'$t'";
|
||||
insert = type != null ? "${type.name}:$t" : t;
|
||||
}
|
||||
controller.text += "$insert ";
|
||||
suggestions.clear();
|
||||
update();
|
||||
focusNode.requestFocus();
|
||||
|
@@ -124,7 +124,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
|
||||
options = widget.options ?? const [];
|
||||
validateOptions();
|
||||
appdata.addSearchHistory(text);
|
||||
suggestionsController = _SuggestionsController(controller);
|
||||
suggestionsController = _SuggestionsController(controller, sourceKey);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@@ -213,6 +213,8 @@ class _SuggestionsController {
|
||||
|
||||
final SearchBarController controller;
|
||||
|
||||
final String sourceKey;
|
||||
|
||||
OverlayEntry? entry;
|
||||
|
||||
void updateWidget() {
|
||||
@@ -270,7 +272,7 @@ class _SuggestionsController {
|
||||
find(TagsTranslation.cosplayerTags, TranslationType.cosplayer);
|
||||
}
|
||||
|
||||
_SuggestionsController(this.controller);
|
||||
_SuggestionsController(this.controller, this.sourceKey);
|
||||
}
|
||||
|
||||
class _Suggestions extends StatefulWidget {
|
||||
@@ -400,14 +402,16 @@ class _SuggestionsState extends State<_Suggestions> {
|
||||
controller.text =
|
||||
controller.text.replaceLast(words[words.length - 1], "");
|
||||
}
|
||||
if (text.contains(' ')) {
|
||||
text = "'$text'";
|
||||
}
|
||||
if (type != null) {
|
||||
controller.text += "${type.name}:$text ";
|
||||
final source = ComicSource.find(widget.controller.sourceKey);
|
||||
String insert;
|
||||
if (source?.onTagSuggestionSelected != null) {
|
||||
insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
|
||||
} else {
|
||||
controller.text += "$text ";
|
||||
var t = text;
|
||||
if (t.contains(' ')) t = "'$t'";
|
||||
insert = type != null ? "${type.name}:$t" : t;
|
||||
}
|
||||
controller.text += "$insert ";
|
||||
widget.controller.suggestions.clear();
|
||||
widget.controller.remove();
|
||||
}
|
||||
@@ -441,6 +445,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 +461,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,
|
||||
|
@@ -96,10 +96,13 @@ Future<bool> checkUpdate() async {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
|
||||
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true, bool delay = false]) async {
|
||||
try {
|
||||
var value = await checkUpdate();
|
||||
if (value) {
|
||||
if (delay) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
|
@@ -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,11 +319,10 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
String url = "";
|
||||
String user = "";
|
||||
String pass = "";
|
||||
bool autoSync = false;
|
||||
bool autoSync = true;
|
||||
|
||||
bool isTesting = false;
|
||||
bool upload = true;
|
||||
bool isEnabled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -349,8 +337,7 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
url = configs[0];
|
||||
user = configs[1];
|
||||
pass = configs[2];
|
||||
isEnabled = true;
|
||||
autoSync = appdata.implicitData['webdavAutoSync'] ?? false;
|
||||
autoSync = appdata.implicitData['webdavAutoSync'] ?? true;
|
||||
}
|
||||
|
||||
void onAutoSyncChanged(bool value) {
|
||||
@@ -368,16 +355,11 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
SwitchListTile(
|
||||
title: Text("WebDAV Auto Sync".tl),
|
||||
value: autoSync,
|
||||
onChanged: onAutoSyncChanged,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
labelText: "URL",
|
||||
hintText: "A valid WebDav directory URL".tl,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
controller: TextEditingController(text: url),
|
||||
@@ -402,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),
|
||||
@@ -428,7 +420,10 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: autoSync
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
@@ -439,10 +434,14 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
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),
|
||||
child: Text(
|
||||
"Once the operation is successful, app will automatically sync data with the server."
|
||||
.tl),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
|
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,
|
||||
@@ -52,6 +52,10 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
||||
title: "Show history on comic tile".tl,
|
||||
settingKey: "showHistoryStatusOnTile",
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Reverse default chapter order".tl,
|
||||
settingKey: "reverseChapterOrder",
|
||||
).toSliver(),
|
||||
_PopupWindowSetting(
|
||||
title: "Keyword blocking".tl,
|
||||
builder: () => const _ManageBlockingWordView(),
|
||||
@@ -90,6 +94,14 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
||||
'3': "Categories Page".tl,
|
||||
},
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Display mode of comic list".tl,
|
||||
settingKey: "comicListDisplayMode",
|
||||
optionTranslation: {
|
||||
"paging": "Paging".tl,
|
||||
"Continuous": "Continuous".tl,
|
||||
},
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@@ -48,6 +48,7 @@ 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['readerScreenPicNumberForLandscape'] = 1;
|
||||
@@ -65,52 +66,32 @@ 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),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['readerMode']!.startsWith('gallery'),
|
||||
child: _SliderSetting(
|
||||
title: "The number of pic in screen for landscape (Only Gallery Mode)".tl,
|
||||
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");
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
SliverAnimatedVisibility(
|
||||
visible: appdata.settings['readerMode']!.startsWith('gallery'),
|
||||
child: _SliderSetting(
|
||||
title: "The number of pic in screen for portrait (Only Gallery Mode)".tl,
|
||||
title:
|
||||
"The number of pic in screen for portrait (Only Gallery Mode)"
|
||||
.tl,
|
||||
settingsIndex: "readerScreenPicNumberForPortrait",
|
||||
interval: 1,
|
||||
min: 1,
|
||||
@@ -120,15 +101,45 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
},
|
||||
),
|
||||
),
|
||||
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: 'Double tap to zoom'.tl,
|
||||
settingKey: 'enableDoubleTapToZoom',
|
||||
onChanged: () {
|
||||
setState(() {});
|
||||
widget.onChanged?.call('enableDoubleTapToZoom');
|
||||
},
|
||||
).toSliver(),
|
||||
_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,
|
||||
@@ -152,6 +163,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
|
||||
},
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Show system status bar".tl,
|
||||
settingKey: "showSystemStatusBar",
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("showSystemStatusBar");
|
||||
},
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Quick collect image".tl,
|
||||
settingKey: "quickCollectImage",
|
||||
@@ -179,6 +197,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) {
|
||||
|
@@ -141,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];
|
||||
@@ -233,17 +232,19 @@ abstract class CBZ {
|
||||
}
|
||||
}
|
||||
var cover = comic.coverFile;
|
||||
await cover
|
||||
.copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
|
||||
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
|
||||
jsonEncode(
|
||||
ComicMetaData(
|
||||
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,
|
||||
).toJson(),
|
||||
),
|
||||
);
|
||||
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
|
||||
jsonEncode(metaData),
|
||||
);
|
||||
await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString(
|
||||
_buildComicInfoXml(metaData),
|
||||
);
|
||||
var cbz = File(outFilePath);
|
||||
if (cbz.existsSync()) cbz.deleteSync();
|
||||
@@ -252,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,11 +95,13 @@ 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);
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,6 @@
|
||||
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';
|
||||
@@ -9,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,6 +22,12 @@ class DataSync with ChangeNotifier {
|
||||
}
|
||||
LocalFavoritesManager().addListener(onDataChanged);
|
||||
ComicSourceManager().addListener(onDataChanged);
|
||||
if (App.isDesktop) {
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
var controller = WindowFrame.of(App.rootContext);
|
||||
controller.addCloseListener(_handleWindowClose);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void onDataChanged() {
|
||||
@@ -27,6 +36,28 @@ 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._());
|
||||
|
@@ -108,3 +108,14 @@ abstract class MapOrNull{
|
||||
return i == null ? null : Map<K, V>.from(i);
|
||||
}
|
||||
}
|
||||
|
||||
extension FutureExt<T> on Future<T>{
|
||||
/// Wrap the future to make sure it will return at least the duration.
|
||||
Future<T> minTime(Duration duration) async {
|
||||
var res = await Future.wait([
|
||||
this,
|
||||
Future.delayed(duration),
|
||||
]);
|
||||
return res[0];
|
||||
}
|
||||
}
|
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));
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
@@ -132,25 +131,28 @@ extension DirectoryExtension on Directory {
|
||||
}
|
||||
|
||||
/// Sanitize the file name. Remove invalid characters and trim the file name.
|
||||
String sanitizeFileName(String fileName) {
|
||||
if (fileName.endsWith('.')) {
|
||||
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;
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/utils/image.dart';
|
||||
@@ -74,6 +75,9 @@ Future<Isolate> _runIsolate(
|
||||
return Isolate.spawn<SendPort>(
|
||||
(sendPort) => overrideIO(
|
||||
() async {
|
||||
if (App.isAndroid) {
|
||||
await SAFTaskWorker().init();
|
||||
}
|
||||
var receivePort = ReceivePort();
|
||||
sendPort.send(receivePort.sendPort);
|
||||
|
||||
|
@@ -36,7 +36,9 @@ extension TagsTranslation on String{
|
||||
static String _translateTags(String tag){
|
||||
if (tag.contains('|')) {
|
||||
var splits = tag.split('|');
|
||||
return enTagsTranslations[splits[0]]??enTagsTranslations[splits[1]]??tag;
|
||||
return enTagsTranslations[splits[0].trim()]
|
||||
?? enTagsTranslations[splits[1].trim()]
|
||||
?? tag;
|
||||
} else if(tag.contains(':')) {
|
||||
var splits = tag.split(':');
|
||||
if(_haveNamespace(splits[0])) {
|
||||
|
@@ -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() {
|
||||
|
118
pubspec.lock
118
pubspec.lock
@@ -45,10 +45,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
version: "2.13.0"
|
||||
battery_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -149,8 +149,8 @@ packages:
|
||||
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"
|
||||
@@ -178,14 +178,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
enough_convert:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: enough_convert
|
||||
sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -308,18 +316,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 +342,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:
|
||||
@@ -408,8 +416,8 @@ packages:
|
||||
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"
|
||||
@@ -425,17 +433,17 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_rust_bridge
|
||||
sha256: "3292ad6085552987b8b3b9a7e5805567f4013372d302736b702801acb001ee00"
|
||||
sha256: b416ff56002789e636244fb4cc449f587656eff995e5a7169457eb0593fcaddb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.1"
|
||||
version: "2.10.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:
|
||||
@@ -516,10 +524,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.0"
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -540,10 +548,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.8"
|
||||
version: "10.0.9"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -580,10 +588,10 @@ 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:
|
||||
@@ -612,8 +620,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,8 +733,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
|
||||
resolved-ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
|
||||
ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
|
||||
resolved-ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
|
||||
url: "https://github.com/wgh136/photo_view"
|
||||
source: git
|
||||
version: "0.14.0"
|
||||
@@ -758,11 +766,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: rhttp
|
||||
ref: HEAD
|
||||
resolved-ref: "18d430cc45fd4f0114885c5235090abf65106257"
|
||||
ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
|
||||
resolved-ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
|
||||
url: "https://github.com/wgh136/rhttp"
|
||||
source: git
|
||||
version: "0.10.0"
|
||||
version: "0.12.0"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1029,10 +1037,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.3.1"
|
||||
version: "15.0.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1045,8 +1053,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"
|
||||
@@ -1094,10 +1102,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 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.32.4"
|
||||
|
27
pubspec.yaml
27
pubspec.yaml
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.2+132
|
||||
version: 1.4.5+145
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
flutter: 3.29.0
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.32.4
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -19,7 +19,7 @@ dependencies:
|
||||
flutter_qjs:
|
||||
git:
|
||||
url: https://github.com/wgh136/flutter_qjs
|
||||
ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3
|
||||
ref: 8feae95df7fb00455df129ad7a0dfec1d0e8d8e4
|
||||
crypto: ^3.0.6
|
||||
dio: ^5.8.0+1
|
||||
html: ^0.15.5
|
||||
@@ -29,7 +29,7 @@ dependencies:
|
||||
photo_view:
|
||||
git:
|
||||
url: https://github.com/wgh136/photo_view
|
||||
ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
|
||||
ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
|
||||
mime: ^2.0.0
|
||||
share_plus: ^10.1.4
|
||||
scrollable_positioned_list:
|
||||
@@ -43,34 +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
|
||||
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
|
||||
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
||||
rhttp:
|
||||
git:
|
||||
url: https://github.com/wgh136/rhttp
|
||||
ref: 1f0ff50336062c5f809c256726dc55cd30b9ce59
|
||||
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
|
||||
@@ -83,6 +85,7 @@ dependencies:
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
yaml: ^3.1.3
|
||||
enough_convert: ^1.6.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
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"]
|
150
update_alt_store.py
Normal file
150
update_alt_store.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import json
|
||||
import plistlib
|
||||
import re
|
||||
import requests
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
def prepare_description(text):
|
||||
text = re.sub('<[^<]+?>', '', text) # Remove HTML tags
|
||||
text = re.sub(r'#{1,6}\s?', '', text) # Remove markdown header tags
|
||||
text = re.sub(r'\*{2}', '', text) # Remove all occurrences of two consecutive asterisks
|
||||
text = re.sub(r'(?<=\r|\n)-', '•', text) # Only replace - with • if it is preceded by \r or \n
|
||||
text = re.sub(r'`', '"', text) # Replace ` with "
|
||||
text = re.sub(r'\r\n\r\n', '\r \n', text) # Replace \r\n\r\n with \r \n (avoid incorrect display of the description regarding paragraphs)
|
||||
return text
|
||||
|
||||
def fetch_latest_release(repo_url):
|
||||
api_url = f"https://api.github.com/repos/{repo_url}/releases"
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
try:
|
||||
response = requests.get(api_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
release = response.json()
|
||||
return release
|
||||
except requests.RequestException as e:
|
||||
print(f"Error fetching releases: {e}")
|
||||
raise
|
||||
|
||||
def get_file_size(url):
|
||||
try:
|
||||
response = requests.head(url)
|
||||
response.raise_for_status()
|
||||
return int(response.headers.get('Content-Length', 0))
|
||||
except requests.RequestException as e:
|
||||
print(f"Error getting file size: {e}")
|
||||
return 194586
|
||||
|
||||
def update_json_file_release(json_file, latest_release):
|
||||
if isinstance(latest_release, list) and latest_release:
|
||||
latest_release = latest_release[0]
|
||||
else:
|
||||
print("Error getting latest release")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(json_file, "r") as file:
|
||||
data = json.load(file)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error reading JSON file: {e}")
|
||||
data = {"apps": []}
|
||||
raise
|
||||
|
||||
app = data["apps"][0]
|
||||
|
||||
full_version = latest_release["tag_name"]
|
||||
tag = latest_release["tag_name"]
|
||||
# Extract version like 1.4.5 from tag, which may be like 'v1.4.5'
|
||||
version_match = re.search(r"(\d+\.\d+\.\d+)", full_version)
|
||||
if version_match:
|
||||
version = version_match.group(1)
|
||||
else:
|
||||
print("Error: Could not parse version from tag_name.")
|
||||
return
|
||||
version_date = latest_release["published_at"]
|
||||
date_obj = datetime.strptime(version_date, "%Y-%m-%dT%H:%M:%SZ")
|
||||
version_date = date_obj.strftime("%Y-%m-%d")
|
||||
|
||||
description = latest_release["body"]
|
||||
description = prepare_description(description)
|
||||
|
||||
assets = latest_release.get("assets", [])
|
||||
download_url = None
|
||||
size = None
|
||||
for asset in assets:
|
||||
# venera-ios-1.4.5+145.ipa
|
||||
if asset["name"] == f"venera-ios-{version}+{version.replace('.', '')}.ipa":
|
||||
download_url = asset["browser_download_url"]
|
||||
size = asset["size"]
|
||||
break
|
||||
|
||||
if download_url is None or size is None:
|
||||
print("Error: IPA file not found in release assets.")
|
||||
return
|
||||
|
||||
version_entry = {
|
||||
"version": version,
|
||||
"date": version_date,
|
||||
"localizedDescription": description,
|
||||
"downloadURL": download_url,
|
||||
"size": size
|
||||
}
|
||||
|
||||
duplicate_entries = [item for item in app["versions"] if item["version"] == version]
|
||||
if duplicate_entries:
|
||||
app["versions"].remove(duplicate_entries[0])
|
||||
|
||||
app["versions"].insert(0, version_entry)
|
||||
|
||||
app.update({
|
||||
"version": version,
|
||||
"versionDate": version_date,
|
||||
"versionDescription": description,
|
||||
"downloadURL": download_url,
|
||||
"size": size
|
||||
})
|
||||
|
||||
if "news" not in data:
|
||||
data["news"] = []
|
||||
|
||||
news_identifier = f"release-{full_version}"
|
||||
date_string = date_obj.strftime("%d/%m/%y")
|
||||
news_entry = {
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": f"Update of Venera just got released!",
|
||||
"date": latest_release["published_at"],
|
||||
"identifier": news_identifier,
|
||||
"notify": True,
|
||||
"tintColor": "#0784FC",
|
||||
"title": f"{full_version} - Venera {date_string}",
|
||||
"url": f"https://github.com/venera-app/venera/releases/tag/{tag}"
|
||||
}
|
||||
|
||||
news_entry_exists = any(item["identifier"] == news_identifier for item in data["news"])
|
||||
if not news_entry_exists:
|
||||
data["news"].append(news_entry)
|
||||
|
||||
try:
|
||||
with open(json_file, "w") as file:
|
||||
json.dump(data, file, indent=2)
|
||||
print("JSON file updated successfully.")
|
||||
except IOError as e:
|
||||
print(f"Error writing to JSON file: {e}")
|
||||
raise
|
||||
|
||||
def main():
|
||||
repo_url = "venera-app/venera"
|
||||
is_nightly = "NIGHTLY_LINK" in os.environ
|
||||
|
||||
try:
|
||||
fetched_data_latest = fetch_latest_release(repo_url)
|
||||
json_file = "alt_store.json"
|
||||
update_json_file_release(json_file, fetched_data_latest)
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
raise
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
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