106 Commits

Author SHA1 Message Date
nyne
0d77803e8c Merge pull request #53 from venera-app/dev
v1.0.6
2024-11-18 18:20:39 +08:00
8db52c9db1 update version code 2024-11-18 17:57:35 +08:00
ce6f65f912 fix auto link 2024-11-18 17:56:27 +08:00
689700f52a improve tab bar 2024-11-18 17:42:20 +08:00
250f458029 improve word segmentation 2024-11-18 17:22:25 +08:00
1489e6c86d add appVersion to JsEngine 2024-11-18 17:02:07 +08:00
b4921c8e14 support rich text comment 2024-11-18 16:59:54 +08:00
800b67fb28 fix network issue 2024-11-18 10:56:19 +08:00
AnxuNA
036474a5d2 Optimization _buildBriefMode (#51)
更改_buildBriefMode样式
2024-11-17 22:55:04 +08:00
a1d1f504bd fix windows build 2024-11-17 21:22:55 +08:00
458bc261f3 update workflow 2024-11-17 20:57:39 +08:00
00af5f1989 update workflow 2024-11-17 20:50:32 +08:00
9988e76149 update workflow 2024-11-17 18:43:29 +08:00
213179b8c2 update workflow 2024-11-17 18:25:18 +08:00
708cf83a32 improve ui 2024-11-17 17:23:43 +08:00
0ee99a8760 fix android method channel 2024-11-16 19:13:19 +08:00
30a1c806cd Convert network folder to local 2024-11-16 16:51:56 +08:00
Pacalini
7bc0aeb4af tool bar: RtL slider & button swap (#50) 2024-11-16 16:07:39 +08:00
8513a739ec When AppLifecycleState is changed to resumed, check for data updates. 2024-11-15 22:14:53 +08:00
AnxuNA
d749e7421e Open in Browser Translation (#49)
Open in Browser Translation
2024-11-15 21:16:17 +08:00
165e5f2850 add authorization 2024-11-15 18:27:59 +08:00
edff9c7a0c fix config update issue 2024-11-15 17:03:41 +08:00
boa
65b41b2873 add option to ignore certificate errors (#46)
add option to ignore certificate errors
2024-11-14 20:40:28 +08:00
AnxuNA
f912e57bfd Change the style of _buildBriefMode. (#44)
Change the style of _buildBriefMode
2024-11-14 19:29:46 +08:00
nyne
2ef03ad7ae Merge pull request #42 from Pacalini/dev
reader: fix start/end flipping
2024-11-14 18:24:26 +08:00
Pacalini
47eb597d96 reader: fix start/end flipping 2024-11-14 18:10:47 +08:00
0ac9ee7061 fix #37 2024-11-14 15:28:57 +08:00
dd7154830b fix potential network issue 2024-11-13 19:28:47 +08:00
nyne
194abb82de Merge pull request #36 from venera-app/dev
v1.0.5-patch
2024-11-13 18:57:10 +08:00
a8bc097541 Update windows build script 2024-11-13 18:56:22 +08:00
d34c7c3806 fix importing data on Android 2024-11-13 18:55:25 +08:00
nyne
926437b967 Merge pull request #34 from venera-app/dev
v1.0.5
2024-11-13 16:27:41 +08:00
nyne
856ad82c55 Merge branch 'master' into dev 2024-11-13 16:27:20 +08:00
81baf53ad4 Update version code 2024-11-13 16:21:13 +08:00
71b03d744a Add feature to download all comics in a folder 2024-11-13 16:20:42 +08:00
6f2bac52e4 Improve sharing image & saving image 2024-11-13 16:08:28 +08:00
9fcc306ee0 fix HtmlElement.parent 2024-11-13 13:12:04 +08:00
5d4e8f5b84 Add sorting folders feature 2024-11-13 12:44:51 +08:00
9bdcba1270 improve ui 2024-11-13 12:21:57 +08:00
8e99e94620 Add the feature for updating local favorites info 2024-11-13 08:57:37 +08:00
nyne
00bcbaa2eb Merge pull request #32 from pkuislm/dev
EhViewer数据导入&本地下载选择优化
2024-11-12 23:13:37 +08:00
pkuislm
acb9c47657 Improve selection button display on small screen devices. 2024-11-12 23:09:53 +08:00
1636c959d0 fix #33 2024-11-12 22:37:46 +08:00
pkuislm
4ff1140bf6 Add cancellation to ehviewer import. 2024-11-12 21:28:07 +08:00
pkuislm
057d6a2f54 Update translation. 2024-11-12 19:54:47 +08:00
pkuislm
601ef68ad3 Improve local comics selection logic. 2024-11-12 19:54:34 +08:00
pkuislm
c94438d7c4 Add EhViewer database import support. 2024-11-12 19:52:34 +08:00
pkuislm
5825f88e78 Allow custom creation time of favorite items, add LocalFavoritesManager.existsFolder function. 2024-11-12 19:50:53 +08:00
pkuislm
389403c11d Ignore files starting with a dot when fetching local comic images, and improve local comic delete logic. 2024-11-12 19:48:15 +08:00
pkuislm
abd9afad6b Fix local comic cover display logic. 2024-11-12 19:45:27 +08:00
pkuislm
5119beb1fe Fix battery forground color. 2024-11-12 19:44:05 +08:00
9b98075153 fix multiple setting pages and search pages 2024-11-12 17:51:20 +08:00
775ab471f5 fix subtitle 2024-11-12 17:49:02 +08:00
293040f374 fix subtitle 2024-11-12 17:43:37 +08:00
a427bcdf84 fix search action 2024-11-12 17:37:29 +08:00
c4f531a463 Exported data should contain cookies 2024-11-12 16:36:02 +08:00
nyne
6c076bfc7a Merge pull request #31 from pkuislm/dev
给阅读界面加个时钟和电池信息
2024-11-11 22:47:55 +08:00
pkuislm
93bf99daa5 Add option to hide time and battery info. 2024-11-11 22:40:46 +08:00
pkuislm
b3e95d7162 Fix widget blinking caused by future builder. 2024-11-11 22:13:03 +08:00
pkuislm
c35bf9fb7f Merge branch 'dev' of https://github.com/pkuislm/venera into dev 2024-11-11 22:10:32 +08:00
pkuislm
189dfe5a43 Fix battery update issue. 2024-11-11 22:08:13 +08:00
pkuislm
53b9bc79dd Fix battery update issue. 2024-11-11 21:58:44 +08:00
pkuislm
bc4e0f79a5 Added clock & battery widgets in reader. 2024-11-11 21:27:40 +08:00
05bbef0b8a fix #30 2024-11-11 18:43:32 +08:00
e1df69e785 [import data] proxy settings should be kept 2024-11-11 17:46:11 +08:00
a0e3cc720a add ImageLoadingConfig constructor 2024-11-11 17:36:42 +08:00
6ae3e50a5b improve network request 2024-11-11 17:18:56 +08:00
nyne
7cf55fcb8e add onLoadFailed to imageLoadingConfig 2024-11-11 15:01:31 +08:00
nyne
d875681c4b update gitignore 2024-11-11 14:23:24 +08:00
193ecdb765 improve data sync 2024-11-11 11:52:36 +08:00
ea3cc8cc58 [windows] prevent multiple instances 2024-11-11 10:58:48 +08:00
f8eace4c31 fix an issue where a deleted comic could not be displayed in a favorite folder. 2024-11-11 10:40:56 +08:00
db2c2395de fix importing data on windows 2024-11-11 10:35:21 +08:00
nyne
fe266dcade Merge pull request #26 from boa-z/translation-typo-fix
fix: translation typo
2024-11-11 00:06:19 +08:00
boa-z
ecb657d20d fix: translation typo 2024-11-10 23:26:21 +08:00
b8492b3adc remove permission_handler 2024-11-10 18:10:30 +08:00
nyne
0f37feb318 Merge pull request #25 from venera-app/dev
v1.0.4
2024-11-10 17:59:58 +08:00
6e2c5c6e07 update version number 2024-11-10 17:59:06 +08:00
64d8bcba9a [Android] Turn page by volume keys 2024-11-10 17:50:20 +08:00
160d0df935 fix setting new download path 2024-11-10 17:48:12 +08:00
6a60194ffb support setting new download path on android 2024-11-10 17:27:27 +08:00
93193bddc0 Merge branch 'refs/heads/master' into dev 2024-11-10 16:01:45 +08:00
aa415f201e quick favorite 2024-11-10 15:57:52 +08:00
4f4411fcc3 sync data using webdav 2024-11-10 10:38:46 +08:00
nyne
afd690ed07 Merge pull request #24 from boa-z/master
Experimental Support for Setting New Storage Path on iOS
2024-11-09 17:16:52 +08:00
nyne
a3936f64da Delete tg.yaml 2024-11-09 17:09:02 +08:00
boa-z
7bf8cf569f experimental support for set new storage path on iOS 2024-11-09 11:04:34 +08:00
boa-z
856ec23586 add copy storage path button 2024-11-08 23:32:18 +08:00
boa-z
d910b8a35d add multiSelect for local_comics_page 2024-11-07 23:30:01 +08:00
nyne
234bf218a9 Merge pull request #23 from venera-app/telegram
Create tg.yaml
2024-11-07 18:33:19 +08:00
nyne
0226477256 Create tg.yaml 2024-11-07 18:33:06 +08:00
nyne
42ded1221a Merge pull request #22 from venera-app/dev
v 1.0.3
2024-11-07 10:26:40 +08:00
a9a22ace14 update README.md 2024-11-07 10:20:50 +08:00
99bbea80dc update version code 2024-11-07 09:56:10 +08:00
26fa41f503 improve translation 2024-11-07 09:41:14 +08:00
082aa36316 improve reader; fix #21 2024-11-07 09:31:57 +08:00
5a14ea48c1 fix changing search target in search result page 2024-11-07 09:02:03 +08:00
5d43f5c556 fix favorites page 2024-11-07 08:59:49 +08:00
e51a58ba4f fix deleting files when canceling a task 2024-11-07 08:49:32 +08:00
5234de434a improve network log 2024-11-06 22:08:23 +08:00
22f2ac99ad fix http 2024-11-06 18:06:20 +08:00
b08b5d0abe update action 2024-11-06 17:43:36 +08:00
nyne
96c6323c07 Merge pull request #18 from venera-app/dev
v1.0.2-patch
2024-11-06 09:21:29 +08:00
ae80715db1 update windows build script 2024-11-06 08:57:06 +08:00
3d7f30af00 update .gitignore 2024-11-06 08:53:57 +08:00
f12cb55bbc update windows build script 2024-11-06 08:51:20 +08:00
85 changed files with 4230 additions and 1024 deletions

View File

@@ -1,33 +0,0 @@
name: Build Linux
run-name: Build Linux
on:
workflow_dispatch: {}
jobs:
Build_Linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version-file: pubspec.yaml
architecture: x64
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py
- run: dart run flutter_to_arch
- run: |
sudo rm -rf build/linux/arch/app.tar.gz
sudo rm -rf build/linux/arch/pkg
sudo rm -rf build/linux/arch/src
sudo rm -rf build/linux/arch/PKGBUILD
- uses: actions/upload-artifact@v4
with:
name: deb_build
path: build/linux/x64/release/debian
- uses: actions/upload-artifact@v4
with:
name: arch_build
path: build/linux/arch/

View File

@@ -1,10 +1,10 @@
name: Build IOS name: Build ALL
run-name: Build IOS run-name: Build ALL
on: on:
workflow_dispatch: {} workflow_dispatch: {}
jobs: jobs:
Build_MacOS: Build_MacOS:
runs-on: macos-13 runs-on: macos-15
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
@@ -12,7 +12,7 @@ jobs:
channel: "stable" channel: "stable"
flutter-version-file: pubspec.yaml flutter-version-file: pubspec.yaml
architecture: x64 architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_14.3.1.app - run: sudo xcode-select --switch /Applications/Xcode_16.0.app
- run: flutter pub get - run: flutter pub get
# Step 1: Decode and install the certificate # Step 1: Decode and install the certificate
- name: Decode and install certificate - name: Decode and install certificate
@@ -38,12 +38,12 @@ jobs:
# Step 4: Attach and upload artifacts (optional) # Step 4: Attach and upload artifacts (optional)
- name: Upload DMG - name: Upload DMG
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: venera.dmg name: venera.dmg
path: dist/venera.dmg path: dist/venera.dmg
Build_IOS: Build_IOS:
runs-on: macos-13 runs-on: macos-15
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
@@ -51,7 +51,7 @@ jobs:
channel: "stable" channel: "stable"
flutter-version-file: pubspec.yaml flutter-version-file: pubspec.yaml
architecture: x64 architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_14.3.1.app - run: sudo xcode-select --switch /Applications/Xcode_16.0.app
- run: flutter pub get - run: flutter pub get
- run: flutter build ios --release --no-codesign - run: flutter build ios --release --no-codesign
- run: | - run: |
@@ -63,3 +63,79 @@ jobs:
with: with:
name: app-ios.ipa name: app-ios.ipa
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa
Build_Android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- name: Decode and install certificate
env:
STORE_FILE: ${{ secrets.ANDROID_KEYSTORE }}
PROPERTY_FILE: ${{ secrets.ANDROID_KEY_PROPERTIES }}
run: |
echo "$STORE_FILE" | base64 --decode > android/keystore.jks
echo "$PROPERTY_FILE" > android/key.properties
- uses: actions/setup-java@v4
with:
distribution: 'oracle'
java-version: '17'
- run: flutter pub get
- run: flutter build apk --release
- uses: actions/upload-artifact@v4
with:
name: apks
path: build/app/outputs/apk/release
Build_Windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: install dependencies
run: |
choco install yq -y
pip install httpx
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- name: build
run: |
flutter pub get
python windows/build.py
- uses: actions/upload-artifact@v4
with:
name: windows_build
path: build/windows/Venera-*
Build_Linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version-file: pubspec.yaml
architecture: x64
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py
- run: dart run flutter_to_arch
- run: |
sudo rm -rf build/linux/arch/app.tar.gz
sudo rm -rf build/linux/arch/pkg
sudo rm -rf build/linux/arch/src
sudo rm -rf build/linux/arch/PKGBUILD
- uses: actions/upload-artifact@v4
with:
name: deb_build
path: build/linux/x64/release/debian
- uses: actions/upload-artifact@v4
with:
name: arch_build
path: build/linux/arch/

5
.gitignore vendored
View File

@@ -42,4 +42,7 @@ app.*.map.json
/android/app/profile /android/app/profile
/android/app/release /android/app/release
./add_translation.py add_translation.py
*/*/generated_*
*/*/Generated*

View File

@@ -4,6 +4,7 @@
[![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE) [![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases) [![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![stars](https://img.shields.io/github/stars/venera-app/venera)](https://github.com/venera-app/venera/stargazers) [![stars](https://img.shields.io/github/stars/venera-app/venera)](https://github.com/venera-app/venera/stargazers)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/+Ws-IpmUutzkxMjhl)
A comic reader that support reading local and network comics. A comic reader that support reading local and network comics.

View File

@@ -1,31 +0,0 @@
import re
import json
path='./assets/translation.json'
with open(path, 'r',encoding='utf-8') as file:
translations=json.load(file)
while True:
line=input()
if line=="q":
break
words=line.split('-')
if len(words)!=3:
print("invalid entry:",line,"(len(words) != 3)"
continue
en=words[0]
cn=words[1]
tw=words[2]
translations["zh_CN"][en]=cn
translations["zh_TW"][en]=tw
with open(path, 'w',encoding='utf-8') as file:
json.dump(translations, file, indent=2,ensure_ascii=False)

View File

@@ -1,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<application <application
android:label="venera" android:label="venera"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -1,49 +1,69 @@
package com.github.wgh136.venera package com.github.wgh136.venera
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.Settings
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.android.FlutterActivity import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import dev.flutter.packages.file_selector_android.FileUtils
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant import io.flutter.plugins.GeneratedPluginRegistrant
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.lang.Exception import java.util.concurrent.atomic.AtomicInteger
class MainActivity : FlutterActivity() { class MainActivity : FlutterFragmentActivity() {
var volumeListen = VolumeListen() var volumeListen = VolumeListen()
var listening = false var listening = false
private val pickDirectoryCode = 1 private val storageRequestCode = 0x10
private var storagePermissionRequest: ((Boolean) -> Unit)? = null
private lateinit var result: MethodChannel.Result private val nextLocalRequestCode = AtomicInteger()
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { private fun <I, O> startContractForResult(
super.onActivityResult(requestCode, resultCode, data) contract: ActivityResultContract<I, O>,
if (requestCode == pickDirectoryCode) { input: I,
if(resultCode != Activity.RESULT_OK) { callback: ActivityResultCallback<O>
result.success(null) ) {
return val key = "activity_rq_for_result#${nextLocalRequestCode.getAndIncrement()}"
val registry = activityResultRegistry
var launcher: ActivityResultLauncher<I>? = null
val observer = object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (Lifecycle.Event.ON_DESTROY == event) {
launcher?.unregister()
lifecycle.removeObserver(this)
} }
val pickedDirectoryUri = data?.data
if (pickedDirectoryUri == null) {
result.success(null)
return
} }
Thread {
try {
result.success(onPickedDirectory(pickedDirectoryUri))
} }
catch (e: Exception) { lifecycle.addObserver(observer)
result.error("Failed to Copy Files", e.toString(), null) val newCallback = ActivityResultCallback<O> {
} launcher?.unregister()
}.start() lifecycle.removeObserver(observer)
callback.onActivityResult(it)
} }
launcher = registry.register(key, contract, newCallback)
launcher.launch(input)
} }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@@ -63,12 +83,27 @@ class MainActivity : FlutterActivity() {
} }
res.success(null) res.success(null)
} }
"getDirectoryPath" -> { "getDirectoryPath" -> {
this.result = res
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
startActivityForResult(intent, pickDirectoryCode) startContractForResult(ActivityResultContracts.StartActivityForResult(), intent) { activityResult ->
if (activityResult.resultCode != Activity.RESULT_OK) {
res.success(null)
return@startContractForResult
} }
val pickedDirectoryUri = activityResult.data?.data
if (pickedDirectoryUri == null)
res.success(null)
else
try {
res.success(onPickedDirectory(pickedDirectoryUri))
} catch (e: Exception) {
res.error("Failed to Copy Files", e.toString(), null)
}
}
}
else -> res.notImplemented() else -> res.notImplemented()
} }
} }
@@ -85,10 +120,23 @@ class MainActivity : FlutterActivity() {
events.success(2) events.success(2)
} }
} }
override fun onCancel(arguments: Any?) { override fun onCancel(arguments: Any?) {
listening = false listening = false
} }
}) })
val storageChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/storage")
storageChannel.setMethodCallHandler { _, res ->
requestStoragePermission { result ->
res.success(result)
}
}
val selectFileChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/select_file")
selectFileChannel.setMethodCallHandler { _, res ->
openFile(res)
}
} }
private fun getProxy(): String { private fun getProxy(): String {
@@ -102,12 +150,13 @@ class MainActivity : FlutterActivity() {
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if(listening){ if (listening) {
when (keyCode) { when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> { KeyEvent.KEYCODE_VOLUME_DOWN -> {
volumeListen.down() volumeListen.down()
return true return true
} }
KeyEvent.KEYCODE_VOLUME_UP -> { KeyEvent.KEYCODE_VOLUME_UP -> {
volumeListen.up() volumeListen.up()
return true return true
@@ -119,19 +168,30 @@ class MainActivity : FlutterActivity() {
/// copy the directory to tmp directory, return copied directory /// copy the directory to tmp directory, return copied directory
private fun onPickedDirectory(uri: Uri): String { private fun onPickedDirectory(uri: Uri): String {
val contentResolver = context.contentResolver if (!hasStoragePermission()) {
var tmp = context.cacheDir // dart:io cannot access the directory without permission.
// so we need to copy the directory to cache directory
val contentResolver = contentResolver
var tmp = cacheDir
tmp = File(tmp, "getDirectoryPathTemp") tmp = File(tmp, "getDirectoryPathTemp")
tmp.mkdir() tmp.mkdir()
Thread {
copyDirectory(contentResolver, uri, tmp) copyDirectory(contentResolver, uri, tmp)
}.start()
return tmp.absolutePath return tmp.absolutePath
} else {
val docId = DocumentsContract.getTreeDocumentId(uri)
val split: Array<String?> = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
return if ((split.size >= 2) && (split[1] != null)) split[1]!!
else File.separator
}
} }
private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) { private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) {
val src = DocumentFile.fromTreeUri(context, srcUri) ?: return val src = DocumentFile.fromTreeUri(this, srcUri) ?: return
for (file in src.listFiles()) { for (file in src.listFiles()) {
if(file.isDirectory) { if (file.isDirectory) {
val newDir = File(destDir, file.name!!) val newDir = File(destDir, file.name!!)
newDir.mkdir() newDir.mkdir()
copyDirectory(resolver, file.uri, newDir) copyDirectory(resolver, file.uri, newDir)
@@ -145,15 +205,139 @@ class MainActivity : FlutterActivity() {
} }
} }
} }
private fun hasStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
} else {
Environment.isExternalStorageManager()
}
}
private fun requestStoragePermission(result: (Boolean) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
val readPermission = ContextCompat.checkSelfPermission(
this,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
val writePermission = ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
if (!readPermission || !writePermission) {
storagePermissionRequest = result
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
storageRequestCode
)
} else {
result(true)
}
} else {
if (!Environment.isExternalStorageManager()) {
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.addCategory("android.intent.category.DEFAULT")
intent.data = Uri.parse("package:$packageName")
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ _ ->
result(Environment.isExternalStorageManager())
}
} catch (e: Exception) {
result(false)
}
} else {
result(true)
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == storageRequestCode) {
storagePermissionRequest?.invoke(grantResults.all {
it == PackageManager.PERMISSION_GRANTED
})
storagePermissionRequest = null
}
}
private fun openFile(result: MethodChannel.Result) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "*/*"
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ activityResult ->
if (activityResult.resultCode != Activity.RESULT_OK) {
result.success(null)
return@startContractForResult
}
val uri = activityResult.data?.data
if (uri == null) {
result.success(null)
return@startContractForResult
}
val contentResolver = contentResolver
val file = DocumentFile.fromSingleUri(this, uri)
if (file == null) {
result.success(null)
return@startContractForResult
}
val fileName = file.name
if (fileName == null) {
result.success(null)
return@startContractForResult
}
if(hasStoragePermission()) {
try {
val filePath = FileUtils.getPathFromUri(this, uri)
result.success(filePath)
return@startContractForResult
}
catch (e: Exception) {
// ignore
}
}
// copy file to cache directory
val cacheDir = cacheDir
val newFile = File(cacheDir, fileName)
val inputStream = contentResolver.openInputStream(uri)
if (inputStream == null) {
result.success(null)
return@startContractForResult
}
val outputStream = FileOutputStream(newFile)
inputStream.copyTo(outputStream)
inputStream.close()
outputStream.close()
// send file path to flutter
result.success(newFile.absolutePath)
}
}
} }
class VolumeListen{ class VolumeListen {
var onUp = fun() {} var onUp = fun() {}
var onDown = fun() {} var onDown = fun() {}
fun up(){ fun up() {
onUp() onUp()
} }
fun down(){
fun down() {
onDown() onDown()
} }
} }

View File

@@ -699,7 +699,7 @@ class HtmlElement {
doc: this.doc, doc: this.doc,
}) })
if(k == null) return null; if(k == null) return null;
return new HtmlElement(k); return new HtmlElement(k, this.doc);
} }
/** /**
@@ -850,6 +850,7 @@ let console = {
* @param id {string} * @param id {string}
* @param title {string} * @param title {string}
* @param subtitle {string} * @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string} * @param cover {string}
* @param tags {string[]} * @param tags {string[]}
* @param description {string} * @param description {string}
@@ -859,10 +860,11 @@ let console = {
* @param stars {number?} - 0-5, double * @param stars {number?} - 0-5, double
* @constructor * @constructor
*/ */
function Comic({id, title, subtitle, cover, tags, description, maxPage, language, favoriteId, stars}) { function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage, language, favoriteId, stars}) {
this.id = id; this.id = id;
this.title = title; this.title = title;
this.subtitle = subtitle; this.subtitle = subtitle;
this.subTitle = subTitle;
this.cover = cover; this.cover = cover;
this.tags = tags; this.tags = tags;
this.description = description; this.description = description;
@@ -940,6 +942,33 @@ function Comment({userName, avatar, content, time, replyCount, id, isLiked, scor
this.voteStatus = voteStatus; this.voteStatus = voteStatus;
} }
/**
* Create image loading config
* @param url {string?}
* @param method {string?} - http method, uppercase
* @param data {any} - request data, may be null
* @param headers {Object?} - request headers
* @param onResponse {((ArrayBuffer) => ArrayBuffer)?} - modify response data
* @param modifyImage {string?}
* A js script string.
* The script will be executed in a new Isolate.
* A function named `modifyImage` should be defined in the script, which receives an [Image] as the only argument, and returns an [Image]..
* @param onLoadFailed {(() => ImageLoadingConfig)?} - called when the image loading failed
* @constructor
* @since 1.0.5
*
* To keep the compatibility with the old version, do not use the constructor. Consider creating a new object with the properties directly.
*/
function ImageLoadingConfig({url, method, data, headers, onResponse, modifyImage, onLoadFailed}) {
this.url = url;
this.method = method;
this.data = data;
this.headers = headers;
this.onResponse = onResponse;
this.modifyImage = modifyImage;
this.onLoadFailed = onLoadFailed;
}
class ComicSource { class ComicSource {
name = "" name = ""

View File

@@ -17,6 +17,7 @@
"Multiple Comics": "多个漫画", "Multiple Comics": "多个漫画",
"help": "帮助", "help": "帮助",
"Select": "选择", "Select": "选择",
"Selected @a comics": "已选择 @a 部漫画",
"Imported @a comics": "已导入 @a 部漫画", "Imported @a comics": "已导入 @a 部漫画",
"Downloading": "下载中", "Downloading": "下载中",
"Back": "后退", "Back": "后退",
@@ -41,10 +42,12 @@
"Folder": "文件夹", "Folder": "文件夹",
"Confirm": "确认", "Confirm": "确认",
"Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?", "Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?",
"Add comic source": "添加漫画来源", "Are you sure you want to delete @a selected comics?": "您确定要删除 @a 部漫画吗?",
"Add comic source": "添加漫画源",
"Select file": "选择文件", "Select file": "选择文件",
"View list": "查看列表", "View list": "查看列表",
"Open help": "打开帮助", "Open help": "打开帮助",
"Open in Browser": "打开网页",
"Check updates": "检查更新", "Check updates": "检查更新",
"Edit": "编辑", "Edit": "编辑",
"Update": "更新", "Update": "更新",
@@ -99,8 +102,8 @@
"Auto page turning interval": "自动翻页间隔", "Auto page turning interval": "自动翻页间隔",
"Theme Mode": "主题模式", "Theme Mode": "主题模式",
"System": "系统", "System": "系统",
"Light": "明亮", "Light": "浅色",
"Dark": "黑暗", "Dark": "深色",
"Theme Color": "主题颜色", "Theme Color": "主题颜色",
"Red": "红色", "Red": "红色",
"Pink": "粉色", "Pink": "粉色",
@@ -141,7 +144,7 @@
"1. The directory only contains image files." : "1. 目录只包含图片文件。", "1. The directory only contains image files." : "1. 目录只包含图片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。", "2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目录包含多个包含图片文件的目录。每个目录被视为一个章节。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。", "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。", "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n",
"Export as cbz": "导出为cbz", "Export as cbz": "导出为cbz",
"Select a cbz file." : "选择一个cbz文件", "Select a cbz file." : "选择一个cbz文件",
"A cbz file" : "一个cbz文件", "A cbz file" : "一个cbz文件",
@@ -169,7 +172,64 @@
"minAppVersion @version is required": "需要最低App版本 @version", "minAppVersion @version is required": "需要最低App版本 @version",
"Remove": "移除", "Remove": "移除",
"Long press to zoom": "长按缩放", "Long press to zoom": "长按缩放",
"Updates Available": "更新可用" "Updates Available": "更新可用",
"Unselected": "未选择",
"Long press and drag to reorder.": "长按并拖动以重新排序。",
"Limit image width": "限制图片宽度",
"When using Continuous(Top to Bottom) mode": "当使用连续(从上到下)模式",
"Open link": "打开链接",
"Open comic": "打开漫画",
"Move To First": "移动到最前",
"Cancel": "取消",
"Paused": "已暂停",
"Pause": "暂停",
"Operation": "操作",
"Upload": "上传",
"Saved": "已保存",
"Sync Data": "同步数据",
"Syncing Data": "正在同步数据",
"Data Sync": "数据同步",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
"Added": "已添加",
"Turn page by volume keys": "使用音量键翻页",
"Display time & battery info in reader":"在阅读器中显示时间和电量信息",
"EhViewer downloads":"EhViewer下载",
"Select an EhViewer database and a download folder.":"选择EhViewer的下载数据导出的db文件与存放下载内容的目录",
"(EhViewer)Default": "(EhViewer)默认",
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若通过EhViewer数据库导入漫画程序将会按其中的下载标签自动创建收藏文件夹。",
"Multi-Select": "进入多选模式",
"Exit Multi-Select": "退出多选模式",
"Selected @c comics": "已选择 @c 本漫画",
"Select All": "全选",
"Deselect": "取消选择",
"Invert Selection": "反选",
"Select in range": "区间选择",
"Finished": "已完成",
"Updating": "更新中",
"Update Comics Info": "更新漫画信息",
"Create Folder": "新建文件夹",
"Select an image on screen": "选择屏幕上的图片",
"Added @count comics to download queue.": "已添加 @count 本漫画到下载队列",
"Ignore Certificate Errors": "忽略证书错误",
"Authorization Required": "需要身份验证",
"Sync": "同步",
"The folder is Linked to @source": "文件夹已关联到 @source",
"Source Folder": "源收藏夹",
"Use a config file": "使用配置文件",
"Comic Source list": "漫画源列表",
"View": "查看",
"Copy": "复制",
"Copied": "已复制",
"Search History": "搜索历史",
"Clear Search History": "清除搜索历史",
"Search in": "搜索于",
"Clear History": "清除历史",
"Are you sure you want to clear your history?": "确定要清除您的历史记录吗?",
"No Explore Pages": "没有探索页面",
"Add a comic source in home page": "在主页添加一个漫画源",
"Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -190,6 +250,7 @@
"Multiple Comics": "多部漫畫", "Multiple Comics": "多部漫畫",
"help": "幫助", "help": "幫助",
"Select": "選擇", "Select": "選擇",
"Selected @a comics": "已選擇 @a 部漫畫",
"Imported @a comics": "已匯入 @a 部漫畫", "Imported @a comics": "已匯入 @a 部漫畫",
"Downloading": "下載中", "Downloading": "下載中",
"Back": "後退", "Back": "後退",
@@ -215,10 +276,12 @@
"Folder": "文件夾", "Folder": "文件夾",
"Confirm": "確認", "Confirm": "確認",
"Are you sure you want to delete this comic?": "您確定要刪除這部漫畫嗎?", "Are you sure you want to delete this comic?": "您確定要刪除這部漫畫嗎?",
"Add comic source": "添加漫畫來源", "Are you sure you want to delete @a selected comics?": "您確定要刪除 @a 部漫畫嗎?",
"Add comic source": "添加漫畫源",
"Select file": "選擇文件", "Select file": "選擇文件",
"View list": "查看列表", "View list": "查看列表",
"Open help": "打開幫助", "Open help": "打開幫助",
"Open in Browser": "打開網頁",
"Check updates": "檢查更新", "Check updates": "檢查更新",
"Edit": "編輯", "Edit": "編輯",
"Update": "更新", "Update": "更新",
@@ -271,8 +334,8 @@
"Auto page turning interval": "自動翻頁間隔", "Auto page turning interval": "自動翻頁間隔",
"Theme Mode": "主題模式", "Theme Mode": "主題模式",
"System": "系統", "System": "系統",
"Light": "明亮", "Light": "浅色",
"Dark": "黑暗", "Dark": "深色",
"Theme Color": "主題顏色", "Theme Color": "主題顏色",
"Red": "紅色", "Red": "紅色",
"Pink": "粉色", "Pink": "粉色",
@@ -313,7 +376,7 @@
"1. The directory only contains image files." : "1. 目錄只包含圖片文件。", "1. The directory only contains image files." : "1. 目錄只包含圖片文件。",
"2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。", "2. The directory contains directories which contain image files. Each directory is considered as a chapter." : "2. 目錄包含多個包含圖片文件的目錄。每個目錄被視為一個章節。",
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。", "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。", "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n",
"Export as cbz": "匯出為cbz", "Export as cbz": "匯出為cbz",
"Select a cbz file." : "選擇一個cbz文件", "Select a cbz file." : "選擇一個cbz文件",
"A cbz file" : "一個cbz文件", "A cbz file" : "一個cbz文件",
@@ -341,6 +404,63 @@
"minAppVersion @version is required": "需要最低App版本 @version", "minAppVersion @version is required": "需要最低App版本 @version",
"Remove": "移除", "Remove": "移除",
"Long press to zoom": "長按縮放", "Long press to zoom": "長按縮放",
"Updates Available": "更新可用" "Updates Available": "更新可用",
"Unselected": "未選擇",
"Long press and drag to reorder.": "長按並拖動以重新排序。",
"Limit image width": "限制圖片寬度",
"When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式",
"Open link": "打開鏈接",
"Open comic": "打開漫畫",
"Move To First": "移動到最前",
"Cancel": "取消",
"Paused": "已暫停",
"Pause": "暫停",
"Operation": "操作",
"Upload": "上傳",
"Saved": "已保存",
"Sync Data": "同步數據",
"Syncing Data": "正在同步數據",
"Data Sync": "數據同步",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個文件夾",
"Added": "已添加",
"Turn page by volume keys": "使用音量鍵翻頁",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量信息",
"EhViewer downloads": "EhViewer下載",
"Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料匯出的db檔案與存放下載內容的目錄",
"(EhViewer)Default": "(EhViewer)預設",
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若透過EhViewer資料庫匯入漫畫程式將會按其中的下載標籤自動建立收藏資料夾。",
"Multi-Select": "進入多選模式",
"Exit Multi-Select": "退出多選模式",
"Selected @c comics": "已選擇 @c 本漫畫",
"Select All": "全選",
"Deselect": "取消選擇",
"Invert Selection": "反選",
"Select in range": "區間選擇",
"Finished": "已完成",
"Updating": "更新中",
"Update Comics Info": "更新漫畫信息",
"Create Folder": "新建文件夾",
"Select an image on screen": "選擇屏幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載隊列",
"Ignore Certificate Errors": "忽略證書錯誤",
"Authorization Required": "需要身份驗證",
"Sync": "同步",
"The folder is Linked to @source": "文件夾已關聯到 @source",
"Source Folder": "源收藏夾",
"Use a config file": "使用配置文件",
"Comic Source list": "漫畫源列表",
"View": "查看",
"Copy": "複製",
"Copied": "已複製",
"Search History": "搜索歷史",
"Clear Search History": "清除搜索歷史",
"Search in": "搜索於",
"Clear History": "清除歷史",
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
"No Explore Pages": "沒有探索頁面",
"Add a comic source in home page": "在主頁添加一個漫畫源",
"Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面"
} }
} }

View File

@@ -15,6 +15,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
C0086D072CDEFE6E004596D9 /* DirectoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -59,6 +60,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryPicker.swift; sourceTree = "<group>"; };
C22B8A9F3177D4A68EB8F66B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; C22B8A9F3177D4A68EB8F66B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -133,6 +135,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
C0086D062CDEFE64004596D9 /* DirectoryPicker.swift */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -144,7 +147,6 @@
730F73FE38E23FCF3E461640 /* Pods-Runner.release.xcconfig */, 730F73FE38E23FCF3E461640 /* Pods-Runner.release.xcconfig */,
29B89F848F26E839605E1D88 /* Pods-Runner.profile.xcconfig */, 29B89F848F26E839605E1D88 /* Pods-Runner.profile.xcconfig */,
); );
name = Pods;
path = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -336,6 +338,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
C0086D072CDEFE6E004596D9 /* DirectoryPicker.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
); );

View File

@@ -1,12 +1,16 @@
import Flutter import Flutter
import UIKit import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import Foundation //
@main @main
@objc class AppDelegate: FlutterAppDelegate, UIDocumentPickerDelegate { @objc class AppDelegate: FlutterAppDelegate, UIDocumentPickerDelegate {
var flutterResult: FlutterResult? var flutterResult: FlutterResult?
var directoryPath: URL! var directoryPath: URL!
//
private var directoryPicker: DirectoryPicker?
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -42,6 +46,9 @@ import UniformTypeIdentifiers
self.directoryPath?.stopAccessingSecurityScopedResource() self.directoryPath?.stopAccessingSecurityScopedResource()
self.directoryPath = nil self.directoryPath = nil
result(nil) result(nil)
} else if call.method == "selectDirectory" {
self.directoryPicker = DirectoryPicker()
self.directoryPicker?.selectDirectory(result: result)
} else { } else {
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }

View File

@@ -0,0 +1,36 @@
import UIKit
import Flutter
class DirectoryPicker: NSObject, UIDocumentPickerDelegate {
private var result: FlutterResult?
//
func selectDirectory(result: @escaping FlutterResult) {
self.result = result
// UIDocumentPicker
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
documentPicker.delegate = self
documentPicker.allowsMultipleSelection = false
//
if let rootViewController = UIApplication.shared.keyWindow?.rootViewController {
rootViewController.present(documentPicker, animated: true, completion: nil)
}
}
//
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
//
if let url = urls.first {
result?(url.path)
} else {
result?(nil)
}
}
//
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
result?(nil)
}
}

View File

@@ -47,5 +47,11 @@
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Choose images</string> <string>Choose images</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSFaceIDUsageDescription</key>
<string>Ensure that the operation is being performed by the user themselves.</string>
</dict> </dict>
</plist> </plist>

View File

@@ -369,10 +369,14 @@ class _FilledTabBarState extends State<FilledTabBar> {
final double tabWidth = tabRight - tabLeft; final double tabWidth = tabRight - tabLeft;
final double tabCenter = tabLeft + tabWidth / 2; final double tabCenter = tabLeft + tabWidth / 2;
final double tabBarWidth = tabBarBox.size.width; final double tabBarWidth = tabBarBox.size.width;
final double scrollOffset = tabCenter - tabBarWidth / 2; double scrollOffset = tabCenter - tabBarWidth / 2;
if (scrollOffset == scrollController.offset) { if (scrollOffset == scrollController.offset) {
return; return;
} }
scrollOffset = scrollOffset.clamp(
0.0,
scrollController.position.maxScrollExtent,
);
scrollController.animateTo( scrollController.animateTo(
scrollOffset, scrollOffset,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),

View File

@@ -156,7 +156,7 @@ class _ButtonState extends State<Button> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var padding = widget.padding ?? var padding = widget.padding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 4); const EdgeInsets.symmetric(horizontal: 16);
var width = widget.width; var width = widget.width;
if (width != null) { if (width != null) {
width = width - padding.horizontal; width = width - padding.horizontal;
@@ -206,6 +206,7 @@ class _ButtonState extends State<Button> {
padding: padding, padding: padding,
constraints: const BoxConstraints( constraints: const BoxConstraints(
minWidth: 76, minWidth: 76,
minHeight: 32,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: buttonColor, color: buttonColor,

View File

@@ -158,9 +158,16 @@ class ComicTile extends StatelessWidget {
image = FileImage(File(comic.cover.substring(7))); image = FileImage(File(comic.cover.substring(7)));
} else if (comic.sourceKey == 'local') { } else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local); var localComic = LocalManager().find(comic.id, ComicType.local);
image = FileImage(localComic!.coverFile); if (localComic == null) {
return const SizedBox();
}
image = FileImage(localComic.coverFile);
} else { } else {
image = CachedImageProvider(comic.cover, sourceKey: comic.sourceKey); image = CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
} }
return AnimatedImage( return AnimatedImage(
image: image, image: image,
@@ -220,16 +227,26 @@ class ComicTile extends StatelessWidget {
Widget _buildBriefMode(BuildContext context) { Widget _buildBriefMode(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Material( child: LayoutBuilder(
color: Colors.transparent, builder: (context, constraints) {
return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
elevation: 1, onTap: _onTap,
onLongPress:
enableLongPressed ? () => onLongPress(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Column(
children: [
Expanded(
child: SizedBox(
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -238,56 +255,97 @@ class ComicTile extends StatelessWidget {
), ),
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0,
right: 0, right: 0,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.3),
Colors.black.withOpacity(0.5),
]),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 4),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
bottomRight: Radius.circular(10.0),
bottomLeft: Radius.circular(10.0),
),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Padding(
padding:
const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.88,
),
child: Text( child: Text(
comic.title.replaceAll("\n", ""), comic.description.isEmpty
? comic.subtitle
?.replaceAll('\n', '') ??
''
: comic.description
.split('|')
.join('\n'),
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
fontSize: 14.0, fontSize: 12,
color: Colors.white, color: Colors.white,
), ),
textAlign: TextAlign.right,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
),
),
),
)), )),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _onTap,
onLongPress:
enableLongPressed ? () => onLongPress(context) : null,
onSecondaryTapDown: (detail) =>
onSecondaryTap(detail, context),
borderRadius: BorderRadius.circular(8),
child: const SizedBox.expand(),
),
),
)
], ],
), ),
), ),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: Text(
comic.title.replaceAll('\n', ''),
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14.0,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
); );
},
));
}
List<String> _splitText(String text) {
// split text by space, comma. text in brackets will be kept together.
var words = <String>[];
var buffer = StringBuffer();
var inBracket = false;
for (var i = 0; i < text.length; i++) {
var c = text[i];
if (c == '[' || c == '(') {
inBracket = true;
} else if (c == ']' || c == ')') {
inBracket = false;
} else if (c == ' ' || c == ',') {
if (inBracket) {
buffer.write(c);
} else {
words.add(buffer.toString());
buffer.clear();
}
} else {
buffer.write(c);
}
}
if (buffer.isNotEmpty) {
words.add(buffer.toString());
}
return words;
} }
void block(BuildContext comicTileContext) { void block(BuildContext comicTileContext) {
@@ -296,7 +354,7 @@ class ComicTile extends StatelessWidget {
builder: (context) { builder: (context) {
var words = <String>[]; var words = <String>[];
var all = <String>[]; var all = <String>[];
all.addAll(comic.title.split(' ').where((element) => element != '')); all.addAll(_splitText(comic.title));
if (comic.subtitle != null && comic.subtitle != "") { if (comic.subtitle != null && comic.subtitle != "") {
all.add(comic.subtitle!); all.add(comic.subtitle!);
} }
@@ -482,12 +540,11 @@ class _ComicDescription extends StatelessWidget {
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
), ),
child: Center( child: Center(
child:Text( child: Text(
"${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}", "${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}",
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
) )),
),
], ],
) )
], ],
@@ -571,17 +628,19 @@ class _ReadingHistoryPainter extends CustomPainter {
} }
class SliverGridComics extends StatefulWidget { class SliverGridComics extends StatefulWidget {
const SliverGridComics({ const SliverGridComics(
super.key, {super.key,
required this.comics, required this.comics,
this.onLastItemBuild, this.onLastItemBuild,
this.badgeBuilder, this.badgeBuilder,
this.menuBuilder, this.menuBuilder,
this.onTap, this.onTap,
}); this.selections});
final List<Comic> comics; final List<Comic> comics;
final Map<Comic, bool>? selections;
final void Function()? onLastItemBuild; final void Function()? onLastItemBuild;
final String? Function(Comic)? badgeBuilder; final String? Function(Comic)? badgeBuilder;
@@ -635,6 +694,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _SliverGridComics( return _SliverGridComics(
comics: comics, comics: comics,
selection: widget.selections,
onLastItemBuild: widget.onLastItemBuild, onLastItemBuild: widget.onLastItemBuild,
badgeBuilder: widget.badgeBuilder, badgeBuilder: widget.badgeBuilder,
menuBuilder: widget.menuBuilder, menuBuilder: widget.menuBuilder,
@@ -650,10 +710,13 @@ class _SliverGridComics extends StatelessWidget {
this.badgeBuilder, this.badgeBuilder,
this.menuBuilder, this.menuBuilder,
this.onTap, this.onTap,
this.selection,
}); });
final List<Comic> comics; final List<Comic> comics;
final Map<Comic, bool>? selection;
final void Function()? onLastItemBuild; final void Function()? onLastItemBuild;
final String? Function(Comic)? badgeBuilder; final String? Function(Comic)? badgeBuilder;
@@ -671,12 +734,27 @@ class _SliverGridComics extends StatelessWidget {
onLastItemBuild?.call(); onLastItemBuild?.call();
} }
var badge = badgeBuilder?.call(comics[index]); var badge = badgeBuilder?.call(comics[index]);
return ComicTile( var isSelected =
selection == null ? false : selection![comics[index]] ?? false;
var comic = ComicTile(
comic: comics[index], comic: comics[index],
badge: badge, badge: badge,
menuOptions: menuBuilder?.call(comics[index]), menuOptions: menuBuilder?.call(comics[index]),
onTap: onTap != null ? () => onTap!(comics[index]) : null, onTap: onTap != null ? () => onTap!(comics[index]) : null,
); );
if(selection == null) {
return comic;
}
return Container(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.surfaceContainer
: null,
borderRadius: BorderRadius.circular(12),
),
margin: const EdgeInsets.all(4),
child: comic,
);
}, },
childCount: comics.length, childCount: comics.length,
), ),
@@ -874,7 +952,7 @@ class ComicListState extends State<ComicList> {
try { try {
if (widget.loadPage != null) { if (widget.loadPage != null) {
var res = await widget.loadPage!(page); var res = await widget.loadPage!(page);
if(!mounted) return; if (!mounted) return;
if (res.success) { if (res.success) {
if (res.data.isEmpty) { if (res.data.isEmpty) {
_data[page] = const []; _data[page] = const [];

View File

@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
/// patched slider.dart with RtL support
class _SliderDefaultsM3 extends SliderThemeData {
_SliderDefaultsM3(this.context)
: super(trackHeight: 4.0);
final BuildContext context;
late final ColorScheme _colors = Theme.of(context).colorScheme;
@override
Color? get activeTrackColor => _colors.primary;
@override
Color? get inactiveTrackColor => _colors.surfaceContainerHighest;
@override
Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54);
@override
Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38);
@override
Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12);
@override
Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12);
@override
Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(0.38);
@override
Color? get inactiveTickMarkColor => _colors.onSurfaceVariant.withOpacity(0.38);
@override
Color? get disabledActiveTickMarkColor => _colors.onSurface.withOpacity(0.38);
@override
Color? get disabledInactiveTickMarkColor => _colors.onSurface.withOpacity(0.38);
@override
Color? get thumbColor => _colors.primary;
@override
Color? get disabledThumbColor => Color.alphaBlend(_colors.onSurface.withOpacity(0.38), _colors.surface);
@override
Color? get overlayColor => WidgetStateColor.resolveWith((Set<WidgetState> states) {
if (states.contains(WidgetState.dragged)) {
return _colors.primary.withOpacity(0.1);
}
if (states.contains(WidgetState.hovered)) {
return _colors.primary.withOpacity(0.08);
}
if (states.contains(WidgetState.focused)) {
return _colors.primary.withOpacity(0.1);
}
return Colors.transparent;
});
@override
TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith(
color: _colors.onPrimary,
);
@override
SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape();
}
class CustomSlider extends StatefulWidget {
const CustomSlider({required this.min, required this.max, required this.value, required this.divisions, required this.onChanged, required this.focusNode, this.reversed = false, super.key});
final double min;
final double max;
final double value;
final int divisions;
final void Function(double) onChanged;
final FocusNode? focusNode;
final bool reversed;
@override
State<CustomSlider> createState() => _CustomSliderState();
}
class _CustomSliderState extends State<CustomSlider> {
late double value;
@override
void initState() {
super.initState();
value = widget.value;
}
@override
void didUpdateWidget(CustomSlider oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
setState(() {
value = widget.value;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final theme = _SliderDefaultsM3(context);
return Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 12),
child: widget.max - widget.min > 0 ? LayoutBuilder(
builder: (context, constraints) => MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTapDown: (details){
var dx = details.localPosition.dx;
if(widget.reversed){
dx = constraints.maxWidth - dx;
}
var gap = constraints.maxWidth / widget.divisions;
var gapValue = (widget.max - widget.min) / widget.divisions;
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
},
onVerticalDragUpdate: (details){
var dx = details.localPosition.dx;
if(dx > constraints.maxWidth || dx < 0) return;
if(widget.reversed){
dx = constraints.maxWidth - dx;
}
var gap = constraints.maxWidth / widget.divisions;
var gapValue = (widget.max - widget.min) / widget.divisions;
widget.onChanged.call((dx / gap).round() * gapValue + widget.min);
},
child: SizedBox(
height: 24,
child: Center(
child: SizedBox(
height: 24,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Center(
child: Container(
width: double.infinity,
height: 6,
decoration: BoxDecoration(
color: theme.inactiveTrackColor,
borderRadius: const BorderRadius.all(Radius.circular(10))
),
),
),
),
if(constraints.maxWidth / widget.divisions > 10)
Positioned.fill(
child: Row(
children: (){
var res = <Widget>[];
for(int i = 0; i<widget.divisions-1; i++){
res.add(const Spacer());
res.add(Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: colorScheme.surface.withRed(10),
shape: BoxShape.circle,
),
));
}
res.add(const Spacer());
return res;
}.call(),
),
),
Positioned(
top: 0,
bottom: 0,
left: widget.reversed ? null : 0,
right: widget.reversed ? 0 : null,
child: Center(
child: Container(
width: constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min)),
height: 8,
decoration: BoxDecoration(
color: theme.activeTrackColor,
borderRadius: const BorderRadius.all(Radius.circular(10))
),
),
)
),
Positioned(
top: 0,
bottom: 0,
left: widget.reversed ? null : constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
right: !widget.reversed ? null : constraints.maxWidth * ((value - widget.min) / (widget.max - widget.min))-11,
child: Center(
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: theme.activeTrackColor,
shape: BoxShape.circle,
),
),
),
)
],
),
),
),
),
),
),
) : null,
);
}
}

View File

@@ -51,6 +51,10 @@ class Flyout extends StatefulWidget {
@override @override
State<Flyout> createState() => FlyoutState(); State<Flyout> createState() => FlyoutState();
static FlyoutState of(BuildContext context) {
return context.findAncestorStateOfType<FlyoutState>()!;
}
} }
class FlyoutState extends State<Flyout> { class FlyoutState extends State<Flyout> {

View File

@@ -2,10 +2,7 @@ part of 'components.dart';
class SliverGridViewWithFixedItemHeight extends StatelessWidget { class SliverGridViewWithFixedItemHeight extends StatelessWidget {
const SliverGridViewWithFixedItemHeight( const SliverGridViewWithFixedItemHeight(
{required this.delegate, {required this.delegate, required this.maxCrossAxisExtent, required this.itemHeight, super.key});
required this.maxCrossAxisExtent,
required this.itemHeight,
super.key});
final SliverChildDelegate delegate; final SliverChildDelegate delegate;
@@ -65,8 +62,7 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
@override @override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) { bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true; if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || oldDelegate.itemHeight != itemHeight) {
oldDelegate.itemHeight != itemHeight) {
return true; return true;
} }
return false; return false;
@@ -95,8 +91,7 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
} }
} }
SliverGridLayout getDetailedModeLayout( SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale) {
SliverConstraints constraints, double scale) {
const minCrossAxisExtent = 360; const minCrossAxisExtent = 360;
final itemHeight = 152 * scale; final itemHeight = 152 * scale;
final width = constraints.crossAxisExtent; final width = constraints.crossAxisExtent;
@@ -111,14 +106,11 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
reverseCrossAxis: false); reverseCrossAxis: false);
} }
SliverGridLayout getBriefModeLayout( SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale) {
SliverConstraints constraints, double scale) {
final maxCrossAxisExtent = 192.0 * scale; final maxCrossAxisExtent = 192.0 * scale;
const childAspectRatio = 0.72; const childAspectRatio = 0.68;
const crossAxisSpacing = 0.0; const crossAxisSpacing = 0.0;
int crossAxisCount = int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
.ceil();
// Ensure a minimum count of 1, can be zero and result in an infinite extent // Ensure a minimum count of 1, can be zero and result in an infinite extent
// below when the window size is 0. // below when the window size is 0.
crossAxisCount = math.max(1, crossAxisCount); crossAxisCount = math.max(1, crossAxisCount);

View File

@@ -27,7 +27,7 @@ class NaviPane extends StatefulWidget {
required this.paneActions, required this.paneActions,
required this.pageBuilder, required this.pageBuilder,
this.initialPage = 0, this.initialPage = 0,
this.onPageChange, this.onPageChanged,
required this.observer, required this.observer,
required this.navigatorKey, required this.navigatorKey,
super.key}); super.key});
@@ -38,7 +38,7 @@ class NaviPane extends StatefulWidget {
final Widget Function(int page) pageBuilder; final Widget Function(int page) pageBuilder;
final void Function(int index)? onPageChange; final void Function(int index)? onPageChanged;
final int initialPage; final int initialPage;
@@ -59,7 +59,7 @@ class _NaviPaneState extends State<NaviPane>
set currentPage(int value) { set currentPage(int value) {
if (value == _currentPage) return; if (value == _currentPage) return;
_currentPage = value; _currentPage = value;
widget.onPageChange?.call(value); widget.onPageChanged?.call(value);
} }
void Function()? mainViewUpdateHandler; void Function()? mainViewUpdateHandler;

View File

@@ -31,8 +31,9 @@ class Select extends StatelessWidget {
var size = renderBox.size; var size = renderBox.size;
showMenu( showMenu(
elevation: 3, elevation: 3,
color: context.colorScheme.surface, color: context.brightness == Brightness.light
surfaceTintColor: Colors.transparent, ? const Color(0xFFF6F6F6)
: const Color(0xFF1E1E1E),
context: context, context: context,
useRootNavigator: true, useRootNavigator: true,
constraints: BoxConstraints( constraints: BoxConstraints(
@@ -41,8 +42,8 @@ class Select extends StatelessWidget {
), ),
position: RelativeRect.fromLTRB( position: RelativeRect.fromLTRB(
offset.dx, offset.dx,
offset.dy + size.height, offset.dy + size.height + 2,
offset.dx + size.height, offset.dx + size.height + 2,
offset.dy, offset.dy,
), ),
items: values items: values

View File

@@ -485,8 +485,15 @@ class WindowPlacement {
} }
} }
static Rect? lastValidRect;
static Future<WindowPlacement> get current async { static Future<WindowPlacement> get current async {
var rect = await windowManager.getBounds(); var rect = await windowManager.getBounds();
if(validate(rect)) {
lastValidRect = rect;
} else {
rect = lastValidRect ?? defaultPlacement.rect;
}
var isMaximized = await windowManager.isMaximized(); var isMaximized = await windowManager.isMaximized();
return WindowPlacement(rect, isMaximized); return WindowPlacement(rect, isMaximized);
} }
@@ -501,9 +508,6 @@ class WindowPlacement {
static void loop() async { static void loop() async {
timer ??= Timer.periodic(const Duration(milliseconds: 100), (timer) async { timer ??= Timer.periodic(const Duration(milliseconds: 100), (timer) async {
var placement = await WindowPlacement.current; var placement = await WindowPlacement.current;
if (!validate(placement.rect)) {
return;
}
if (placement.rect != cache.rect || if (placement.rect != cache.rect ||
placement.isMaximized != cache.isMaximized) { placement.isMaximized != cache.isMaximized) {
cache = placement; cache = placement;

View File

@@ -10,7 +10,7 @@ export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.0.2"; final version = "1.0.6";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;

View File

@@ -113,6 +113,14 @@ class _Settings with ChangeNotifier {
'downloadThreads': 5, 'downloadThreads': 5,
'enableLongPressToZoom': true, 'enableLongPressToZoom': true,
'checkUpdateOnStart': true, 'checkUpdateOnStart': true,
'limitImageWidth': true,
'webdav': [], // empty means not configured
'dataVersion': 0,
'quickFavorite': null,
'enableTurnPageByVolumeKey': true,
'enableClockAndBatteryInfoInReader': true,
'ignoreCertificateErrors': false,
'authorizationRequired': false,
}; };
operator [](String key) { operator [](String key) {

View File

@@ -10,6 +10,7 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -135,6 +136,8 @@ class ComicSource {
notifyListeners(); notifyListeners();
} }
static bool get isEmpty => _sources.isEmpty;
/// Name of this source. /// Name of this source.
final String name; final String name;
@@ -236,6 +239,7 @@ class ComicSource {
} }
await file.writeAsString(jsonEncode(data)); await file.writeAsString(jsonEncode(data));
_isSaving = false; _isSaving = false;
DataSync().uploadData();
} }
Future<bool> reLogin() async { Future<bool> reLogin() async {

View File

@@ -92,7 +92,7 @@ class Comic {
Comic.fromJson(Map<String, dynamic> json, this.sourceKey) Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
: title = json["title"], : title = json["title"],
subtitle = json["subTitle"] ?? "", subtitle = json["subtitle"] ?? json["subTitle"] ?? "",
cover = json["cover"], cover = json["cover"],
id = json["id"], id = json["id"],
tags = List<String>.from(json["tags"] ?? []), tags = List<String>.from(json["tags"] ?? []),

View File

@@ -157,9 +157,11 @@ class ComicSourceParser {
await source.loadData(); await source.loadData();
if(_checkExists("init")) {
Future.delayed(const Duration(milliseconds: 50), () { Future.delayed(const Duration(milliseconds: 50), () {
JsEngine().runCode("ComicSource.sources.$_key.init()"); JsEngine().runCode("ComicSource.sources.$_key.init()");
}); });
}
return source; return source;
} }

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/image_provider/local_favorite_image.dart'; import 'package:venera/foundation/image_provider/local_favorite_image.dart';
@@ -10,8 +11,8 @@ import 'app.dart';
import 'comic_source/comic_source.dart'; import 'comic_source/comic_source.dart';
import 'comic_type.dart'; import 'comic_type.dart';
String _getCurTime() { String _getTimeString(DateTime time) {
return DateTime.now() return time
.toIso8601String() .toIso8601String()
.replaceFirst("T", " ") .replaceFirst("T", " ")
.substring(0, 19); .substring(0, 19);
@@ -26,7 +27,7 @@ class FavoriteItem implements Comic {
@override @override
String id; String id;
String coverPath; String coverPath;
String time = _getCurTime(); late String time;
FavoriteItem({ FavoriteItem({
required this.id, required this.id,
@@ -35,7 +36,11 @@ class FavoriteItem implements Comic {
required this.author, required this.author,
required this.type, required this.type,
required this.tags, required this.tags,
}); DateTime? favoriteTime
}) {
var t = favoriteTime ?? DateTime.now();
time = _getTimeString(t);
}
FavoriteItem.fromRow(Row row) FavoriteItem.fromRow(Row row)
: name = row["name"], : name = row["name"],
@@ -148,7 +153,7 @@ class FavoriteItemWithFolderInfo extends FavoriteItem {
); );
} }
class LocalFavoritesManager { class LocalFavoritesManager with ChangeNotifier {
factory LocalFavoritesManager() => factory LocalFavoritesManager() =>
cache ?? (cache = LocalFavoritesManager._create()); cache ?? (cache = LocalFavoritesManager._create());
@@ -166,6 +171,13 @@ class LocalFavoritesManager {
order_value int order_value int
); );
"""); """);
_db.execute("""
create table if not exists folder_sync (
folder_name text primary key,
source_key text,
source_folder text
);
""");
} }
List<String> find(String id, ComicType type) { List<String> find(String id, ComicType type) {
@@ -226,13 +238,14 @@ class LocalFavoritesManager {
return folders; return folders;
} }
void updateOrder(Map<String, int> order) { void updateOrder(List<String> folders) {
for (var folder in order.keys) { for (int i = 0; i < folders.length; i++) {
_db.execute(""" _db.execute("""
insert or replace into folder_order (folder_name, order_value) insert or replace into folder_order (folder_name, order_value)
values (?, ?); values (?, ?);
""", [folder, order[folder]]); """, [folders[i], i]);
} }
notifyListeners();
} }
int count(String folderName) { int count(String folderName) {
@@ -272,6 +285,7 @@ class LocalFavoritesManager {
set tags = '$tag,' || tags set tags = '$tag,' || tags
where id == ? where id == ?
""", [id]); """, [id]);
notifyListeners();
} }
List<FavoriteItemWithFolderInfo> allComics() { List<FavoriteItemWithFolderInfo> allComics() {
@@ -286,12 +300,16 @@ class LocalFavoritesManager {
return res; return res;
} }
bool existsFolder(String name) {
return folderNames.contains(name);
}
/// create a folder /// create a folder
String createFolder(String name, [bool renameWhenInvalidName = false]) { String createFolder(String name, [bool renameWhenInvalidName = false]) {
if (name.isEmpty) { if (name.isEmpty) {
if (renameWhenInvalidName) { if (renameWhenInvalidName) {
int i = 0; int i = 0;
while (folderNames.contains(i.toString())) { while (existsFolder(i.toString())) {
i++; i++;
} }
name = i.toString(); name = i.toString();
@@ -299,11 +317,11 @@ class LocalFavoritesManager {
throw "name is empty!"; throw "name is empty!";
} }
} }
if (folderNames.contains(name)) { if (existsFolder(name)) {
if (renameWhenInvalidName) { if (renameWhenInvalidName) {
var prevName = name; var prevName = name;
int i = 0; int i = 0;
while (folderNames.contains(i.toString())) { while (existsFolder(i.toString())) {
i++; i++;
} }
name = prevName + i.toString(); name = prevName + i.toString();
@@ -324,9 +342,36 @@ class LocalFavoritesManager {
primary key (id, type) primary key (id, type)
); );
"""); """);
notifyListeners();
return name; return name;
} }
void linkFolderToNetwork(String folder, String source, String networkFolder) {
_db.execute("""
insert or replace into folder_sync (folder_name, source_key, source_folder)
values (?, ?, ?);
""", [folder, source, networkFolder]);
}
bool isLinkedToNetworkFolder(String folder, String source, String networkFolder) {
var res = _db.select("""
select * from folder_sync
where folder_name == ? and source_key == ? and source_folder == ?;
""", [folder, source, networkFolder]);
return res.isNotEmpty;
}
(String?, String?) findLinked(String folder) {
var res = _db.select("""
select * from folder_sync
where folder_name == ?;
""", [folder]);
if (res.isEmpty) {
return (null, null);
}
return (res.first["source_key"], res.first["source_folder"]);
}
bool comicExists(String folder, String id, ComicType type) { bool comicExists(String folder, String id, ComicType type) {
var res = _db.select(""" var res = _db.select("""
select * from "$folder" select * from "$folder"
@@ -346,20 +391,19 @@ class LocalFavoritesManager {
return FavoriteItem.fromRow(res.first); return FavoriteItem.fromRow(res.first);
} }
/// add comic to a folder /// add comic to a folder.
/// /// return true if success, false if already exists
/// This method will download cover to local, to avoid problems like changing url bool addComic(String folder, FavoriteItem comic, [int? order]) {
void addComic(String folder, FavoriteItem comic, [int? order]) async {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
if (!folderNames.contains(folder)) { if (!existsFolder(folder)) {
throw Exception("Folder does not exists"); throw Exception("Folder does not exists");
} }
var res = _db.select(""" var res = _db.select("""
select * from "$folder" select * from "$folder"
where id == '${comic.id}'; where id == ? and type == ?;
"""); """, [comic.id, comic.type.value]);
if (res.isNotEmpty) { if (res.isNotEmpty) {
return; return false;
} }
final params = [ final params = [
comic.id, comic.id,
@@ -386,6 +430,8 @@ class LocalFavoritesManager {
values (?, ?, ?, ?, ?, ?, ?, ?); values (?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, minValue(folder) - 1]); """, [...params, minValue(folder) - 1]);
} }
notifyListeners();
return true;
} }
/// delete a folder /// delete a folder
@@ -394,6 +440,11 @@ class LocalFavoritesManager {
_db.execute(""" _db.execute("""
drop table "$name"; drop table "$name";
"""); """);
_db.execute("""
delete from folder_order
where folder_name == ?;
""", [name]);
notifyListeners();
} }
void deleteComic(String folder, FavoriteItem comic) { void deleteComic(String folder, FavoriteItem comic) {
@@ -408,6 +459,7 @@ class LocalFavoritesManager {
delete from "$folder" delete from "$folder"
where id == ? and type == ?; where id == ? and type == ?;
""", [id, type.value]); """, [id, type.value]);
notifyListeners();
} }
Future<void> clearAll() async { Future<void> clearAll() async {
@@ -417,7 +469,7 @@ class LocalFavoritesManager {
} }
void reorder(List<FavoriteItem> newFolder, String folder) async { void reorder(List<FavoriteItem> newFolder, String folder) async {
if (!folderNames.contains(folder)) { if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found"); throw Exception("Failed to reorder: folder not found");
} }
deleteFolder(folder); deleteFolder(folder);
@@ -425,10 +477,11 @@ class LocalFavoritesManager {
for (int i = 0; i < newFolder.length; i++) { for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], i); addComic(folder, newFolder[i], i);
} }
notifyListeners();
} }
void rename(String before, String after) { void rename(String before, String after) {
if (folderNames.contains(after)) { if (existsFolder(after)) {
throw "Name already exists!"; throw "Name already exists!";
} }
if (after.contains('"')) { if (after.contains('"')) {
@@ -438,6 +491,17 @@ class LocalFavoritesManager {
ALTER TABLE "$before" ALTER TABLE "$before"
RENAME TO "$after"; RENAME TO "$after";
"""); """);
_db.execute("""
update folder_order
set folder_name = ?
where folder_name == ?;
""", [after, before]);
_db.execute("""
update folder_sync
set folder_name = ?
where folder_name == ?;
""", [after, before]);
notifyListeners();
} }
void onReadEnd(String id, ComicType type) async { void onReadEnd(String id, ComicType type) async {
@@ -475,6 +539,7 @@ class LocalFavoritesManager {
""", [newTime, id]); """, [newTime, id]);
} }
} }
notifyListeners();
} }
List<FavoriteItemWithFolderInfo> search(String keyword) { List<FavoriteItemWithFolderInfo> search(String keyword) {
@@ -521,6 +586,7 @@ class LocalFavoritesManager {
set tags = ? set tags = ?
where id == ?; where id == ?;
""", [tags.join(","), id]); """, [tags.join(","), id]);
notifyListeners();
} }
final _cachedFavoritedIds = <String, bool>{}; final _cachedFavoritedIds = <String, bool>{};
@@ -560,6 +626,7 @@ class LocalFavoritesManager {
comic.id, comic.id,
comic.type.value comic.type.value
]); ]);
notifyListeners();
} }
String folderToJson(String folder) { String folderToJson(String folder) {
@@ -579,9 +646,9 @@ class LocalFavoritesManager {
if (folder == null || folder is! String) { if (folder == null || folder is! String) {
throw "Invalid data"; throw "Invalid data";
} }
if (folderNames.contains(folder)) { if (existsFolder(folder)) {
int i = 0; int i = 0;
while (folderNames.contains("$folder($i)")) { while (existsFolder("$folder($i)")) {
i++; i++;
} }
folder = "$folder($i)"; folder = "$folder($i)";

View File

@@ -8,7 +8,7 @@ import 'cached_image.dart' as image_provider;
class CachedImageProvider class CachedImageProvider
extends BaseImageProvider<image_provider.CachedImageProvider> { extends BaseImageProvider<image_provider.CachedImageProvider> {
/// Image provider for normal image. /// Image provider for normal image.
const CachedImageProvider(this.url, {this.headers, this.sourceKey}); const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
final String url; final String url;
@@ -16,9 +16,11 @@ class CachedImageProvider
final String? sourceKey; final String? sourceKey;
final String? cid;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
chunkEvents.add(ImageChunkEvent( chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes, cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes, expectedTotalBytes: progress.totalBytes,
@@ -36,5 +38,5 @@ class CachedImageProvider
} }
@override @override
String get key => url; String get key => url + (sourceKey ?? "") + (cid ?? "");
} }

View File

@@ -19,6 +19,7 @@ import 'package:pointycastle/block/modes/cfb.dart';
import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ecb.dart';
import 'package:pointycastle/block/modes/ofb.dart'; import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
@@ -70,6 +71,7 @@ class JsEngine with _JSEngineApi {
var setGlobalFunc = var setGlobalFunc =
_engine!.evaluate("(key, value) => { this[key] = value; }"); _engine!.evaluate("(key, value) => { this[key] = value; }");
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]); (setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
setGlobalFunc(["appVersion", App.version]);
setGlobalFunc.free(); setGlobalFunc.free();
var jsInit = await rootBundle.load("assets/init.js"); var jsInit = await rootBundle.load("assets/init.js");
_engine! _engine!

View File

@@ -5,6 +5,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -158,12 +160,13 @@ class LocalManager with ChangeNotifier {
return "Directory is not empty"; return "Directory is not empty";
} }
try { try {
await copyDirectory( await copyDirectoryIsolate(
Directory(path), Directory(path),
newDir, newDir,
); );
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(path); await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(newPath);
} catch (e) { } catch (e, s) {
Log.error("IO", e, s);
return e.toString(); return e.toString();
} }
await Directory(path).deleteIgnoreError(recursive:true); await Directory(path).deleteIgnoreError(recursive:true);
@@ -344,6 +347,10 @@ class LocalManager with ChangeNotifier {
comic.cover) { comic.cover) {
continue; continue;
} }
//Hidden file in some file system
if(entity.name.startsWith('.')) {
continue;
}
files.add(entity); files.add(entity);
} }
} }
@@ -358,10 +365,10 @@ class LocalManager with ChangeNotifier {
return files.map((e) => "file://${e.path}").toList(); return files.map((e) => "file://${e.path}").toList();
} }
Future<bool> isDownloaded(String id, ComicType type, int ep) async { Future<bool> isDownloaded(String id, ComicType type, [int? ep]) async {
var comic = find(id, type); var comic = find(id, type);
if (comic == null) return false; if (comic == null) return false;
if (comic.chapters == null) return true; if (comic.chapters == null || ep == null) return true;
return comic.downloadedChapters return comic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(ep-1)); .contains(comic.chapters!.keys.elementAt(ep-1));
} }
@@ -437,9 +444,20 @@ class LocalManager with ChangeNotifier {
downloadingTasks.first.resume(); downloadingTasks.first.resume();
} }
void deleteComic(LocalComic c) { void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
if(removeFileOnDisk) {
var dir = Directory(FilePath.join(path, c.directory)); var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true); dir.deleteIgnoreError(recursive: true);
}
//Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
if(HistoryManager().findSync(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType);
}
assert(c.comicType == ComicType.local);
var folders = LocalFavoritesManager().find(c.id, c.comicType);
for (var f in folders) {
LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType);
}
remove(c.id, c.comicType); remove(c.id, c.comicType);
notifyListeners(); notifyListeners();
} }

View File

@@ -82,11 +82,12 @@ class Log {
addLog(LogLevel.warning, title, content); addLog(LogLevel.warning, title, content);
} }
static error(String title, String content, [Object? stackTrace]) { static error(String title, Object content, [Object? stackTrace]) {
var info = content.toString();
if(stackTrace != null) { if(stackTrace != null) {
content += "\n${stackTrace.toString()}"; info += "\n${stackTrace.toString()}";
} }
addLog(LogLevel.error, title, content); addLog(LogLevel.error, title, info);
} }
static void clear() => _logs.clear(); static void clear() => _logs.clear();

View File

@@ -5,7 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:rhttp/rhttp.dart'; import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/pages/auth_page.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/main_page.dart'; import 'package:venera/pages/main_page.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
@@ -65,15 +65,59 @@ class MyApp extends StatefulWidget {
State<MyApp> createState() => _MyAppState(); State<MyApp> createState() => _MyAppState();
} }
class _MyAppState extends State<MyApp> { class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
checkUpdates(); checkUpdates();
App.registerForceRebuild(forceRebuild); App.registerForceRebuild(forceRebuild);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addObserver(this);
super.initState(); super.initState();
} }
bool isAuthPageActive = false;
OverlayEntry? hideContentOverlay;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if(!App.isMobile) {
return;
}
if (state == AppLifecycleState.inactive && hideContentOverlay == null) {
hideContentOverlay = OverlayEntry(
builder: (context) {
return Positioned.fill(
child: Container(
width: double.infinity,
height: double.infinity,
color: App.rootContext.colorScheme.surface,
),
);
},
);
Overlay.of(App.rootContext).insert(hideContentOverlay!);
} else if (hideContentOverlay != null &&
state == AppLifecycleState.resumed) {
hideContentOverlay!.remove();
hideContentOverlay = null;
}
if (state == AppLifecycleState.hidden &&
appdata.settings['authorizationRequired'] &&
!isAuthPageActive) {
isAuthPageActive = true;
App.rootContext.to(
() => AuthPage(
onSuccessfulAuth: () {
App.rootContext.pop();
isAuthPageActive = false;
},
),
);
}
super.didChangeAppLifecycleState(state);
}
void forceRebuild() { void forceRebuild() {
void rebuild(Element el) { void rebuild(Element el) {
el.markNeedsBuild(); el.markNeedsBuild();
@@ -86,14 +130,25 @@ class _MyAppState extends State<MyApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget home;
if (appdata.settings['authorizationRequired']) {
home = AuthPage(
onSuccessfulAuth: () {
App.rootContext.toReplacement(() => const MainPage());
},
);
} else {
home = const MainPage();
}
return MaterialApp( return MaterialApp(
home: const MainPage(), home: home,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: App.mainColor, seedColor: App.mainColor,
surface: Colors.white, surface: Colors.white,
primary: App.mainColor.shade600, primary: App.mainColor.shade600,
// ignore: deprecated_member_use
background: Colors.white, background: Colors.white,
), ),
fontFamily: App.isWindows ? "Microsoft YaHei" : null, fontFamily: App.isWindows ? "Microsoft YaHei" : null,
@@ -105,6 +160,7 @@ class _MyAppState extends State<MyApp> {
brightness: Brightness.dark, brightness: Brightness.dark,
surface: Colors.black, surface: Colors.black,
primary: App.mainColor.shade400, primary: App.mainColor.shade400,
// ignore: deprecated_member_use
background: Colors.black, background: Colors.black,
), ),
fontFamily: App.isWindows ? "Microsoft YaHei" : null, fontFamily: App.isWindows ? "Microsoft YaHei" : null,
@@ -171,12 +227,12 @@ class _MyAppState extends State<MyApp> {
} }
void checkUpdates() async { void checkUpdates() async {
if(!appdata.settings['checkUpdateOnStart']) { if (!appdata.settings['checkUpdateOnStart']) {
return; return;
} }
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch; var now = DateTime.now().millisecondsSinceEpoch;
if(now - lastCheck < 24 * 60 * 60 * 1000) { if (now - lastCheck < 24 * 60 * 60 * 1000) {
return; return;
} }
appdata.implicitData['lastCheckUpdate'] = now; appdata.implicitData['lastCheckUpdate'] = now;

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -105,32 +106,22 @@ class MyLogInterceptor implements Interceptor {
class AppDio with DioMixin { class AppDio with DioMixin {
String? _proxy = proxy; String? _proxy = proxy;
static bool get ignoreCertificateErrors => appdata.settings['ignoreCertificateErrors'] == true;
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
interceptors.add(MyLogInterceptor()); httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
httpClientAdapter = RHttpAdapter(const rhttp.ClientSettings()); proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
tlsSettings: rhttp.TlsSettings(
verifyCertificates: !ignoreCertificateErrors,
),
));
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager()); interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor()); interceptors.add(CloudflareInterceptor());
} interceptors.add(MyLogInterceptor());
static HttpClient createHttpClient() {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
client.findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
client.idleTimeout = const Duration(seconds: 100);
client.badCertificateCallback =
(X509Certificate cert, String host, int port) {
if (host.contains("cdn")) return true;
final ipv4RegExp = RegExp(
r'^((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})$');
if (ipv4RegExp.hasMatch(host)) {
return true;
}
return false;
};
return client;
} }
static String? proxy; static String? proxy;
@@ -176,6 +167,8 @@ class AppDio with DioMixin {
return res; return res;
} }
static final Map<String, bool> _requests = {};
@override @override
Future<Response<T>> request<T>( Future<Response<T>> request<T>(
String path, { String path, {
@@ -186,6 +179,13 @@ class AppDio with DioMixin {
ProgressCallback? onSendProgress, ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress, ProgressCallback? onReceiveProgress,
}) async { }) async {
if (options?.headers?['prevent-parallel'] == 'true') {
while (_requests.containsKey(path)) {
await Future.delayed(const Duration(milliseconds: 20));
}
_requests[path] = true;
options!.headers!.remove('prevent-parallel');
}
proxy = await getProxy(); proxy = await getProxy();
if (_proxy != proxy) { if (_proxy != proxy) {
Log.info("Network", "Proxy changed to $proxy"); Log.info("Network", "Proxy changed to $proxy");
@@ -194,15 +194,13 @@ class AppDio with DioMixin {
proxySettings: proxy == null proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy() ? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!), : rhttp.ProxySettings.proxy(proxy!),
tlsSettings: rhttp.TlsSettings(
verifyCertificates: !ignoreCertificateErrors,
),
)); ));
} }
Log.info( try {
"Network", return super.request<T>(
"${options?.method ?? 'GET'} $path\n"
"Headers: ${options?.headers}\n"
"Data: $data\n",
);
return super.request(
path, path,
data: data, data: data,
queryParameters: queryParameters, queryParameters: queryParameters,
@@ -211,21 +209,29 @@ class AppDio with DioMixin {
onSendProgress: onSendProgress, onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress, onReceiveProgress: onReceiveProgress,
); );
} finally {
if (_requests.containsKey(path)) {
_requests.remove(path);
}
}
} }
} }
class RHttpAdapter implements HttpClientAdapter { class RHttpAdapter implements HttpClientAdapter {
rhttp.ClientSettings settings; rhttp.ClientSettings settings;
RHttpAdapter(this.settings) { RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
settings.copyWith( settings = settings.copyWith(
redirectSettings: const rhttp.RedirectSettings.limited(5), redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings( timeoutSettings: const rhttp.TimeoutSettings(
connectTimeout: Duration(seconds: 15), connectTimeout: Duration(seconds: 15),
keepAliveTimeout: Duration(seconds: 60), keepAliveTimeout: Duration(seconds: 60),
keepAlivePing: Duration(seconds: 30), keepAlivePing: Duration(seconds: 30),
), ),
httpVersionPref: rhttp.HttpVersionPref.http1_1, throwOnStatusCode: false,
tlsSettings: rhttp.TlsSettings(
verifyCertificates: !AppDio.ignoreCertificateErrors,
),
); );
} }
@@ -273,14 +279,9 @@ class RHttpAdapter implements HttpClientAdapter {
headers[key]!.add(entry.$2); headers[key]!.add(entry.$2);
} }
var data = res.body; var data = res.body;
if(headers['content-encoding']?.contains('gzip') ?? false) { if (headers['content-encoding']?.contains('gzip') ?? false) {
// rhttp does not support gzip decoding // rhttp does not support gzip decoding
var buffer = <int>[]; data = gzip.decoder.bind(data).map((data) => Uint8List.fromList(data));
await for (var chunk in data) {
buffer.addAll(chunk);
}
data = Stream.value(Uint8List.fromList(gzip.decode(buffer)));
buffer.clear();
} }
return ResponseBody( return ResponseBody(
data, data,

View File

@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
class NetworkCache { class NetworkCache {
@@ -43,7 +42,7 @@ class NetworkCacheManager implements Interceptor {
static const _maxCacheSize = 10 * 1024 * 1024; static const _maxCacheSize = 10 * 1024 * 1024;
void setCache(NetworkCache cache) { void setCache(NetworkCache cache) {
while(size > _maxCacheSize){ while (size > _maxCacheSize) {
size -= _cache.values.first.size; size -= _cache.values.first.size;
_cache.remove(_cache.keys.first); _cache.remove(_cache.keys.first);
} }
@@ -53,7 +52,7 @@ class NetworkCacheManager implements Interceptor {
void removeCache(Uri uri) { void removeCache(Uri uri) {
var cache = _cache[uri]; var cache = _cache[uri];
if(cache != null){ if (cache != null) {
size -= cache.size; size -= cache.size;
} }
_cache.remove(uri); _cache.remove(uri);
@@ -64,41 +63,29 @@ class NetworkCacheManager implements Interceptor {
size = 0; size = 0;
} }
var preventParallel = <Uri, Completer>{};
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) { void onError(DioException err, ErrorInterceptorHandler handler) {
if(err.requestOptions.method != "GET"){ if (err.requestOptions.method != "GET") {
return handler.next(err); return handler.next(err);
} }
if(preventParallel[err.requestOptions.uri] != null){
preventParallel[err.requestOptions.uri]!.complete();
preventParallel.remove(err.requestOptions.uri);
}
return handler.next(err); return handler.next(err);
} }
@override @override
void onRequest( void onRequest(
RequestOptions options, RequestInterceptorHandler handler) async { RequestOptions options, RequestInterceptorHandler handler) async {
if(options.method != "GET"){ if (options.method != "GET") {
return handler.next(options); return handler.next(options);
} }
if(preventParallel[options.uri] != null){
await preventParallel[options.uri]!.future;
}
var cache = getCache(options.uri); var cache = getCache(options.uri);
if (cache == null || !compareHeaders(options.headers, cache.requestHeaders)) { if (cache == null ||
if(options.headers['cache-time'] != null){ !compareHeaders(options.headers, cache.requestHeaders)) {
if (options.headers['cache-time'] != null) {
options.headers.remove('cache-time'); options.headers.remove('cache-time');
} }
if(options.headers['prevent-parallel'] != null){
options.headers.remove('prevent-parallel');
preventParallel[options.uri] = Completer();
}
return handler.next(options); return handler.next(options);
} else { } else {
if(options.headers['cache-time'] == 'no'){ if (options.headers['cache-time'] == 'no') {
options.headers.remove('cache-time'); options.headers.remove('cache-time');
removeCache(options.uri); removeCache(options.uri);
return handler.next(options); return handler.next(options);
@@ -106,20 +93,21 @@ class NetworkCacheManager implements Interceptor {
} }
var time = DateTime.now(); var time = DateTime.now();
var diff = time.difference(cache.time); var diff = time.difference(cache.time);
if (options.headers['cache-time'] == 'long' if (options.headers['cache-time'] == 'long' &&
&& diff < const Duration(hours: 2)) { diff < const Duration(hours: 2)) {
return handler.resolve(Response( return handler.resolve(Response(
requestOptions: options, requestOptions: options,
data: cache.data, data: cache.data,
headers: Headers.fromMap(cache.responseHeaders), headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200, statusCode: 200,
)); ));
} } else if (diff < const Duration(seconds: 5)) {
else if (diff < const Duration(seconds: 5)) {
return handler.resolve(Response( return handler.resolve(Response(
requestOptions: options, requestOptions: options,
data: cache.data, data: cache.data,
headers: Headers.fromMap(cache.responseHeaders), headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200, statusCode: 200,
)); ));
} else if (diff < const Duration(hours: 1)) { } else if (diff < const Duration(hours: 1)) {
@@ -133,7 +121,8 @@ class NetworkCacheManager implements Interceptor {
return handler.resolve(Response( return handler.resolve(Response(
requestOptions: options, requestOptions: options,
data: cache.data, data: cache.data,
headers: Headers.fromMap(cache.responseHeaders), headers: Headers.fromMap(cache.responseHeaders)
..set('venera-cache', 'true'),
statusCode: 200, statusCode: 200,
)); ));
} }
@@ -143,6 +132,10 @@ class NetworkCacheManager implements Interceptor {
} }
static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) { static bool compareHeaders(Map<String, dynamic> a, Map<String, dynamic> b) {
a.remove('cache-time');
a.remove('prevent-parallel');
b.remove('cache-time');
b.remove('prevent-parallel');
if (a.length != b.length) { if (a.length != b.length) {
return false; return false;
} }
@@ -160,11 +153,11 @@ class NetworkCacheManager implements Interceptor {
if (response.requestOptions.method != "GET") { if (response.requestOptions.method != "GET") {
return handler.next(response); return handler.next(response);
} }
if(response.statusCode != null && response.statusCode! >= 400){ if (response.statusCode != null && response.statusCode! >= 400) {
return handler.next(response); return handler.next(response);
} }
var size = _calculateSize(response.data); var size = _calculateSize(response.data);
if(size != null && size < 1024 * 1024 && size > 0) { if (size != null && size < 1024 * 1024 && size > 0) {
var cache = NetworkCache( var cache = NetworkCache(
uri: response.requestOptions.uri, uri: response.requestOptions.uri,
requestHeaders: response.requestOptions.headers, requestHeaders: response.requestOptions.headers,
@@ -175,30 +168,29 @@ class NetworkCacheManager implements Interceptor {
); );
setCache(cache); setCache(cache);
} }
if(preventParallel[response.requestOptions.uri] != null){
preventParallel[response.requestOptions.uri]!.complete();
preventParallel.remove(response.requestOptions.uri);
}
handler.next(response); handler.next(response);
} }
static int? _calculateSize(Object? data){ static int? _calculateSize(Object? data) {
if(data == null){ if (data == null) {
return 0; return 0;
} }
if(data is List<int>) { if (data is List<int>) {
return data.length; return data.length;
} }
if(data is String) { if (data is Uint8List) {
if(data.trim().isEmpty){ return data.length;
}
if (data is String) {
if (data.trim().isEmpty) {
return 0; return 0;
} }
if(data.length < 512 && data.contains("IP address")){ if (data.length < 512 && data.contains("IP address")) {
return 0; return 0;
} }
return data.length * 4; return data.length * 4;
} }
if(data is Map) { if (data is Map) {
return data.toString().length * 4; return data.toString().length * 4;
} }
return null; return null;

View File

@@ -1,7 +1,6 @@
import 'dart:io' as io; import 'dart:io' as io;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';

View File

@@ -76,11 +76,14 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
@override @override
ComicType get comicType => ComicType(source.key.hashCode); ComicType get comicType => ComicType(source.key.hashCode);
String? comicTitle;
ImagesDownloadTask({ ImagesDownloadTask({
required this.source, required this.source,
required this.comicId, required this.comicId,
this.comic, this.comic,
this.chapters, this.chapters,
this.comicTitle,
}); });
@override @override
@@ -90,7 +93,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
var local = LocalManager().find(id, comicType); var local = LocalManager().find(id, comicType);
if (path != null) { if (path != null) {
if (local == null) { if (local == null) {
Directory(path!).deleteIgnoreError(); Directory(path!).deleteIgnoreError(recursive: true);
} else if (chapters != null) { } else if (chapters != null) {
for (var c in chapters!) { for (var c in chapters!) {
var dir = Directory(FilePath.join(path!, c)); var dir = Directory(FilePath.join(path!, c));
@@ -357,6 +360,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} }
LocalManager().completeTask(this); LocalManager().completeTask(this);
stopRecorder();
} }
@override @override
@@ -378,7 +382,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
int get speed => currentSpeed; int get speed => currentSpeed;
@override @override
String get title => comic?.title ?? "Loading..."; String get title => comic?.title ?? comicTitle ?? "Loading...";
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@@ -534,6 +538,9 @@ class _ImageDownloadWrapper {
} }
} }
} catch (e, s) { } catch (e, s) {
if (isCancelled) {
return;
}
Log.error("Download", e.toString(), s); Log.error("Download", e.toString(), s);
retry--; retry--;
if (retry > 0) { if (retry > 0) {

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
@@ -9,8 +10,9 @@ import 'app_dio.dart';
class ImageDownloader { class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail( static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey) async* { String url, String? sourceKey,
final cacheKey = "$url@$sourceKey"; [String? cid]) async* {
final cacheKey = "$url@$sourceKey${cid != null ? '@$cid' : ''}";
final cache = await CacheManager().findCache(cacheKey); final cache = await CacheManager().findCache(cacheKey);
if (cache != null) { if (cache != null) {
@@ -33,6 +35,16 @@ class ImageDownloader {
configs['headers']['user-agent'] = webUA; configs['headers']['user-agent'] = webUA;
} }
if (((configs['url'] as String?) ?? url).startsWith('cover.') &&
sourceKey != null) {
var comicSource = ComicSource.find(sourceKey);
if(comicSource != null) {
var comicInfo = await comicSource.loadComicInfo!(cid!);
yield* loadThumbnail(comicInfo.data.cover, sourceKey);
return;
}
}
var dio = AppDio(BaseOptions( var dio = AppDio(BaseOptions(
headers: Map<String, dynamic>.from(configs['headers']), headers: Map<String, dynamic>.from(configs['headers']),
method: configs['method'] ?? 'GET', method: configs['method'] ?? 'GET',
@@ -57,8 +69,9 @@ class ImageDownloader {
} }
} }
if (configs['onResponse'] != null) { if (configs['onResponse'] is JSInvokable) {
buffer = configs['onResponse'](buffer); buffer = (configs['onResponse'] as JSInvokable)([buffer]);
(configs['onResponse'] as JSInvokable).free();
} }
await CacheManager().writeCache(cacheKey, buffer); await CacheManager().writeCache(cacheKey, buffer);
@@ -83,16 +96,33 @@ class ImageDownloader {
); );
} }
Future<Map<String, dynamic>?> Function()? onLoadFailed;
var configs = <String, dynamic>{}; var configs = <String, dynamic>{};
if (sourceKey != null) { if (sourceKey != null) {
var comicSource = ComicSource.find(sourceKey); var comicSource = ComicSource.find(sourceKey);
configs = (await comicSource!.getImageLoadingConfig configs = (await comicSource!.getImageLoadingConfig
?.call(imageKey, cid, eid)) ?? {}; ?.call(imageKey, cid, eid)) ??
{};
} }
var retryLimit = 5;
while (true) {
try {
configs['headers'] ??= { configs['headers'] ??= {
'user-agent': webUA, 'user-agent': webUA,
}; };
if (configs['onLoadFailed'] is JSInvokable) {
onLoadFailed = () async {
dynamic result = (configs['onLoadFailed'] as JSInvokable)([]);
if (result is Future) {
result = await result;
}
if (result is! Map<String, dynamic>) return null;
return result;
};
}
var dio = AppDio(BaseOptions( var dio = AppDio(BaseOptions(
headers: configs['headers'], headers: configs['headers'],
method: configs['method'] ?? 'GET', method: configs['method'] ?? 'GET',
@@ -117,8 +147,9 @@ class ImageDownloader {
} }
} }
if (configs['onResponse'] != null) { if (configs['onResponse'] is JSInvokable) {
buffer = configs['onResponse'](buffer); buffer = (configs['onResponse'] as JSInvokable)([buffer]);
(configs['onResponse'] as JSInvokable).free();
} }
var data = Uint8List.fromList(buffer); var data = Uint8List.fromList(buffer);
@@ -138,6 +169,25 @@ class ImageDownloader {
totalBytes: data.length, totalBytes: data.length,
imageBytes: data, imageBytes: data,
); );
return;
} catch (e) {
if (retryLimit < 0 || onLoadFailed == null) {
rethrow;
}
var newConfig = await onLoadFailed();
(configs['onLoadFailed'] as JSInvokable).free();
onLoadFailed = null;
if (newConfig == null) {
rethrow;
}
configs = newConfig;
retryLimit--;
} finally {
if (onLoadFailed != null) {
(configs['onLoadFailed'] as JSInvokable).free();
}
}
}
} }
} }

60
lib/pages/auth_page.dart Normal file
View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:venera/utils/translations.dart';
class AuthPage extends StatefulWidget {
const AuthPage({super.key, this.onSuccessfulAuth});
final void Function()? onSuccessfulAuth;
@override
State<AuthPage> createState() => _AuthPageState();
}
class _AuthPageState extends State<AuthPage> {
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) {
SystemNavigator.pop();
}
},
child: Material(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.security, size: 36),
const SizedBox(height: 16),
Text("Authentication Required".tl),
const SizedBox(height: 16),
FilledButton(
onPressed: auth,
child: Text("Continue".tl),
),
],
),
),
),
);
}
void auth() async {
var localAuth = LocalAuthentication();
var canCheckBiometrics = await localAuth.canCheckBiometrics;
if (!canCheckBiometrics && !await localAuth.isDeviceSupported()) {
widget.onSuccessfulAuth?.call();
return;
}
var isAuthorized = await localAuth.authenticate(
localizedReason: "Please authenticate to continue".tl,
);
if (isAuthorized) {
widget.onSuccessfulAuth?.call();
}
}
}

View File

@@ -30,8 +30,15 @@ class CategoriesPage extends StatelessWidget {
.toList(); .toList();
if(categories.isEmpty) { if(categories.isEmpty) {
var msg = "No Category Pages".tl;
msg += '\n';
if(ComicSource.isEmpty) {
msg += "Add a comic source in home page".tl;
} else {
msg += "Please check your settings".tl;
}
return NetworkError( return NetworkError(
message: "No Category Pages".tl, message: msg,
retry: () { retry: () {
controller.update(); controller.update();
}, },
@@ -248,36 +255,19 @@ class _CategoryPage extends StatelessWidget {
Widget buildTag(String tag, ClickTagCallback onClick, Widget buildTag(String tag, ClickTagCallback onClick,
[String? namespace, String? param]) { [String? namespace, String? param]) {
String translateTag(String tag) {
/*
// TODO: Implement translation
if (enableTranslation) {
if (namespace != null) {
tag = TagsTranslation.translationTagWithNamespace(tag, namespace);
} else {
tag = tag.translateTagsToCN;
}
}
*/
return tag;
}
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Builder( child: Builder(
builder: (context) { builder: (context) {
return Material( return Material(
elevation: 0.6, borderRadius: const BorderRadius.all(Radius.circular(8)),
borderRadius: const BorderRadius.all(Radius.circular(4)), color: context.colorScheme.primaryContainer.withOpacity(0.72),
color: context.colorScheme.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(4)), borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () => onClick(tag, param), onTap: () => onClick(tag, param),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(translateTag(tag)), child: Text(tag),
), ),
), ),
); );

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
@@ -282,6 +284,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
isActive: isFavorite || isAddToLocalFav, isActive: isFavorite || isAddToLocalFav,
text: 'Favorite'.tl, text: 'Favorite'.tl,
onPressed: openFavPanel, onPressed: openFavPanel,
onLongPressed: quickFavorite,
iconColor: context.useTextColor(Colors.purple), iconColor: context.useTextColor(Colors.purple),
), ),
if (comicSource.commentsLoader != null) if (comicSource.commentsLoader != null)
@@ -324,7 +327,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
} }
Widget buildDescription() { Widget buildDescription() {
if (comic.description == null) { if (comic.description == null || comic.description!.trim().isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
return SliverToBoxAdapter( return SliverToBoxAdapter(
@@ -389,6 +392,27 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
child: InkWell( child: InkWell(
borderRadius: borderRadius, borderRadius: borderRadius,
onTap: onTap, onTap: onTap,
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
onSecondaryTapDown: (details) {
showMenuX(context, details.globalPosition, [
MenuEntry(
icon: Icons.remove_red_eye,
text: "View".tl,
onClick: onTap,
),
MenuEntry(
icon: Icons.copy,
text: "Copy".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
),
]);
},
child: Text(text).padding(padding), child: Text(text).padding(padding),
), ),
); );
@@ -403,6 +427,26 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
} }
} }
String formatTime(String time) {
if (int.tryParse(time) != null) {
var t = int.tryParse(time);
if (t! > 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(t)
.toString()
.substring(0, 19);
} else {
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
.toString()
.substring(0, 19);
}
}
if (time.contains('T') || time.contains('Z')) {
var t = DateTime.parse(time);
return t.toString().substring(0, 19);
}
return time;
}
Widget buildWrap({required List<Widget> children}) { Widget buildWrap({required List<Widget> children}) {
return Wrap( return Wrap(
runSpacing: 8, runSpacing: 8,
@@ -461,14 +505,14 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildWrap( buildWrap(
children: [ children: [
buildTag(text: 'Upload Time'.tl, isTitle: true), buildTag(text: 'Upload Time'.tl, isTitle: true),
buildTag(text: comic.uploadTime!), buildTag(text: formatTime(comic.uploadTime!)),
], ],
), ),
if (comic.updateTime != null) if (comic.updateTime != null)
buildWrap( buildWrap(
children: [ children: [
buildTag(text: 'Update Time'.tl, isTitle: true), buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: comic.updateTime!), buildTag(text: formatTime(comic.updateTime!)),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -538,12 +582,22 @@ abstract mixin class _ComicPageActions {
bool isFavorite = false; bool isFavorite = false;
void openFavPanel() { FavoriteItem _toFavoriteItem() {
var tags = <String>[]; var tags = <String>[];
for (var e in comic.tags.entries) { for (var e in comic.tags.entries) {
tags.addAll(e.value.map((tag) => '${e.key}:$tag')); tags.addAll(e.value.map((tag) => '${e.key}:$tag'));
} }
return FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
);
}
void openFavPanel() {
showSideBar( showSideBar(
App.rootContext, App.rootContext,
_FavoritePanel( _FavoritePanel(
@@ -555,18 +609,25 @@ abstract mixin class _ComicPageActions {
isAddToLocalFav = local ?? isAddToLocalFav; isAddToLocalFav = local ?? isAddToLocalFav;
update(); update();
}, },
favoriteItem: FavoriteItem( favoriteItem: _toFavoriteItem(),
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
),
), ),
); );
} }
void quickFavorite() {
var folder = appdata.settings['quickFavorite'];
if (folder is! String) {
return;
}
LocalFavoritesManager().addComic(
folder,
_toFavoriteItem(),
);
isAddToLocalFav = true;
update();
App.rootContext.showMessage(message: "Added".tl);
}
void share() { void share() {
var text = comic.title; var text = comic.title;
if (comic.url != null) { if (comic.url != null) {
@@ -800,6 +861,7 @@ class _ActionButton extends StatelessWidget {
required this.icon, required this.icon,
required this.text, required this.text,
required this.onPressed, required this.onPressed,
this.onLongPressed,
this.activeIcon, this.activeIcon,
this.isActive, this.isActive,
this.isLoading, this.isLoading,
@@ -820,6 +882,8 @@ class _ActionButton extends StatelessWidget {
final Color? iconColor; final Color? iconColor;
final void Function()? onLongPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -837,6 +901,7 @@ class _ActionButton extends StatelessWidget {
onPressed(); onPressed();
} }
}, },
onLongPress: onLongPressed,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
child: IconTheme.merge( child: IconTheme.merge(
data: IconThemeData(size: 20, color: iconColor), data: IconThemeData(size: 20, color: iconColor),
@@ -998,6 +1063,8 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
String? error; String? error;
bool isLoading = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!; state = context.findAncestorStateOfType<_ComicPageState>()!;
@@ -1011,6 +1078,12 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
if (!isInitialLoading && next == null) { if (!isInitialLoading && next == null) {
return; return;
} }
if (isLoading) return;
Future.microtask(() {
setState(() {
isLoading = true;
});
});
var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next); var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next);
if (res.success) { if (res.success) {
thumbnails.addAll(res.data); thumbnails.addAll(res.data);
@@ -1019,13 +1092,15 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
} else { } else {
error = res.errorMessage; error = res.errorMessage;
} }
setState(() {}); setState(() {
isLoading = false;
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverMainAxisGroup( return MultiSliver(
slivers: [ children: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: ListTile( child: ListTile(
title: Text("Preview".tl), title: Text("Preview".tl),
@@ -1125,7 +1200,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
], ],
), ),
) )
else if (next != null || isInitialLoading) else if (isLoading)
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: ListLoadingIndicator(), child: ListLoadingIndicator(),
), ),
@@ -1576,7 +1651,9 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: selected.isEmpty ? null : () { onPressed: selected.isEmpty
? null
: () {
widget.finishSelect(selected); widget.finishSelect(selected);
context.pop(); context.pop();
}, },

View File

@@ -40,7 +40,7 @@ class ComicSourcePage extends StatefulWidget {
} }
controller?.close(); controller?.close();
if (shouldUpdate.isEmpty) { if (shouldUpdate.isEmpty) {
if(!implicit) { if (!implicit) {
App.rootContext.showMessage(message: "No Update Available".tl); App.rootContext.showMessage(message: "No Update Available".tl);
} }
return; return;
@@ -55,10 +55,10 @@ class ComicSourcePage extends StatefulWidget {
title: "Updates Available".tl, title: "Updates Available".tl,
content: msg, content: msg,
confirmText: "Update", confirmText: "Update",
onConfirm: () { onConfirm: () async {
for (var key in shouldUpdate) { for (var key in shouldUpdate) {
var source = ComicSource.find(key); var source = ComicSource.find(key);
_BodyState.update(source!); await _BodyState.update(source!);
} }
}, },
); );
@@ -95,24 +95,12 @@ class _BodyState extends State<_Body> {
return SmoothCustomScrollView( return SmoothCustomScrollView(
slivers: [ slivers: [
buildCard(context), buildCard(context),
buildSettings(),
for (var source in ComicSource.all()) buildSource(context, source), for (var source in ComicSource.all()) buildSource(context, source),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
], ],
); );
} }
Widget buildSettings() {
return SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.update_outlined),
title: Text("Check updates".tl),
onTap: () => ComicSourcePage.checkComicSourceUpdate(false),
trailing: const Icon(Icons.arrow_right),
),
);
}
Widget buildSource(BuildContext context, ComicSource source) { Widget buildSource(BuildContext context, ComicSource source) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Column( child: Column(
@@ -181,11 +169,12 @@ class _BodyState extends State<_Body> {
trailing: Select( trailing: Select(
current: (current as String).ts(source.key), current: (current as String).ts(source.key),
values: (item.value['options'] as List) values: (item.value['options'] as List)
.map<String>( .map<String>((e) =>
(e) => ((e['text'] ?? e['value']) as String).ts(source.key)) ((e['text'] ?? e['value']) as String).ts(source.key))
.toList(), .toList(),
onTap: (i) { onTap: (i) {
source.data['settings'][key] = item.value['options'][i]['value']; source.data['settings'][key] =
item.value['options'][i]['value'];
source.saveData(); source.saveData();
setState(() {}); setState(() {});
}, },
@@ -209,7 +198,8 @@ class _BodyState extends State<_Body> {
source.data['settings'][key] ?? item.value['default'] ?? ''; source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile( yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)), 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( trailing: IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () { onPressed: () {
@@ -231,8 +221,7 @@ class _BodyState extends State<_Body> {
), ),
); );
} }
} } catch (e, s) {
catch(e, s) {
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s"); Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
} }
} }
@@ -277,7 +266,7 @@ class _BodyState extends State<_Body> {
} }
} }
static void update(ComicSource source) async { static Future<void> update(ComicSource source) async {
if (!source.url.isURL) { if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config"); App.rootContext.showMessage(message: "Invalid url config");
return; return;
@@ -305,8 +294,13 @@ class _BodyState extends State<_Body> {
} }
Widget buildCard(BuildContext context) { Widget buildCard(BuildContext context) {
Widget buildButton({required Widget child, required VoidCallback onPressed}) {
return Button.normal(
onPressed: onPressed,
child: child,
).fixHeight(32);
}
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Card.outlined(
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: Column( child: Column(
@@ -321,39 +315,52 @@ class _BodyState extends State<_Body> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: "URL", hintText: "URL",
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 12),
const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton( suffix: IconButton(
onPressed: () => handleAddSource(url), onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check))), icon: const Icon(Icons.check))),
onChanged: (value) { onChanged: (value) {
url = value; url = value;
}, },
onSubmitted: handleAddSource) onSubmitted: handleAddSource,
.paddingHorizontal(16) ).paddingHorizontal(16).paddingBottom(8),
.paddingBottom(32), ListTile(
Row( title: Text("Comic Source list".tl),
children: [ trailing: buildButton(
TextButton( child: Text("View".tl),
onPressed: _selectFile, child: Text("Select file".tl))
.paddingLeft(8),
const Spacer(),
TextButton(
onPressed: () { onPressed: () {
showPopUpWidget( showPopUpWidget(
App.rootContext, _ComicSourceList(handleAddSource)); App.rootContext,
_ComicSourceList(handleAddSource),
);
}, },
child: Text("View list".tl)), ),
const Spacer(), ),
TextButton(onPressed: help, child: Text("Open help".tl)) ListTile(
.paddingRight(8), title: Text("Use a config file".tl),
], trailing: buildButton(
onPressed: _selectFile,
child: Text("Select".tl),
),
),
ListTile(
title: Text("Help".tl),
trailing: buildButton(
onPressed: help,
child: Text("Open".tl),
),
),
ListTile(
title: Text("Check updates".tl),
trailing: buildButton(
onPressed: () => ComicSourcePage.checkComicSourceUpdate(false),
child: Text("Check".tl),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
), ),
), ),
).paddingHorizontal(12),
); );
} }
@@ -372,8 +379,7 @@ class _BodyState extends State<_Body> {
} }
void help() { void help() {
launchUrlString( launchUrlString("https://github.com/venera-app/venera-configs");
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
} }
Future<void> handleAddSource(String url) async { Future<void> handleAddSource(String url) async {

View File

@@ -1,8 +1,14 @@
import 'dart:collection';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
class CommentsPage extends StatefulWidget { class CommentsPage extends StatefulWidget {
@@ -268,7 +274,10 @@ class _CommentTileState extends State<_CommentTile> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(widget.comment.userName, style: ts.bold,), Text(
widget.comment.userName,
style: ts.bold,
),
if (widget.comment.time != null) if (widget.comment.time != null)
Text(widget.comment.time!, style: ts.s12), Text(widget.comment.time!, style: ts.s12),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -426,7 +435,7 @@ class _CommentTileState extends State<_CommentTile> {
isCancel, isCancel,
); );
if (res.success) { if (res.success) {
if(isCancel) { if (isCancel) {
voteStatus = 0; voteStatus = 0;
} else { } else {
if (isUp) { if (isUp) {
@@ -498,6 +507,287 @@ class _CommentContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!text.contains('<') && !text.contains('http')) {
return SelectableText(text); return SelectableText(text);
} else {
return _RichCommentContent(text: text);
}
}
}
class _Tag {
final String name;
final Map<String, String> attributes;
const _Tag(this.name, this.attributes);
TextSpan merge(TextSpan s, BuildContext context) {
var style = s.style ?? ts;
style = switch (name) {
'b' => style.bold,
'i' => style.italic,
'u' => style.underline,
's' => style.lineThrough,
'a' => style.withColor(context.colorScheme.primary),
'span' => () {
if (attributes.containsKey('style')) {
var s = attributes['style']!;
var css = s.split(';');
for (var c in css) {
var kv = c.split(':');
if (kv.length == 2) {
var key = kv[0].trim();
var value = kv[1].trim();
switch (key) {
case 'color':
// Color is not supported, we should make text display well in light and dark mode.
break;
case 'font-weight':
if (value == 'bold') {
style = style.bold;
} else if (value == 'lighter') {
style = style.light;
}
break;
case 'font-style':
if (value == 'italic') {
style = style.italic;
}
break;
case 'text-decoration':
if (value == 'underline') {
style = style.underline;
} else if (value == 'line-through') {
style = style.lineThrough;
}
break;
case 'font-size':
// Font size is not supported.
break;
}
}
}
}
return style;
}(),
_ => style,
};
if (style.color != null) {
style = style.copyWith(decorationColor: style.color);
}
var recognizer = s.recognizer;
if (name == 'a') {
var link = attributes['href'];
if (link != null && link.isURL) {
recognizer = TapGestureRecognizer()
..onTap = () {
handleLink(link);
};
}
}
return TextSpan(
text: s.text,
style: style,
recognizer: recognizer,
);
}
static void handleLink(String link) async {
if (link.isURL) {
if (await handleAppLink(Uri.parse(link))) {
App.rootContext.pop();
} else {
launchUrlString(link);
}
}
}
}
class _CommentImage {
final String url;
final String? link;
const _CommentImage(this.url, this.link);
}
class _RichCommentContent extends StatefulWidget {
const _RichCommentContent({required this.text});
final String text;
@override
State<_RichCommentContent> createState() => _RichCommentContentState();
}
class _RichCommentContentState extends State<_RichCommentContent> {
var textSpan = <InlineSpan>[];
var images = <_CommentImage>[];
@override
void didChangeDependencies() {
render();
super.didChangeDependencies();
}
bool isValidUrlChar(String char) {
return RegExp(r'[a-zA-Z0-9%:/.@\-_?&=#*!+;]').hasMatch(char);
}
void render() {
var s = Queue<_Tag>();
int i = 0;
var buffer = StringBuffer();
var text = widget.text;
void writeBuffer() {
if (buffer.isEmpty) return;
var span = TextSpan(text: buffer.toString());
for (var tag in s) {
span = tag.merge(span, context);
}
textSpan.add(span);
buffer.clear();
}
while (i < text.length) {
if (text[i] == '<' && i != text.length - 1) {
if (text[i + 1] != '/') {
// start tag
var j = text.indexOf('>', i);
if (j != -1) {
var tagContent = text.substring(i + 1, j);
var splits = tagContent.split(' ');
splits.removeWhere((element) => element.isEmpty);
var tagName = splits[0];
var attributes = <String, String>{};
for (var k = 1; k < splits.length; k++) {
var attr = splits[k];
var attrSplits = attr.split('=');
if (attrSplits.length == 2) {
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', '');
}
}
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span'];
if (acceptedTags.contains(tagName)) {
writeBuffer();
if (tagName == 'img') {
var url = attributes['src'];
String? link;
for (var tag in s) {
if (tag.name == 'a') {
link = tag.attributes['href'];
break;
}
}
if (url != null) {
images.add(_CommentImage(url, link));
}
} else if (tagName == 'br') {
buffer.write('\n');
} else {
s.add(_Tag(tagName, attributes));
}
i = j + 1;
continue;
}
}
} else {
// end tag
var j = text.indexOf('>', i);
if (j != -1) {
var tagContent = text.substring(i + 2, j);
var splits = tagContent.split(' ');
splits.removeWhere((element) => element.isEmpty);
var tagName = splits[0];
if (s.isNotEmpty && s.last.name == tagName) {
writeBuffer();
s.removeLast();
i = j + 1;
continue;
}
if (tagName == 'br') {
i = j + 1;
buffer.write('\n');
continue;
}
}
}
} else if (text.length - i > 8 &&
text.substring(i, i + 4) == 'http' &&
!s.any((e) => e.name == 'a')) {
// auto link
int j = i;
for (; j < text.length; j++) {
if (!isValidUrlChar(text[j])) {
break;
}
}
var url = text.substring(i, j);
if (url.isURL) {
writeBuffer();
textSpan.add(TextSpan(
text: url,
style: ts.withColor(context.colorScheme.primary),
recognizer: TapGestureRecognizer()
..onTap = () {
_Tag.handleLink(url);
},
));
i = j;
continue;
}
}
buffer.write(text[i]);
i++;
}
writeBuffer();
}
@override
Widget build(BuildContext context) {
Widget content = SelectableText.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: textSpan,
),
);
if (images.isNotEmpty) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
content,
Wrap(
runSpacing: 4,
spacing: 4,
children: images.map((e) {
Widget image = Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainerLow,
),
width: 100,
height: 100,
child: Image(
width: 100,
height: 100,
image: CachedImageProvider(e.url),
),
);
if (e.link != null) {
image = InkWell(
onTap: () {
_Tag.handleLink(e.link!);
},
child: image,
);
}
return image;
}).toList(),
)
],
);
}
return content;
} }
} }

View File

@@ -27,8 +27,10 @@ class _DownloadingPageState extends State<DownloadingPage> {
} }
void update() { void update() {
if(mounted) {
setState(() {}); setState(() {});
} }
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@@ -93,8 +93,15 @@ class _ExplorePageState extends State<ExplorePage>
Widget buildBody(String i) => _SingleExplorePage(i, key: Key(i)); Widget buildBody(String i) => _SingleExplorePage(i, key: Key(i));
Widget buildEmpty() { Widget buildEmpty() {
var msg = "No Explore Pages".tl;
msg += '\n';
if(ComicSource.isEmpty) {
msg += "Add a comic source in home page".tl;
} else {
msg += "Please check your settings".tl;
}
return NetworkError( return NetworkError(
message: "No Explore Pages".tl, message: msg,
retry: () { retry: () {
setState(() { setState(() {
pages = ComicSource.all() pages = ComicSource.all()

View File

@@ -6,7 +6,6 @@ Future<void> newFolder() async {
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {
var controller = TextEditingController(); var controller = TextEditingController();
var folders = LocalFavoritesManager().folderNames;
String? error; String? error;
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
@@ -35,12 +34,11 @@ Future<void> newFolder() async {
child: Text("Import from file".tl), child: Text("Import from file".tl),
onPressed: () async { onPressed: () async {
var file = await selectFile(ext: ['json']); var file = await selectFile(ext: ['json']);
if(file == null) return; if (file == null) return;
var data = await file.readAsBytes(); var data = await file.readAsBytes();
try { try {
LocalFavoritesManager().fromJson(utf8.decode(data)); LocalFavoritesManager().fromJson(utf8.decode(data));
} } catch (e) {
catch(e) {
context.showMessage(message: "Failed to import".tl); context.showMessage(message: "Failed to import".tl);
return; return;
} }
@@ -114,7 +112,9 @@ void addFavorite(Comic comic) {
name: comic.title, name: comic.title,
coverPath: comic.cover, coverPath: comic.cover,
author: comic.subtitle ?? '', author: comic.subtitle ?? '',
type: ComicType((comic.sourceKey == 'local' ? 0 : comic.sourceKey.hashCode)), type: ComicType((comic.sourceKey == 'local'
? 0
: comic.sourceKey.hashCode)),
tags: comic.tags ?? [], tags: comic.tags ?? [],
), ),
); );
@@ -129,3 +129,337 @@ void addFavorite(Comic comic) {
}, },
); );
} }
Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var comics = LocalFavoritesManager().getAllComics(folder);
Future<void> updateSingleComic(int index) async {
int retry = 3;
while (true) {
try {
var c = comics[index];
var comicSource = c.type.comicSource;
if (comicSource == null) return;
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
comics[index] = FavoriteItem(
id: c.id,
name: newInfo.title,
coverPath: newInfo.cover,
author: newInfo.subTitle ??
newInfo.tags['author']?.firstOrNull ??
c.author,
type: c.type,
tags: c.tags,
);
LocalFavoritesManager().updateInfo(folder, comics[index]);
return;
} catch (e) {
retry--;
if (retry == 0) {
rethrow;
}
continue;
}
}
}
var finished = ValueNotifier(0);
var errors = 0;
var index = 0;
bool isCanceled = false;
showDialog(
context: App.rootContext,
builder: (context) {
return ValueListenableBuilder(
valueListenable: finished,
builder: (context, value, child) {
var isFinished = value == comics.length;
return ContentDialog(
title: isFinished ? "Finished".tl : "Updating".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
LinearProgressIndicator(
value: value / comics.length,
),
const SizedBox(height: 4),
Text("$value/${comics.length}"),
const SizedBox(height: 4),
if (errors > 0) Text("Errors: $errors"),
],
).paddingHorizontal(16),
actions: [
Button.filled(
color: isFinished ? null : context.colorScheme.error,
onPressed: () {
isCanceled = true;
context.pop();
},
child: isFinished ? Text("OK".tl) : Text("Cancel".tl),
),
],
);
},
);
},
).then((_) {
isCanceled = true;
});
while (index < comics.length) {
var futures = <Future>[];
const maxConcurrency = 4;
if (isCanceled) {
return comics;
}
for (var i = 0; i < maxConcurrency; i++) {
if (index + i >= comics.length) break;
futures.add(updateSingleComic(index + i).then((v) {
finished.value++;
}, onError: (_) {
errors++;
finished.value++;
}));
}
await Future.wait(futures);
index += maxConcurrency;
}
return comics;
}
Future<void> sortFolders() async {
var folders = LocalFavoritesManager().folderNames;
await showPopUpWidget(
App.rootContext,
StatefulBuilder(builder: (context, setState) {
return PopUpWidgetScaffold(
title: "Sort".tl,
tailing: [
Tooltip(
message: "Help".tl,
child: IconButton(
icon: const Icon(Icons.help_outline),
onPressed: () {
showInfoDialog(
context: context,
title: "Reorder".tl,
content: "Long press and drag to reorder.".tl,
);
},
),
)
],
body: ReorderableListView.builder(
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex--;
}
setState(() {
var item = folders.removeAt(oldIndex);
folders.insert(newIndex, item);
});
},
itemCount: folders.length,
itemBuilder: (context, index) {
return ListTile(
key: ValueKey(folders[index]),
title: Text(folders[index]),
);
},
),
);
}),
);
LocalFavoritesManager().updateOrder(folders);
}
Future<void> importNetworkFolder(
String source,
String? folder,
String? folderID,
) async {
var comicSource = ComicSource.find(source);
if (comicSource == null) {
return;
}
if(folder != null && folder.isEmpty) {
folder = null;
}
var resultName = folder ?? comicSource.name;
var exists = LocalFavoritesManager().existsFolder(resultName);
if (exists) {
if (!LocalFavoritesManager()
.isLinkedToNetworkFolder(resultName, source, folderID ?? "")) {
App.rootContext.showMessage(message: "Folder already exists".tl);
return;
}
}
if(!exists) {
LocalFavoritesManager().createFolder(resultName);
LocalFavoritesManager().linkFolderToNetwork(
resultName,
source,
folderID ?? "",
);
}
var current = 0;
var isFinished = false;
String? next;
Future<void> fetchNext() async {
var retry = 3;
while (true) {
try {
if (comicSource.favoriteData?.loadComic != null) {
next ??= '1';
var page = int.parse(next!);
var res = await comicSource.favoriteData!.loadComic!(page, folderID);
var count = 0;
for (var c in res.data) {
var result = LocalFavoritesManager().addComic(
resultName,
FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
),
);
if (result) {
count++;
}
}
current += count;
if (res.data.isEmpty || res.subData == page) {
isFinished = true;
next = null;
} else {
next = (page + 1).toString();
}
} else if (comicSource.favoriteData?.loadNext != null) {
var res = await comicSource.favoriteData!.loadNext!(next, folderID);
var count = 0;
for (var c in res.data) {
var result = LocalFavoritesManager().addComic(
resultName,
FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
),
);
if (result) {
count++;
}
}
current += count;
if (res.data.isEmpty || res.subData == null) {
isFinished = true;
next = null;
} else {
next = res.subData;
}
} else {
throw "Unsupported source";
}
return;
} catch (e) {
retry--;
if (retry == 0) {
rethrow;
}
continue;
}
}
}
bool isCanceled = false;
String? errorMsg;
bool isErrored() => errorMsg != null;
void Function()? updateDialog;
showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
updateDialog = () => setState(() {});
return ContentDialog(
title: isFinished
? "Finished".tl
: isErrored()
? "Error".tl
: "Importing".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
LinearProgressIndicator(
value: isFinished ? 1 : null,
),
const SizedBox(height: 4),
Text("Imported @c comics".tlParams({
"c": current,
})),
const SizedBox(height: 4),
if (isErrored()) Text("Error: $errorMsg"),
],
).paddingHorizontal(16),
actions: [
Button.filled(
color: (isFinished || isErrored())
? null
: context.colorScheme.error,
onPressed: () {
isCanceled = true;
context.pop();
},
child: (isFinished || isErrored())
? Text("OK".tl)
: Text("Cancel".tl),
),
],
);
},
);
},
).then((_) {
isCanceled = true;
});
while (!isFinished && !isCanceled) {
try {
await fetchNext();
updateDialog?.call();
} catch (e) {
errorMsg = e.toString();
updateDialog?.call();
break;
}
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
@@ -9,7 +10,9 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -152,7 +155,8 @@ class _FavoritesPageState extends State<FavoritesPage> {
} else { } else {
var favoriteData = getFavoriteDataOrNull(folder!); var favoriteData = getFavoriteDataOrNull(folder!);
if (favoriteData == null) { if (favoriteData == null) {
return const Center(child: Text("Unknown source")); folder = null;
return buildBody();
} else { } else {
return NetworkFavoritePage(favoriteData, key: Key(folder!)); return NetworkFavoritePage(favoriteData, key: Key(folder!));
} }

View File

@@ -14,11 +14,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
late List<FavoriteItem> comics; late List<FavoriteItem> comics;
String? networkSource;
String? networkFolder;
void updateComics() { void updateComics() {
print(comics.length);
setState(() { setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder); comics = LocalFavoritesManager().getAllComics(widget.folder);
print(comics.length);
}); });
} }
@@ -26,6 +27,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
void initState() { void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
comics = LocalFavoritesManager().getAllComics(widget.folder); comics = LocalFavoritesManager().getAllComics(widget.folder);
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a;
networkFolder = b;
super.initState(); super.initState();
} }
@@ -51,6 +55,51 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
child: Text(favPage.folder ?? "Unselected".tl), child: Text(favPage.folder ?? "Unselected".tl),
), ),
actions: [ actions: [
if (networkSource != null)
Tooltip(
message: "Sync".tl,
child: Flyout(
flyoutBuilder: (context) {
var sourceName = ComicSource.find(networkSource!)?.name ??
networkSource!;
var text = "The folder is Linked to @source".tlParams({
"source": sourceName,
});
if(networkFolder != null && networkFolder!.isNotEmpty) {
text += "\n${"Source Folder".tl}: $networkFolder";
}
return FlyoutContent(
title: "Sync".tl,
content: Text(text),
actions: [
Button.filled(
child: Text("Update".tl),
onPressed: () {
context.pop();
importNetworkFolder(
networkSource!,
widget.folder,
networkFolder!,
).then(
(value) {
updateComics();
},
);
},
),
],
);
},
child: Builder(builder: (context) {
return IconButton(
icon: const Icon(Icons.sync),
onPressed: () {
Flyout.of(context).show();
},
);
}),
),
),
MenuButton( MenuButton(
entries: [ entries: [
MenuEntry( MenuEntry(
@@ -107,7 +156,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}, },
).then( ).then(
(value) { (value) {
if (mounted) {
setState(() {}); setState(() {});
}
}, },
); );
}), }),
@@ -123,6 +174,45 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
filename: "${widget.folder}.json", filename: "${widget.folder}.json",
); );
}), }),
MenuEntry(
icon: Icons.update,
text: "Update Comics Info".tl,
onClick: () {
updateComicsInfo(widget.folder).then((newComics) {
if (mounted) {
setState(() {
comics = newComics;
});
}
});
}),
MenuEntry(
icon: Icons.download,
text: "Download All".tl,
onClick: () async {
int count = 0;
for (var c in comics) {
if (await LocalManager().isDownloaded(c.id, c.type)) {
continue;
}
var comicSource = c.type.comicSource;
if (comicSource == null) {
continue;
}
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: c.id,
comic: null,
comicTitle: c.name,
));
count++;
}
context.showMessage(
message: "Added @count comics to download queue."
.tlParams({
"count": count.toString(),
}));
}),
], ],
), ),
], ],
@@ -199,6 +289,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
var comicSource = e.type.comicSource; var comicSource = e.type.comicSource;
return ComicTile( return ComicTile(
key: Key(e.hashCode.toString()), key: Key(e.hashCode.toString()),
enableLongPressed: false,
comic: Comic( comic: Comic(
e.name, e.name,
e.coverPath, e.coverPath,
@@ -206,7 +297,8 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
e.author, e.author,
e.tags, e.tags,
"${e.time} | ${comicSource?.name ?? "Unknown"}", "${e.time} | ${comicSource?.name ?? "Unknown"}",
comicSource?.key ?? "Unknown", comicSource?.key ??
(e.type == ComicType.local ? "local" : "Unknown"),
null, null,
null, null,
), ),

View File

@@ -108,6 +108,17 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
onTap: context.width < _kTwoPanelChangeWidth ? showFolders : null, onTap: context.width < _kTwoPanelChangeWidth ? showFolders : null,
child: Text(widget.data.title), child: Text(widget.data.title),
), ),
actions: [
MenuButton(entries: [
MenuEntry(
icon: Icons.sync,
text: "Convert to local".tl,
onClick: () {
importNetworkFolder(widget.data.key, null, null);
},
)
]),
],
), ),
errorLeading: Appbar( errorLeading: Appbar(
leading: Tooltip( leading: Tooltip(
@@ -533,6 +544,17 @@ class _FavoriteFolder extends StatelessWidget {
key: comicListKey, key: comicListKey,
leadingSliver: SliverAppbar( leadingSliver: SliverAppbar(
title: Text(title), title: Text(title),
actions: [
MenuButton(entries: [
MenuEntry(
icon: Icons.sync,
text: "Convert to local".tl,
onClick: () {
importNetworkFolder(data.key, title, folderID);
},
)
]),
],
), ),
errorLeading: Appbar( errorLeading: Appbar(
title: Text(title), title: Text(title),

View File

@@ -80,7 +80,6 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 16),
Icon( Icon(
Icons.local_activity, Icons.local_activity,
color: context.colorScheme.secondary, color: context.colorScheme.secondary,
@@ -88,17 +87,19 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
const SizedBox(width: 12), const SizedBox(width: 12),
Text("Local".tl), Text("Local".tl),
const Spacer(), const Spacer(),
IconButton( MenuButton(
icon: const Icon(Icons.search), entries: [
color: context.colorScheme.primary, MenuEntry(
onPressed: () { icon: Icons.search,
text: 'Search'.tl,
onClick: () {
context.to(() => const LocalSearchPage()); context.to(() => const LocalSearchPage());
}, },
), ),
IconButton( MenuEntry(
icon: const Icon(Icons.add), icon: Icons.add,
color: context.colorScheme.primary, text: 'Create Folder'.tl,
onPressed: () { onClick: () {
newFolder().then((value) { newFolder().then((value) {
setState(() { setState(() {
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
@@ -106,9 +107,21 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
}); });
}, },
), ),
const SizedBox(width: 16), MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
], ],
), ),
],
).paddingHorizontal(16),
); );
} }
index--; index--;
@@ -219,13 +232,13 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
@override @override
void update() { void update() {
if(!mounted) return; if (!mounted) return;
setState(() {}); setState(() {});
} }
@override @override
void updateFolders() { void updateFolders() {
if(!mounted) return; if (!mounted) return;
setState(() { setState(() {
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all() networkFolders = ComicSource.all()

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -17,9 +18,12 @@ import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_page.dart'; import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/search_page.dart'; import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/cbz.dart'; import 'package:venera/utils/cbz.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:sqlite3/sqlite3.dart' as sql;
import 'dart:math';
import 'local_comics_page.dart'; import 'local_comics_page.dart';
@@ -32,6 +36,7 @@ class HomePage extends StatelessWidget {
slivers: [ slivers: [
SliverPadding(padding: EdgeInsets.only(top: context.padding.top)), SliverPadding(padding: EdgeInsets.only(top: context.padding.top)),
const _SearchBar(), const _SearchBar(),
const _SyncDataWidget(),
const _History(), const _History(),
const _Local(), const _Local(),
const _ComicSourceWidget(), const _ComicSourceWidget(),
@@ -77,6 +82,113 @@ class _SearchBar extends StatelessWidget {
} }
} }
class _SyncDataWidget extends StatefulWidget {
const _SyncDataWidget();
@override
State<_SyncDataWidget> createState() => _SyncDataWidgetState();
}
class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
DataSync().addListener(update);
WidgetsBinding.instance.addObserver(this);
lastCheck = DateTime.now();
}
void update() {
if(mounted) {
setState(() {});
}
}
@override
void dispose() {
super.dispose();
DataSync().removeListener(update);
WidgetsBinding.instance.removeObserver(this);
}
late DateTime lastCheck;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if(state == AppLifecycleState.resumed) {
if(DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) {
lastCheck = DateTime.now();
DataSync().downloadData();
}
}
}
@override
Widget build(BuildContext context) {
Widget child;
if(!DataSync().isEnabled) {
child = const SliverPadding(padding: EdgeInsets.zero);
} else if (DataSync().isUploading || DataSync().isDownloading) {
child = SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(Icons.sync),
title: Text('Syncing Data'.tl),
trailing: const CircularProgressIndicator(strokeWidth: 2)
.fixWidth(18)
.fixHeight(18),
),
),
);
} else {
child = SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(Icons.sync),
title: Text('Sync Data'.tl),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}
),
IconButton(
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}
),
],
),
),
),
);
}
return SliverAnimatedPaintExtent(
duration: const Duration(milliseconds: 200),
child: child,
);
}
}
class _History extends StatefulWidget { class _History extends StatefulWidget {
const _History(); const _History();
@@ -162,6 +274,7 @@ class _HistoryState extends State<_History> {
ImageProvider imageProvider = CachedImageProvider( ImageProvider imageProvider = CachedImageProvider(
cover, cover,
sourceKey: history[index].type.comicSource?.key, sourceKey: history[index].type.comicSource?.key,
cid: history[index].id,
); );
if (!cover.isURL) { if (!cover.isURL) {
var localComic = LocalManager().find( var localComic = LocalManager().find(
@@ -401,7 +514,14 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
"Select a directory which contains the comic files.".tl, "Select a directory which contains the comic files.".tl,
"Select a directory which contains the comic directories.".tl, "Select a directory which contains the comic directories.".tl,
"Select a cbz file.".tl, "Select a cbz file.".tl,
"Select an EhViewer database and a download folder.".tl
][type]; ][type];
List<String> importMethods = [
"Single Comic".tl,
"Multiple Comics".tl,
"A cbz file".tl,
"EhViewer downloads".tl
];
return ContentDialog( return ContentDialog(
dismissible: !loading, dismissible: !loading,
@@ -419,36 +539,18 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 600), const SizedBox(width: 600),
RadioListTile( ...List.generate(importMethods.length, (index) {
title: Text("Single Comic".tl), return RadioListTile(
value: 0, title: Text(importMethods[index]),
value: index,
groupValue: type, groupValue: type,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
type = value as int; type = value as int;
}); });
}, },
), );
RadioListTile( }),
title: Text("Multiple Comics".tl),
value: 1,
groupValue: type,
onChanged: (value) {
setState(() {
type = value as int;
});
},
),
RadioListTile(
title: Text("A cbz file".tl),
value: 2,
groupValue: type,
onChanged: (value) {
setState(() {
type = value as int;
});
},
),
ListTile( ListTile(
title: Text("Add to favorites".tl), title: Text("Add to favorites".tl),
trailing: Select( trailing: Select(
@@ -482,7 +584,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
onPressed: () { onPressed: () {
showDialog( showDialog(
context: context, context: context,
barrierColor: Colors.transparent, barrierColor: Colors.black.withOpacity(0.2),
builder: (context) { builder: (context) {
var help = ''; var help = '';
help += help +=
@@ -493,8 +595,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
help += help +=
'${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n'; '${"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used.".tl}\n\n';
help += help +=
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles." "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n"
.tl; .tl;
help +="If you import an EhViewer's database, program will automatically create folders according to the download label in that database.".tl;
return ContentDialog( return ContentDialog(
title: "Help".tl, title: "Help".tl,
content: Text(help).paddingHorizontal(16), content: Text(help).paddingHorizontal(16),
@@ -529,7 +632,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
await xFile!.saveTo(cache); await xFile!.saveTo(cache);
var comic = await CBZ.import(File(cache)); var comic = await CBZ.import(File(cache));
if (selectedFolder != null) { if (selectedFolder != null) {
LocalFavoritesManager().addComic(selectedFolder!, FavoriteItem( LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id, id: comic.id,
name: comic.title, name: comic.title,
coverPath: comic.cover, coverPath: comic.cover,
@@ -545,6 +650,135 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
} }
controller.close(); controller.close();
return; return;
} else if (type == 3) {
var dbFile = await selectFile(ext: ['db']);
final picker = DirectoryPicker();
final comicSrc = await picker.pickDirectory();
if (dbFile == null || comicSrc == null) {
return;
}
bool cancelled = false;
var controller = showLoadingDialog(context, onCancel: () { cancelled = true; });
try {
var cache = FilePath.join(App.cachePath, dbFile.name);
await dbFile.saveTo(cache);
var db = sql.sqlite3.open(cache);
Future<void> addTagComics(String destFolder, List<sql.Row> comics) async {
for(var comic in comics) {
if(cancelled) {
return;
}
var comicDir = Directory(FilePath.join(comicSrc.path, comic['DIRNAME'] as String));
if(!(await comicDir.exists())) {
continue;
}
String titleJP = comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String;
String title = titleJP == "" ? comic['TITLE'] as String : titleJP;
if (LocalManager().findByName(title) != null) {
Log.info("Import Comic", "Comic already exists: $title");
continue;
}
String coverURL = await comicDir.joinFile(".thumb").exists() ?
comicDir.joinFile(".thumb").path :
(comic['THUMB'] as String).replaceAll('s.exhentai.org', 'ehgt.org');
int downloadedTimeStamp = comic['TIME'] as int;
DateTime downloadedTime =
downloadedTimeStamp != 0 ?
DateTime.fromMillisecondsSinceEpoch(downloadedTimeStamp) : DateTime.now();
var comicObj = LocalComic(
id: LocalManager().findValidId(ComicType.local),
title: title,
subtitle: '',
tags: [
//1 >> x
[
"MISC",
"DOUJINSHI",
"MANGA",
"ARTISTCG",
"GAMECG",
"IMAGE SET",
"COSPLAY",
"ASIAN PORN",
"NON-H",
"WESTERN",
][(log(comic['CATEGORY'] as int) / ln2).floor()]
],
directory: comicDir.path,
chapters: null,
cover: coverURL,
comicType: ComicType.local,
downloadedChapters: [],
createdAt: downloadedTime,
);
LocalManager().add(comicObj, comicObj.id);
LocalFavoritesManager().addComic(
destFolder,
FavoriteItem(
id: comicObj.id,
name: comicObj.title,
coverPath: comicObj.cover,
author: comicObj.subtitle,
type: comicObj.comicType,
tags: comicObj.tags,
favoriteTime: downloadedTime
),
);
}
}
//default folder
{
var defaultFolderName = '(EhViewer)Default'.tl;
if(!LocalFavoritesManager().existsFolder(defaultFolderName)) {
LocalFavoritesManager().createFolder(defaultFolderName);
}
var comicList = db.select("""
SELECT *
FROM DOWNLOAD_DIRNAME DN
LEFT JOIN DOWNLOADS DL
ON DL.GID = DN.GID
WHERE DL.LABEL IS NULL AND DL.STATE = 3
ORDER BY DL.TIME DESC
""").toList();
await addTagComics(defaultFolderName, comicList);
}
var folders = db.select("""
SELECT * FROM DOWNLOAD_LABELS;
""");
for (var folder in folders) {
if(cancelled) {
break;
}
var label = folder["LABEL"] as String;
var folderName = '(EhViewer)$label';
if(!LocalFavoritesManager().existsFolder(folderName)) {
LocalFavoritesManager().createFolder(folderName);
}
var comicList = db.select("""
SELECT *
FROM DOWNLOAD_DIRNAME DN
LEFT JOIN DOWNLOADS DL
ON DL.GID = DN.GID
WHERE DL.LABEL = ? AND DL.STATE = 3
ORDER BY DL.TIME DESC
""", [label]).toList();
await addTagComics(folderName, comicList);
}
db.dispose();
await File(cache).deleteIgnoreError();
} catch (e, s) {
Log.error("Import Comic", e.toString(), s);
context.showMessage(message: e.toString());
}
controller.close();
return;
} }
height = key.currentContext!.size!.height; height = key.currentContext!.size!.height;
setState(() { setState(() {
@@ -610,7 +844,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
for (var comic in comics.values) { for (var comic in comics.values) {
LocalManager().add(comic, LocalManager().findValidId(ComicType.local)); LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
if (selectedFolder != null) { if (selectedFolder != null) {
LocalFavoritesManager().addComic(selectedFolder!, FavoriteItem( LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id, id: comic.id,
name: comic.title, name: comic.title,
coverPath: comic.cover, coverPath: comic.cover,

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/downloading_page.dart';
import 'package:venera/utils/cbz.dart'; import 'package:venera/utils/cbz.dart';
@@ -24,8 +25,12 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
bool searchMode = false; bool searchMode = false;
bool multiSelectMode = false;
Map<Comic, bool> selectedComics = {};
void update() { void update() {
if(keyword.isEmpty) { if (keyword.isEmpty) {
setState(() { setState(() {
comics = LocalManager().getComics(sortType); comics = LocalManager().getComics(sortType);
}); });
@@ -95,8 +100,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
actions: [ actions: [
FilledButton( FilledButton(
onPressed: () { onPressed: () {
appdata.implicitData["local_sort"] = appdata.implicitData["local_sort"] = sortType.value;
sortType.value;
appdata.writeImplicitData(); appdata.writeImplicitData();
Navigator.pop(context); Navigator.pop(context);
update(); update();
@@ -112,10 +116,69 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( void selectAll() {
setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
});
}
void deSelect() {
setState(() {
selectedComics.clear();
});
}
void invertSelection() {
setState(() {
comics.asMap().forEach((k, v) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
});
selectedComics.removeWhere((k, v) => !v);
});
}
void selectRange() {
setState(() {
List<int> l = [];
selectedComics.forEach((k, v) {
l.add(comics.indexOf(k as LocalComic));
});
if (l.isEmpty) {
return;
}
l.sort();
int start = l.first;
int end = l.last;
selectedComics.clear();
selectedComics.addEntries(List.generate(end - start + 1, (i) {
return MapEntry(comics[start + i], true);
}));
});
}
List<Widget> selectActions = [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: "Select All".tl,
onPressed: selectAll),
IconButton(
icon: const Icon(Icons.deselect),
tooltip: "Deselect".tl,
onPressed: deSelect),
IconButton(
icon: const Icon(Icons.flip),
tooltip: "Invert Selection".tl,
onPressed: invertSelection),
IconButton(
icon: const Icon(Icons.border_horizontal_outlined),
tooltip: "Select in range".tl,
onPressed: selectRange),
];
var body = Scaffold(
body: SmoothCustomScrollView( body: SmoothCustomScrollView(
slivers: [ slivers: [
if(!searchMode) if (!searchMode && !multiSelectMode)
SliverAppbar( SliverAppbar(
title: Text("Local".tl), title: Text("Local".tl),
actions: [ actions: [
@@ -145,11 +208,55 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
showPopUpWidget(context, const DownloadingPage()); showPopUpWidget(context, const DownloadingPage());
}, },
), ),
) ),
Tooltip(
message: multiSelectMode
? "Exit Multi-Select".tl
: "Multi-Select".tl,
child: IconButton(
icon: const Icon(Icons.checklist),
onPressed: () {
setState(() {
multiSelectMode = !multiSelectMode;
});
},
),
),
], ],
) )
else else if (multiSelectMode)
SliverAppbar( SliverAppbar(
leading: Tooltip(
message: "Cancel".tl,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
},
),
),
title: Text(
"Selected @c comics".tlParams({"c": selectedComics.length})),
actions: selectActions,
)
else if (searchMode)
SliverAppbar(
leading: Tooltip(
message: "Cancel".tl,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
searchMode = false;
keyword = "";
update();
});
},
),
),
title: TextField( title: TextField(
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
@@ -161,22 +268,21 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
update(); update();
}, },
), ),
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
searchMode = false;
keyword = "";
update();
});
},
),
],
), ),
SliverGridComics( SliverGridComics(
comics: comics, comics: comics,
onTap: (c) { selections: selectedComics,
onTap: multiSelectMode
? (c) {
setState(() {
if (selectedComics.containsKey(c as LocalComic)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
});
}
: (c) {
(c as LocalComic).read(); (c as LocalComic).read();
}, },
menuBuilder: (c) { menuBuilder: (c) {
@@ -185,7 +291,55 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
icon: Icons.delete, icon: Icons.delete,
text: "Delete".tl, text: "Delete".tl,
onClick: () { onClick: () {
LocalManager().deleteComic(c as LocalComic); showDialog(
context: context,
builder: (context) {
bool removeComicFile = true;
return StatefulBuilder(builder: (context, state) {
return ContentDialog(
title: "Delete".tl,
content: Column(
children: [
Text("Delete selected comics?".tl)
.paddingVertical(8),
Transform.scale(
scale: 0.9,
child: CheckboxListTile(
title: Text(
"Also remove files on disk".tl),
value: removeComicFile,
onChanged: (v) {
state(() {
removeComicFile =
!removeComicFile;
});
})),
],
).paddingHorizontal(16).paddingVertical(8),
actions: [
FilledButton(
onPressed: () {
context.pop();
if (multiSelectMode) {
for (var comic in selectedComics.keys) {
LocalManager().deleteComic(
comic as LocalComic,
removeComicFile);
}
setState(() {
selectedComics.clear();
});
} else {
LocalManager().deleteComic(
c as LocalComic, removeComicFile);
}
},
child: Text("Confirm".tl),
),
],
);
});
});
}), }),
MenuEntry( MenuEntry(
icon: Icons.outbox_outlined, icon: Icons.outbox_outlined,
@@ -196,9 +350,20 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
allowCancel: false, allowCancel: false,
); );
try { try {
if (multiSelectMode) {
for (var comic in selectedComics.keys) {
var file = await CBZ.export(comic as LocalComic);
await saveFile(filename: file.name, file: file);
await file.delete();
}
setState(() {
selectedComics.clear();
});
} else {
var file = await CBZ.export(c as LocalComic); var file = await CBZ.export(c as LocalComic);
await saveFile(filename: file.name, file: file); await saveFile(filename: file.name, file: file);
await file.delete(); await file.delete();
}
} catch (e) { } catch (e) {
context.showMessage(message: e.toString()); context.showMessage(message: e.toString());
} }
@@ -210,5 +375,24 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
], ],
), ),
); );
return PopScope(
canPop: !multiSelectMode && !searchMode,
onPopInvokedWithResult: (didPop, result) {
if(multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
} else if(searchMode) {
setState(() {
searchMode = false;
keyword = "";
update();
});
}
},
child: body,
);
} }
} }

View File

@@ -78,20 +78,25 @@ class _MainPageState extends State<MainPage> {
activeIcon: Icons.category, activeIcon: Icons.category,
), ),
], ],
onPageChanged: (i) {
setState(() {
index = i;
});
},
paneActions: [ paneActions: [
if(index != 0) if(index != 0)
PaneActionEntry( PaneActionEntry(
icon: Icons.search, icon: Icons.search,
label: "Search".tl, label: "Search".tl,
onTap: () { onTap: () {
to(() => const SearchPage()); to(() => const SearchPage(), preventDuplicate: true);
}, },
), ),
PaneActionEntry( PaneActionEntry(
icon: Icons.settings, icon: Icons.settings,
label: "Settings".tl, label: "Settings".tl,
onTap: () { onTap: () {
to(() => const SettingsPage()); to(() => const SettingsPage(), preventDuplicate: true);
}, },
) )
], ],

View File

@@ -22,6 +22,8 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_DragListener? dragListener; _DragListener? dragListener;
int fingers = 0;
@override @override
void initState() { void initState() {
_tapGestureRecognizer = TapGestureRecognizer() _tapGestureRecognizer = TapGestureRecognizer()
@@ -38,6 +40,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
return Listener( return Listener(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onPointerDown: (event) { onPointerDown: (event) {
fingers++;
_lastTapPointer = event.pointer; _lastTapPointer = event.pointer;
_lastTapMoveDistance = Offset.zero; _lastTapMoveDistance = Offset.zero;
_tapGestureRecognizer.addPointer(event); _tapGestureRecognizer.addPointer(event);
@@ -46,7 +49,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_dragInProgress = false; _dragInProgress = false;
} }
Future.delayed(_kLongPressMinTime, () { Future.delayed(_kLongPressMinTime, () {
if (_lastTapPointer == event.pointer) { if (_lastTapPointer == event.pointer && fingers == 1) {
if(_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) { if(_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) {
onLongPressedDown(event.position); onLongPressedDown(event.position);
_longPressInProgress = true; _longPressInProgress = true;
@@ -67,6 +70,19 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
} }
}, },
onPointerUp: (event) { onPointerUp: (event) {
fingers--;
if (_longPressInProgress) {
onLongPressedUp(event.position);
}
if(_dragInProgress) {
dragListener?.onEnd?.call();
_dragInProgress = false;
}
_lastTapPointer = null;
_lastTapMoveDistance = null;
},
onPointerCancel: (event) {
fingers--;
if (_longPressInProgress) { if (_longPressInProgress) {
onLongPressedUp(event.position); onLongPressedUp(event.position);
} }

View File

@@ -471,18 +471,24 @@ class _ContinuousModeState extends State<_ContinuousMode>
}, },
child: widget, child: widget,
); );
var width = MediaQuery.of(context).size.width;
var height = MediaQuery.of(context).size.height;
if(appdata.settings['limitImageWidth'] && width / height > 0.7) {
width = height * 0.7;
}
return PhotoView.customChild( return PhotoView.customChild(
backgroundDecoration: BoxDecoration( backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface, color: context.colorScheme.surface,
), ),
childSize: Size(width, height),
minScale: 1.0, minScale: 1.0,
maxScale: 2.5, maxScale: 2.5,
strictScale: true, strictScale: true,
controller: photoViewController, controller: photoViewController,
child: SizedBox( child: SizedBox(
width: MediaQuery.of(context).size.width, width: width,
height: MediaQuery.of(context).size.height, height: height,
child: widget, child: widget,
), ),
); );

View File

@@ -12,6 +12,7 @@ import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/components/custom_slider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
@@ -20,10 +21,13 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:venera/utils/volume.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:battery_plus/battery_plus.dart';
part 'scaffold.dart'; part 'scaffold.dart';
part 'images.dart'; part 'images.dart';
@@ -97,6 +101,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
var focusNode = FocusNode(); var focusNode = FocusNode();
VolumeListener? volumeListener;
@override @override
void initState() { void initState() {
page = widget.initialPage ?? 1; page = widget.initialPage ?? 1;
@@ -107,6 +113,9 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
updateHistory(); updateHistory();
}); });
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if(appdata.settings['enableTurnPageByVolumeKey']) {
handleVolumeEvent();
}
super.initState(); super.initState();
} }
@@ -115,6 +124,10 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
autoPageTurningTimer?.cancel(); autoPageTurningTimer?.cancel();
focusNode.dispose(); focusNode.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
stopVolumeEvent();
Future.microtask(() {
DataSync().onDataChanged();
});
super.dispose(); super.dispose();
} }
@@ -152,6 +165,31 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
HistoryManager().addHistory(history!); HistoryManager().addHistory(history!);
} }
} }
void handleVolumeEvent() {
if(!App.isAndroid) {
// Currently only support Android
return;
}
if(volumeListener != null) {
volumeListener?.cancel();
}
volumeListener = VolumeListener(
onDown: () {
toNextPage();
},
onUp: () {
toPrevPage();
},
)..listen();
}
void stopVolumeEvent() {
if(volumeListener != null) {
volumeListener?.cancel();
volumeListener = null;
}
}
} }
abstract mixin class _ReaderLocation { abstract mixin class _ReaderLocation {
@@ -207,8 +245,10 @@ abstract mixin class _ReaderLocation {
bool toPage(int page) { bool toPage(int page) {
if (_validatePage(page)) { if (_validatePage(page)) {
if (page == this.page) { if (page == this.page) {
if(!(chapter == 1 && page == 1) && !(chapter == maxChapter && page == maxPage)) {
return false; return false;
} }
}
this.page = page; this.page = page;
update(); update();
if (enablePageAnimation) { if (enablePageAnimation) {
@@ -287,6 +327,8 @@ enum ReaderMode {
bool get isGallery => key.startsWith('gallery'); bool get isGallery => key.startsWith('gallery');
bool get isContinuous => key.startsWith('continuous');
const ReaderMode(this.key); const ReaderMode(this.key);
static ReaderMode fromKey(String key) { static ReaderMode fromKey(String key) {

View File

@@ -18,6 +18,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
bool get isOpen => _isOpen; bool get isOpen => _isOpen;
bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft;
int showFloatingButtonValue = 0; int showFloatingButtonValue = 0;
var lastValue = 0; var lastValue = 0;
@@ -131,10 +134,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: widget.child, child: widget.child,
), ),
buildPageInfoText(), buildPageInfoText(),
buildStatusInfo(),
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
right: 16, right: 16,
bottom: showFloatingButtonValue == 0 ? -58 : 16, bottom: showFloatingButtonValue == 0 ? -58 : 36,
child: buildEpChangeButton(), child: buildEpChangeButton(),
), ),
AnimatedPositioned( AnimatedPositioned(
@@ -216,34 +220,26 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
children: [ children: [
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton.filledTonal( IconButton.filledTonal(
onPressed: () { onPressed: () => !isReversed
if (!context.reader.toPrevChapter()) { ? context.reader.chapter > 1
context.reader.toPage(1); ? context.reader.toPrevChapter()
} else { : context.reader.toPage(1)
if (showFloatingButtonValue != 0) { : context.reader.chapter < context.reader.maxChapter
setState(() { ? context.reader.toNextChapter()
showFloatingButtonValue = 0; : context.reader.toPage(context.reader.maxPage),
});
}
}
},
icon: const Icon(Icons.first_page), icon: const Icon(Icons.first_page),
), ),
Expanded( Expanded(
child: buildSlider(), child: buildSlider(),
), ),
IconButton.filledTonal( IconButton.filledTonal(
onPressed: () { onPressed: () => !isReversed
if (!context.reader.toNextChapter()) { ? context.reader.chapter < context.reader.maxChapter
context.reader.toPage(context.reader.maxPage); ? context.reader.toNextChapter()
} else { : context.reader.toPage(context.reader.maxPage)
if (showFloatingButtonValue != 0) { : context.reader.chapter > 1
setState(() { ? context.reader.toPrevChapter()
showFloatingButtonValue = 0; : context.reader.toPage(1),
});
}
}
},
icon: const Icon(Icons.last_page)), icon: const Icon(Icons.last_page)),
const SizedBox( const SizedBox(
width: 8, width: 8,
@@ -378,12 +374,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
var sliderFocus = FocusNode(); var sliderFocus = FocusNode();
Widget buildSlider() { Widget buildSlider() {
return Slider( return CustomSlider(
focusNode: sliderFocus, focusNode: sliderFocus,
value: context.reader.page.toDouble(), value: context.reader.page.toDouble(),
min: 1, min: 1,
max: max:
context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(), context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
reversed: isReversed,
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16), divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
onChanged: (i) { onChanged: (i) {
context.reader.toPage(i.toInt()); context.reader.toPage(i.toInt());
@@ -424,6 +421,24 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
} }
Widget buildStatusInfo() {
if (appdata.settings['enableClockAndBatteryInfoInReader']) {
return Positioned(
bottom: 13,
right: 25,
child: Row(
children: [
_ClockWidget(),
const SizedBox(width: 10),
_BatteryWidget(),
],
),
);
} else {
return const SizedBox.shrink();
}
}
void openChapterDrawer() { void openChapterDrawer() {
showSideBar( showSideBar(
context, context,
@@ -432,8 +447,73 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
} }
Future<Uint8List> _getCurrentImageData() async { Future<Uint8List?> _getCurrentImageData() async {
var imageKey = context.reader.images![context.reader.page - 1]; 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![e.index - 1])
.toList();
String? selected;
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,
);
}
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: () {
selected = images[index];
App.rootContext.pop();
},
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
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,
),
),
),
);
if (selected == null) {
return null;
} else {
imageKey = selected!;
}
}
if (imageKey.startsWith("file://")) { if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes(); return await File(imageKey.substring(7)).readAsBytes();
} else { } else {
@@ -445,6 +525,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
void saveCurrentImage() async { void saveCurrentImage() async {
var data = await _getCurrentImageData(); var data = await _getCurrentImageData();
if (data == null) {
return;
}
var fileType = detectFileType(data); var fileType = detectFileType(data);
var filename = "${context.reader.page}${fileType.ext}"; var filename = "${context.reader.page}${fileType.ext}";
saveFile(data: data, filename: filename); saveFile(data: data, filename: filename);
@@ -452,6 +535,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
void share() async { void share() async {
var data = await _getCurrentImageData(); var data = await _getCurrentImageData();
if (data == null) {
return;
}
var fileType = detectFileType(data); var fileType = detectFileType(data);
var filename = "${context.reader.page}${fileType.ext}"; var filename = "${context.reader.page}${fileType.ext}";
Share.shareFile( Share.shareFile(
@@ -470,6 +556,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]); context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
App.rootContext.pop(); App.rootContext.pop();
} }
if (key == "enableTurnPageByVolumeKey") {
if (appdata.settings[key]) {
context.reader.handleVolumeEvent();
} else {
context.reader.stopVolumeEvent();
}
}
context.reader.update(); context.reader.update();
}, },
), ),
@@ -562,6 +655,188 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
} }
class _BatteryWidget extends StatefulWidget {
@override
_BatteryWidgetState createState() => _BatteryWidgetState();
}
class _BatteryWidgetState extends State<_BatteryWidget> {
late Battery _battery;
late int _batteryLevel = 100;
Timer? _timer;
bool _hasBattery = false;
@override
void initState() {
super.initState();
_battery = Battery();
_checkBatteryAvailability();
}
void _checkBatteryAvailability() async {
try {
_batteryLevel = await _battery.batteryLevel;
if (_batteryLevel != -1) {
setState(() {
_hasBattery = true;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_battery.batteryLevel.then((level) => {
if (_batteryLevel != level)
{
setState(() {
_batteryLevel = level;
})
}
});
});
});
} else {
setState(() {
_hasBattery = false;
});
}
} catch (e) {
setState(() {
_hasBattery = false;
});
}
}
@override
Widget build(BuildContext context) {
if (!_hasBattery) {
return const SizedBox.shrink(); //Empty Widget
}
return _batteryInfo(_batteryLevel);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Widget _batteryInfo(int batteryLevel) {
IconData batteryIcon;
Color batteryColor = context.colorScheme.onSurface;
if (batteryLevel >= 96) {
batteryIcon = Icons.battery_full_sharp;
} else if (batteryLevel >= 84) {
batteryIcon = Icons.battery_6_bar_sharp;
} else if (batteryLevel >= 72) {
batteryIcon = Icons.battery_5_bar_sharp;
} else if (batteryLevel >= 60) {
batteryIcon = Icons.battery_4_bar_sharp;
} else if (batteryLevel >= 48) {
batteryIcon = Icons.battery_3_bar_sharp;
} else if (batteryLevel >= 36) {
batteryIcon = Icons.battery_2_bar_sharp;
} else if (batteryLevel >= 24) {
batteryIcon = Icons.battery_1_bar_sharp;
} else if (batteryLevel >= 12) {
batteryIcon = Icons.battery_0_bar_sharp;
} else {
batteryIcon = Icons.battery_alert_sharp;
batteryColor = Colors.red;
}
return Row(
children: [
Icon(
batteryIcon,
size: 16,
color: batteryColor,
// Stroke
shadows: List.generate(
9,
(index) {
if (index == 4) {
return null;
}
double offsetX = (index % 3 - 1) * 0.8;
double offsetY = ((index / 3).floor() - 1) * 0.8;
return Shadow(
color: context.colorScheme.onInverseSurface,
offset: Offset(offsetX, offsetY),
);
},
).whereType<Shadow>().toList(),
),
Stack(
children: [
Text(
'$batteryLevel%',
style: TextStyle(
fontSize: 14,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.4
..color = context.colorScheme.onInverseSurface,
),
),
Text('$batteryLevel%'),
],
),
],
);
}
}
class _ClockWidget extends StatefulWidget {
@override
_ClockWidgetState createState() => _ClockWidgetState();
}
class _ClockWidgetState extends State<_ClockWidget> {
late String _currentTime;
late Timer _timer;
@override
void initState() {
super.initState();
_currentTime = _getCurrentTime();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
final time = _getCurrentTime();
if (_currentTime != time) {
setState(() {
_currentTime = time;
});
}
});
}
String _getCurrentTime() {
final now = DateTime.now();
return "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}";
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Text(
_currentTime,
style: TextStyle(
fontSize: 14,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.4
..color = context.colorScheme.onInverseSurface,
),
),
Text(_currentTime),
],
);
}
}
class _ChaptersView extends StatefulWidget { class _ChaptersView extends StatefulWidget {
const _ChaptersView(this.reader); const _ChaptersView(this.reader);

View File

@@ -305,13 +305,24 @@ class _SearchPageState extends State<SearchPage> {
), ),
); );
} }
return ListTile( return InkWell(
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
title: Text(appdata.searchHistory[index - 2]),
onTap: () { onTap: () {
search(appdata.searchHistory[index - 2]); search(appdata.searchHistory[index - 2]);
}, },
); child: Container(
decoration: BoxDecoration(
// color: context.colorScheme.surfaceContainer,
border: Border(
left: BorderSide(
color: context.colorScheme.outlineVariant,
width: 2,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(appdata.searchHistory[index - 2], style: ts.s14),
),
).paddingBottom(8).paddingHorizontal(4);
}, },
childCount: 2 + appdata.searchHistory.length, childCount: 2 + appdata.searchHistory.length,
), ),
@@ -369,6 +380,9 @@ class _SearchPageState extends State<SearchPage> {
), ),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () { onTap: () {
setState(() {
suggestions.clear();
});
handleAppLink(Uri.parse(controller.text)); handleAppLink(Uri.parse(controller.text));
}, },
); );
@@ -487,7 +501,7 @@ class SearchOptionWidget extends StatelessWidget {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: Text(option.label.ts(sourceKey)), title: Text(option.label.ts(sourceKey)),
), ),
if(option.type == 'select') if (option.type == 'select')
Wrap( Wrap(
runSpacing: 8, runSpacing: 8,
spacing: 8, spacing: 8,
@@ -501,7 +515,7 @@ class SearchOptionWidget extends StatelessWidget {
); );
}).toList(), }).toList(),
), ),
if(option.type == 'multi-select') if (option.type == 'multi-select')
Wrap( Wrap(
runSpacing: 8, runSpacing: 8,
spacing: 8, spacing: 8,
@@ -511,7 +525,7 @@ class SearchOptionWidget extends StatelessWidget {
isSelected: (jsonDecode(value) as List).contains(e.key), isSelected: (jsonDecode(value) as List).contains(e.key),
onTap: () { onTap: () {
var list = jsonDecode(value) as List; var list = jsonDecode(value) as List;
if(list.contains(e.key)) { if (list.contains(e.key)) {
list.remove(e.key); list.remove(e.key);
} else { } else {
list.add(e.key); list.add(e.key);
@@ -521,7 +535,7 @@ class SearchOptionWidget extends StatelessWidget {
); );
}).toList(), }).toList(),
), ),
if(option.type == 'dropdown') if (option.type == 'dropdown')
Select( Select(
current: option.options[value], current: option.options[value],
values: option.options.values.toList(), values: option.options.values.toList(),

View File

@@ -42,7 +42,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
void search([String? text]) { void search([String? text]) {
if (text != null) { if (text != null) {
if(suggestionsController.entry != null) { if (suggestionsController.entry != null) {
suggestionsController.remove(); suggestionsController.remove();
} }
setState(() { setState(() {
@@ -135,14 +135,18 @@ class _SearchResultPageState extends State<SearchResultPage> {
onChanged: onChanged, onChanged: onChanged,
action: buildAction(), action: buildAction(),
), ),
loadPage: source!.searchPageData!.loadPage == null ? null : (i) { loadPage: source!.searchPageData!.loadPage == null
? null
: (i) {
return source.searchPageData!.loadPage!( return source.searchPageData!.loadPage!(
text, text,
i, i,
options, options,
); );
}, },
loadNext: source.searchPageData!.loadNext == null ? null : (i) { loadNext: source.searchPageData!.loadNext == null
? null
: (i) {
return source.searchPageData!.loadNext!( return source.searchPageData!.loadNext!(
text, text,
i, i,
@@ -424,6 +428,11 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
setState(() { setState(() {
searchTarget = e.key; searchTarget = e.key;
options.clear(); options.clear();
final searchOptions = ComicSource.find(searchTarget)!
.searchPageData!
.searchOptions ??
<SearchOptions>[];
options = searchOptions.map((e) => e.defaultValue).toList();
onChanged(); onChanged();
}); });
}, },

View File

@@ -16,12 +16,12 @@ class _AboutSettingsState extends State<AboutSettings> {
slivers: [ slivers: [
SliverAppbar(title: Text("About".tl)), SliverAppbar(title: Text("About".tl)),
SizedBox( SizedBox(
height: 136, height: 112,
width: double.infinity, width: double.infinity,
child: Center( child: Center(
child: Container( child: Container(
width: 136, width: 112,
height: 136, height: 112,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(136), borderRadius: BorderRadius.circular(136),
), ),

View File

@@ -20,16 +20,44 @@ class _AppSettingsState extends State<AppSettings> {
ListTile( ListTile(
title: Text("Storage Path for local comics".tl), title: Text("Storage Path for local comics".tl),
subtitle: Text(LocalManager().path, softWrap: false), subtitle: Text(LocalManager().path, softWrap: false),
trailing: IconButton(
icon: const Icon(Icons.copy),
onPressed: () {
Clipboard.setData(ClipboardData(text: LocalManager().path));
context.showMessage(message: "Path copied to clipboard".tl);
},
),
).toSliver(), ).toSliver(),
_CallbackSetting( _CallbackSetting(
title: "Set New Storage Path".tl, title: "Set New Storage Path".tl,
actionTitle: "Set".tl, actionTitle: "Set".tl,
callback: () async { callback: () async {
if (App.isMobile) { String? result;
context.showMessage(message: "Not supported".tl); if (App.isAndroid) {
var channel = const MethodChannel("venera/storage");
var permission = await channel.invokeMethod('');
if (permission != true) {
context.showMessage(message: "Permission denied".tl);
return; return;
} }
var result = await selectDirectory(); var path = await selectDirectory();
if (path != null) {
// check if the path is writable
var testFile = File(FilePath.join(path, "test"));
try {
await testFile.writeAsBytes([1]);
await testFile.delete();
} catch (e) {
context.showMessage(message: "Permission denied".tl);
return;
}
result = path;
}
} else if (App.isIOS) {
result = await selectDirectoryIOS();
} else {
result = await selectDirectory();
}
if (result == null) return; if (result == null) return;
var loadingDialog = showLoadingDialog( var loadingDialog = showLoadingDialog(
App.rootContext, App.rootContext,
@@ -78,8 +106,7 @@ class _AppSettingsState extends State<AppSettings> {
appdata.settings['cacheSize'] = int.parse(value); appdata.settings['cacheSize'] = int.parse(value);
appdata.saveData(); appdata.saveData();
setState(() {}); setState(() {});
CacheManager() CacheManager().setLimitSize(appdata.settings['cacheSize']);
.setLimitSize(appdata.settings['cacheSize']);
return null; return null;
}, },
); );
@@ -101,13 +128,12 @@ class _AppSettingsState extends State<AppSettings> {
callback: () async { callback: () async {
var controller = showLoadingDialog(context); var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera']); var file = await selectFile(ext: ['venera']);
if(file != null) { if (file != null) {
var cacheFile = File(FilePath.join(App.cachePath, "temp.venera")); var cacheFile = File(FilePath.join(App.cachePath, "temp.venera"));
await file.saveTo(cacheFile.path); await file.saveTo(cacheFile.path);
try { try {
await importAppData(cacheFile); await importAppData(cacheFile);
} } catch (e, s) {
catch(e, s) {
Log.error("Import data", e.toString(), s); Log.error("Import data", e.toString(), s);
context.showMessage(message: "Failed to import data".tl); context.showMessage(message: "Failed to import data".tl);
} }
@@ -116,6 +142,13 @@ class _AppSettingsState extends State<AppSettings> {
}, },
actionTitle: 'Import'.tl, actionTitle: 'Import'.tl,
).toSliver(), ).toSliver(),
_CallbackSetting(
title: "Data Sync".tl,
callback: () async {
showPopUpWidget(context, const _WebdavSetting());
},
actionTitle: 'Set'.tl,
).toSliver(),
_SettingPartTitle( _SettingPartTitle(
title: "Log".tl, title: "Log".tl,
icon: Icons.error_outline, icon: Icons.error_outline,
@@ -144,6 +177,29 @@ class _AppSettingsState extends State<AppSettings> {
App.forceRebuild(); App.forceRebuild();
}, },
).toSliver(), ).toSliver(),
if (!App.isLinux)
_SwitchSetting(
title: "Authorization Required".tl,
settingKey: "authorizationRequired",
onChanged: () async {
var current = appdata.settings['authorizationRequired'];
if (current) {
final auth = LocalAuthentication();
final bool canAuthenticateWithBiometrics =
await auth.canCheckBiometrics;
final bool canAuthenticate = canAuthenticateWithBiometrics ||
await auth.isDeviceSupported();
if (!canAuthenticate) {
context.showMessage(message: "Biometrics not supported".tl);
setState(() {
appdata.settings['authorizationRequired'] = false;
});
appdata.saveData();
return;
}
}
},
).toSliver(),
], ],
); );
} }
@@ -271,3 +327,129 @@ class _LogsPageState extends State<LogsPage> {
saveFile(data: utf8.encode(log), filename: 'log.txt'); saveFile(data: utf8.encode(log), filename: 'log.txt');
} }
} }
class _WebdavSetting extends StatefulWidget {
const _WebdavSetting();
@override
State<_WebdavSetting> createState() => _WebdavSettingState();
}
class _WebdavSettingState extends State<_WebdavSetting> {
String url = "";
String user = "";
String pass = "";
bool isTesting = false;
bool upload = true;
@override
void initState() {
super.initState();
if (appdata.settings['webdav'] is! List) {
appdata.settings['webdav'] = [];
}
var configs = appdata.settings['webdav'] as List;
if (configs.whereType<String>().length != 3) {
return;
}
url = configs[0];
user = configs[1];
pass = configs[2];
}
@override
Widget build(BuildContext context) {
return PopUpWidgetScaffold(
title: "Webdav",
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: "URL",
border: OutlineInputBorder(),
),
controller: TextEditingController(text: url),
onChanged: (value) => url = value,
),
const SizedBox(height: 12),
TextField(
decoration: InputDecoration(
labelText: "Username".tl,
border: const OutlineInputBorder(),
),
controller: TextEditingController(text: user),
onChanged: (value) => user = value,
),
const SizedBox(height: 12),
TextField(
decoration: InputDecoration(
labelText: "Password".tl,
border: const OutlineInputBorder(),
),
controller: TextEditingController(text: pass),
onChanged: (value) => pass = value,
),
const SizedBox(height: 12),
Row(
children: [
Text("Operation".tl),
Radio<bool>(
groupValue: upload,
value: true,
onChanged: (value) {
setState(() {
upload = value!;
});
},
),
Text("Upload".tl),
Radio<bool>(
groupValue: upload,
value: false,
onChanged: (value) {
setState(() {
upload = value!;
});
},
),
Text("Download".tl),
],
),
const SizedBox(height: 16),
Center(
child: Button.filled(
isLoading: isTesting,
onPressed: () async {
var oldConfig = appdata.settings['webdav'];
appdata.settings['webdav'] = [url, user, pass];
setState(() {
isTesting = true;
});
var testResult = upload
? await DataSync().uploadData()
: await DataSync().downloadData();
if (testResult.error) {
setState(() {
isTesting = false;
});
appdata.settings['webdav'] = oldConfig;
context.showMessage(message: testResult.errorMessage!);
return;
}
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
},
child: Text("Continue".tl),
),
)
],
).paddingHorizontal(16),
),
);
}
}

View File

@@ -24,12 +24,20 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
SelectSetting( SelectSetting(
title: "Move favorite after reading".tl, title: "Move favorite after reading".tl,
settingKey: "moveFavoriteAfterRead", settingKey: "moveFavoriteAfterRead",
optionTranslation: { optionTranslation: const {
"none": "None", "none": "None",
"end": "End", "end": "End",
"start": "Start", "start": "Start",
}, },
).toSliver(), ).toSliver(),
SelectSetting(
title: "Quick Favorite".tl,
settingKey: "quickFavorite",
help: "Long press on the favorite button to quickly add to this folder".tl,
optionTranslation: {
for (var e in LocalFavoritesManager().folderNames) e: e
},
).toSliver(),
], ],
); );
} }

View File

@@ -38,14 +38,11 @@ class _ProxySettingView extends StatefulWidget {
class _ProxySettingViewState extends State<_ProxySettingView> { class _ProxySettingViewState extends State<_ProxySettingView> {
String type = ''; String type = '';
String host = ''; String host = '';
String port = ''; String port = '';
String username = ''; String username = '';
String password = ''; String password = '';
bool ignoreCertificateErrors = false;
// USERNAME:PASSWORD@HOST:PORT // USERNAME:PASSWORD@HOST:PORT
String toProxyStr() { String toProxyStr() {
@@ -103,6 +100,7 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
void initState() { void initState() {
var proxy = appdata.settings['proxy']; var proxy = appdata.settings['proxy'];
parseProxyString(proxy); parseProxyString(proxy);
ignoreCertificateErrors = appdata.settings['ignoreCertificateErrors'] ?? false;
super.initState(); super.initState();
} }
@@ -148,6 +146,17 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
}, },
), ),
if (type == 'manual') buildManualProxy(), if (type == 'manual') buildManualProxy(),
SwitchListTile(
title: Text("Ignore Certificate Errors".tl),
value: ignoreCertificateErrors,
onChanged: (v) {
setState(() {
ignoreCertificateErrors = v;
});
appdata.settings['ignoreCertificateErrors'] = ignoreCertificateErrors;
appdata.saveData();
},
),
], ],
), ),
), ),

View File

@@ -61,6 +61,29 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call('enableLongPressToZoom'); widget.onChanged?.call('enableLongPressToZoom');
}, },
).toSliver(), ).toSliver(),
_SwitchSetting(
title: 'Limit image width'.tl,
subtitle: 'When using Continuous(Top to Bottom) mode'.tl,
settingKey: 'limitImageWidth',
onChanged: () {
widget.onChanged?.call('limitImageWidth');
},
).toSliver(),
if(App.isAndroid)
_SwitchSetting(
title: 'Turn page by volume keys'.tl,
settingKey: 'enableTurnPageByVolumeKey',
onChanged: () {
widget.onChanged?.call('enableTurnPageByVolumeKey');
},
).toSliver(),
_SwitchSetting(
title: "Display time & battery info in reader".tl,
settingKey: "enableClockAndBatteryInfoInReader",
onChanged: () {
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
},
).toSliver(),
], ],
); );
} }

View File

@@ -5,6 +5,7 @@ class _SwitchSetting extends StatefulWidget {
required this.title, required this.title,
required this.settingKey, required this.settingKey,
this.onChanged, this.onChanged,
this.subtitle,
}); });
final String title; final String title;
@@ -13,6 +14,8 @@ class _SwitchSetting extends StatefulWidget {
final VoidCallback? onChanged; final VoidCallback? onChanged;
final String? subtitle;
@override @override
State<_SwitchSetting> createState() => _SwitchSettingState(); State<_SwitchSetting> createState() => _SwitchSettingState();
} }
@@ -24,14 +27,16 @@ class _SwitchSettingState extends State<_SwitchSetting> {
return ListTile( return ListTile(
title: Text(widget.title), title: Text(widget.title),
subtitle: widget.subtitle == null ? null : Text(widget.subtitle!),
trailing: Switch( trailing: Switch(
value: appdata.settings[widget.settingKey], value: appdata.settings[widget.settingKey],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
appdata.settings[widget.settingKey] = value; appdata.settings[widget.settingKey] = value;
appdata.saveData();
}); });
appdata.saveData().then((_) {
widget.onChanged?.call(); widget.onChanged?.call();
});
}, },
), ),
); );
@@ -45,6 +50,7 @@ class SelectSetting extends StatelessWidget {
required this.settingKey, required this.settingKey,
required this.optionTranslation, required this.optionTranslation,
this.onChanged, this.onChanged,
this.help,
}); });
final String title; final String title;
@@ -55,6 +61,8 @@ class SelectSetting extends StatelessWidget {
final VoidCallback? onChanged; final VoidCallback? onChanged;
final String? help;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return SizedBox(
@@ -67,6 +75,7 @@ class SelectSetting extends StatelessWidget {
settingKey: settingKey, settingKey: settingKey,
optionTranslation: optionTranslation, optionTranslation: optionTranslation,
onChanged: onChanged, onChanged: onChanged,
help: help,
); );
} else { } else {
return _EndSelectorSelectSetting( return _EndSelectorSelectSetting(
@@ -74,6 +83,7 @@ class SelectSetting extends StatelessWidget {
settingKey: settingKey, settingKey: settingKey,
optionTranslation: optionTranslation, optionTranslation: optionTranslation,
onChanged: onChanged, onChanged: onChanged,
help: help,
); );
} }
}, },
@@ -88,6 +98,7 @@ class _DoubleLineSelectSettings extends StatefulWidget {
required this.settingKey, required this.settingKey,
required this.optionTranslation, required this.optionTranslation,
this.onChanged, this.onChanged,
this.help,
}); });
final String title; final String title;
@@ -98,6 +109,8 @@ class _DoubleLineSelectSettings extends StatefulWidget {
final VoidCallback? onChanged; final VoidCallback? onChanged;
final String? help;
@override @override
State<_DoubleLineSelectSettings> createState() => State<_DoubleLineSelectSettings> createState() =>
_DoubleLineSelectSettingsState(); _DoubleLineSelectSettingsState();
@@ -107,9 +120,39 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( return ListTile(
title: Text(widget.title), title: Row(
subtitle: children: [
Text(widget.optionTranslation[appdata.settings[widget.settingKey]]!), Text(widget.title),
const SizedBox(width: 4),
if (widget.help != null)
Button.icon(
size: 18,
icon: const Icon(Icons.help_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return ContentDialog(
title: "Help".tl,
content: Text(widget.help!)
.paddingHorizontal(16)
.fixWidth(double.infinity),
actions: [
Button.filled(
onPressed: context.pop,
child: Text("OK".tl),
),
],
);
},
);
},
),
],
),
subtitle: Text(
widget.optionTranslation[appdata.settings[widget.settingKey]] ??
"None"),
trailing: const Icon(Icons.arrow_drop_down), trailing: const Icon(Icons.arrow_drop_down),
onTap: () { onTap: () {
var renderBox = context.findRenderObject() as RenderBox; var renderBox = context.findRenderObject() as RenderBox;
@@ -118,8 +161,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
var rect = offset & size; var rect = offset & size;
showMenu( showMenu(
elevation: 3, elevation: 3,
color: context.colorScheme.surface, color: context.brightness == Brightness.light
surfaceTintColor: Colors.transparent, ? const Color(0xFFF6F6F6)
: const Color(0xFF1E1E1E),
context: context, context: context,
position: RelativeRect.fromRect( position: RelativeRect.fromRect(
rect, rect,
@@ -152,6 +196,7 @@ class _EndSelectorSelectSetting extends StatefulWidget {
required this.settingKey, required this.settingKey,
required this.optionTranslation, required this.optionTranslation,
this.onChanged, this.onChanged,
this.help,
}); });
final String title; final String title;
@@ -162,6 +207,8 @@ class _EndSelectorSelectSetting extends StatefulWidget {
final VoidCallback? onChanged; final VoidCallback? onChanged;
final String? help;
@override @override
State<_EndSelectorSelectSetting> createState() => State<_EndSelectorSelectSetting> createState() =>
_EndSelectorSelectSettingState(); _EndSelectorSelectSettingState();
@@ -172,10 +219,40 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var options = widget.optionTranslation; var options = widget.optionTranslation;
return ListTile( return ListTile(
title: Text(widget.title), title: Row(
children: [
Text(widget.title),
const SizedBox(width: 4),
if (widget.help != null)
Button.icon(
size: 18,
icon: const Icon(Icons.help_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return ContentDialog(
title: "Help".tl,
content: Text(widget.help!)
.paddingHorizontal(16)
.fixWidth(double.infinity),
actions: [
Button.filled(
onPressed: context.pop,
child: Text("OK".tl),
),
],
);
},
);
},
),
],
),
trailing: Select( trailing: Select(
current: options[appdata.settings[widget.settingKey]]!, current: options[appdata.settings[widget.settingKey]],
values: options.values.toList(), values: options.values.toList(),
minWidth: 64,
onTap: (index) { onTap: (index) {
setState(() { setState(() {
appdata.settings[widget.settingKey] = options.keys.elementAt(index); appdata.settings[widget.settingKey] = options.keys.elementAt(index);
@@ -389,11 +466,15 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return SimpleDialog( return ContentDialog(
title: const Text("Add"), title: "Add".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: canAdd.entries children: canAdd.entries
.map((e) => InkWell( .map(
child: ListTile(title: Text(e.value), key: Key(e.key)), (e) => ListTile(
title: Text(e.value),
key: Key(e.key),
onTap: () { onTap: () {
context.pop(); context.pop();
setState(() { setState(() {
@@ -401,10 +482,13 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
}); });
updateSetting(); updateSetting();
}, },
)) ),
)
.toList(), .toList(),
),
);
},
); );
});
} }
void updateSetting() { void updateSetting() {

View File

@@ -4,17 +4,19 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:local_auth/local_auth.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/data.dart'; import 'package:venera/utils/data.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:yaml/yaml.dart'; import 'package:yaml/yaml.dart';
@@ -42,7 +44,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
ColorScheme get colors => Theme.of(context).colorScheme; ColorScheme get colors => Theme.of(context).colorScheme;
bool get enableTwoViews => context.width > changePoint; bool get enableTwoViews => context.width > 720;
final categories = <String>[ final categories = <String>[
"Explore", "Explore",

View File

@@ -18,11 +18,11 @@ export 'package:flutter_inappwebview/flutter_inappwebview.dart'
extension WebviewExtension on InAppWebViewController { extension WebviewExtension on InAppWebViewController {
Future<List<io.Cookie>?> getCookies(String url) async { Future<List<io.Cookie>?> getCookies(String url) async {
if(url.contains("https://")){ if (url.contains("https://")) {
url.replaceAll("https://", ""); url.replaceAll("https://", "");
} }
if(url[url.length-1] == '/'){ if (url[url.length - 1] == '/') {
url = url.substring(0, url.length-1); url = url.substring(0, url.length - 1);
} }
CookieManager cookieManager = CookieManager.instance(); CookieManager cookieManager = CookieManager.instance();
final cookies = await cookieManager.getCookies(url: WebUri(url)); final cookies = await cookieManager.getCookies(url: WebUri(url));
@@ -89,29 +89,29 @@ class _AppWebviewState extends State<AppWebview> {
child: IconButton( child: IconButton(
icon: const Icon(Icons.more_horiz), icon: const Icon(Icons.more_horiz),
onPressed: () { onPressed: () {
showMenu( showMenuX(
context: context, context,
position: RelativeRect.fromLTRB( Offset(context.width, context.padding.top),
MediaQuery.of(context).size.width, [
0, MenuEntry(
MediaQuery.of(context).size.width, icon: Icons.open_in_browser,
0), text: "Open in browser".tl,
items: [ onClick: () async =>
PopupMenuItem(
child: Text("Open in browser".tl),
onTap: () async =>
launchUrlString((await controller?.getUrl())!.toString()), launchUrlString((await controller?.getUrl())!.toString()),
), ),
PopupMenuItem( MenuEntry(
child: Text("Copy link".tl), icon: Icons.copy,
onTap: () async => Clipboard.setData(ClipboardData( text: "Copy link".tl,
onClick: () async => Clipboard.setData(ClipboardData(
text: (await controller?.getUrl())!.toString())), text: (await controller?.getUrl())!.toString())),
), ),
PopupMenuItem( MenuEntry(
child: Text("Reload".tl), icon: Icons.refresh,
onTap: () => controller?.reload(), text: "Reload".tl,
onClick: () => controller?.reload(),
), ),
]); ],
);
}, },
), ),
) )

View File

@@ -10,7 +10,7 @@ void handleLinks() {
}); });
} }
void handleAppLink(Uri uri) async { Future<bool> handleAppLink(Uri uri) async {
for(var source in ComicSource.all()) { for(var source in ComicSource.all()) {
if(source.linkHandler != null) { if(source.linkHandler != null) {
if(source.linkHandler!.domains.contains(uri.host)) { if(source.linkHandler!.domains.contains(uri.host)) {
@@ -22,9 +22,11 @@ void handleAppLink(Uri uri) async {
App.mainNavigatorKey!.currentContext?.to(() { App.mainNavigatorKey!.currentContext?.to(() {
return ComicPage(id: id, sourceKey: source.key); return ComicPage(id: id, sourceKey: source.key);
}); });
return true;
} }
return; return false;
} }
} }
} }
return false;
} }

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:isolate'; import 'dart:isolate';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -5,6 +6,7 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:zip_flutter/zip_flutter.dart'; import 'package:zip_flutter/zip_flutter.dart';
import 'io.dart'; import 'io.dart';
@@ -14,7 +16,7 @@ Future<File> exportAppData() async {
var cacheFilePath = FilePath.join(App.cachePath, '$time.venera'); var cacheFilePath = FilePath.join(App.cachePath, '$time.venera');
var cacheFile = File(cacheFilePath); var cacheFile = File(cacheFilePath);
var dataPath = App.dataPath; var dataPath = App.dataPath;
if(await cacheFile.exists()) { if (await cacheFile.exists()) {
await cacheFile.delete(); await cacheFile.delete();
} }
await Isolate.run(() { await Isolate.run(() {
@@ -22,11 +24,14 @@ Future<File> exportAppData() async {
var historyFile = FilePath.join(dataPath, "history.db"); var historyFile = FilePath.join(dataPath, "history.db");
var localFavoriteFile = FilePath.join(dataPath, "local_favorite.db"); var localFavoriteFile = FilePath.join(dataPath, "local_favorite.db");
var appdata = FilePath.join(dataPath, "appdata.json"); var appdata = FilePath.join(dataPath, "appdata.json");
var cookies = FilePath.join(dataPath, "cookie.db");
zipFile.addFile("history.db", historyFile); zipFile.addFile("history.db", historyFile);
zipFile.addFile("local_favorite.db", localFavoriteFile); zipFile.addFile("local_favorite.db", localFavoriteFile);
zipFile.addFile("appdata.json", appdata); zipFile.addFile("appdata.json", appdata);
for(var file in Directory(FilePath.join(dataPath, "comic_source")).listSync()) { zipFile.addFile("cookie.db", cookies);
if(file is File) { for (var file
in Directory(FilePath.join(dataPath, "comic_source")).listSync()) {
if (file is File) {
zipFile.addFile("comic_source/${file.name}", file.path); zipFile.addFile("comic_source/${file.name}", file.path);
} }
} }
@@ -35,7 +40,7 @@ Future<File> exportAppData() async {
return cacheFile; return cacheFile;
} }
Future<void> importAppData(File file) async { Future<void> importAppData(File file, [bool checkVersion = false]) async {
var cacheDirPath = FilePath.join(App.cachePath, 'temp_data'); var cacheDirPath = FilePath.join(App.cachePath, 'temp_data');
var cacheDir = Directory(cacheDirPath); var cacheDir = Directory(cacheDirPath);
await Isolate.run(() { await Isolate.run(() {
@@ -44,25 +49,50 @@ Future<void> importAppData(File file) async {
var historyFile = cacheDir.joinFile("history.db"); var historyFile = cacheDir.joinFile("history.db");
var localFavoriteFile = cacheDir.joinFile("local_favorite.db"); var localFavoriteFile = cacheDir.joinFile("local_favorite.db");
var appdataFile = cacheDir.joinFile("appdata.json"); var appdataFile = cacheDir.joinFile("appdata.json");
if(await historyFile.exists()) { var cookieFile = cacheDir.joinFile("cookie.db");
if (checkVersion && appdataFile.existsSync()) {
var data = jsonDecode(await appdataFile.readAsString());
var version = data["settings"]["dataVersion"];
if (version is int && version <= appdata.settings["dataVersion"]) {
return;
}
}
if (await historyFile.exists()) {
HistoryManager().close(); HistoryManager().close();
await historyFile.copy(FilePath.join(App.dataPath, "history.db")); File(FilePath.join(App.dataPath, "history.db")).deleteIfExistsSync();
historyFile.renameSync(FilePath.join(App.dataPath, "history.db"));
HistoryManager().init(); HistoryManager().init();
} }
if(await localFavoriteFile.exists()) { if (await localFavoriteFile.exists()) {
LocalFavoritesManager().close(); LocalFavoritesManager().close();
await localFavoriteFile.copy(FilePath.join(App.dataPath, "local_favorite.db")); File(FilePath.join(App.dataPath, "local_favorite.db")).deleteIfExistsSync();
localFavoriteFile
.renameSync(FilePath.join(App.dataPath, "local_favorite.db"));
LocalFavoritesManager().init(); LocalFavoritesManager().init();
} }
if(await appdataFile.exists()) { if (await appdataFile.exists()) {
await appdataFile.copy(FilePath.join(App.dataPath, "appdata.json")); // proxy settings should be kept
appdata.init(); var proxySettings = appdata.settings["proxy"];
File(FilePath.join(App.dataPath, "appdata.json")).deleteIfExistsSync();
appdataFile.renameSync(FilePath.join(App.dataPath, "appdata.json"));
await appdata.init();
appdata.settings["proxy"] = proxySettings;
appdata.saveData();
}
if (await cookieFile.exists()) {
SingleInstanceCookieJar.instance?.dispose();
File(FilePath.join(App.dataPath, "cookie.db")).deleteIfExistsSync();
cookieFile.renameSync(FilePath.join(App.dataPath, "cookie.db"));
SingleInstanceCookieJar.instance =
SingleInstanceCookieJar(FilePath.join(App.dataPath, "cookie.db"))
..init();
} }
var comicSourceDir = FilePath.join(cacheDirPath, "comic_source"); var comicSourceDir = FilePath.join(cacheDirPath, "comic_source");
if(Directory(comicSourceDir).existsSync()) { if (Directory(comicSourceDir).existsSync()) {
for(var file in Directory(comicSourceDir).listSync()) { for (var file in Directory(comicSourceDir).listSync()) {
if(file is File) { if (file is File) {
var targetFile = FilePath.join(App.dataPath, "comic_source", file.name); var targetFile = FilePath.join(App.dataPath, "comic_source", file.name);
File(targetFile).deleteIfExistsSync();
await file.copy(targetFile); await file.copy(targetFile);
} }
} }

204
lib/utils/data_sync.dart Normal file
View File

@@ -0,0 +1,204 @@
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
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 'io.dart';
class DataSync with ChangeNotifier {
DataSync._() {
if (isEnabled) {
downloadData();
}
LocalFavoritesManager().addListener(onDataChanged);
ComicSource.addListener(onDataChanged);
}
void onDataChanged() {
if (isEnabled) {
uploadData();
}
}
static DataSync? instance;
factory DataSync() => instance ?? (instance = DataSync._());
bool isDownloading = false;
bool isUploading = false;
bool haveWaitingTask = false;
bool get isEnabled {
var config = appdata.settings['webdav'];
return config is List && config.isNotEmpty;
}
List<String>? _validateConfig() {
var config = appdata.settings['webdav'];
if (config is! List || (config.isNotEmpty && config.length != 3)) {
return null;
}
if (config.whereType<String>().length != 3) {
return null;
}
return List.from(config);
}
Future<Res<bool>> uploadData() async {
if(isDownloading) return const Res(true);
if (haveWaitingTask) return const Res(true);
while (isUploading) {
haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
isUploading = true;
notifyListeners();
try {
var config = _validateConfig();
if (config == null) {
return const Res.error('Invalid WebDAV configuration');
}
if (config.isEmpty) {
return const Res(true);
}
String url = config[0];
String user = config[1];
String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient(
url,
user: user,
password: pass,
adapter: IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
),
);
try {
await client.ping();
} catch (e) {
Log.error("Upload Data", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try {
appdata.settings['dataVersion']++;
await appdata.saveData();
var data = await exportAppData();
var time =
(DateTime.now().millisecondsSinceEpoch ~/ 86400000).toString();
var filename = time;
filename += '-';
filename += appdata.settings['dataVersion'].toString();
filename += '.venera';
var files = await client.readDir('/');
files = files.where((e) => e.name!.endsWith('.venera')).toList();
var old = files.firstWhereOrNull( (e) => e.name!.startsWith("$time-"));
if (old != null) {
await client.remove(old.name!);
}
if (files.length >= 10) {
files.sort((a, b) => a.name!.compareTo(b.name!));
await client.remove(files.first.name!);
}
await client.write(filename, await data.readAsBytes());
Log.info("Upload Data", "Data uploaded successfully");
return const Res(true);
} catch (e, s) {
Log.error("Upload Data", e, s);
return Res.error(e.toString());
}
} finally {
isUploading = false;
notifyListeners();
}
}
Future<Res<bool>> downloadData() async {
if (haveWaitingTask) return const Res(true);
while (isDownloading || isUploading) {
haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
isDownloading = true;
notifyListeners();
try {
var config = _validateConfig();
if (config == null) {
return const Res.error('Invalid WebDAV configuration');
}
if (config.isEmpty) {
return const Res(true);
}
String url = config[0];
String user = config[1];
String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient(
url,
user: user,
password: pass,
adapter: IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
),
);
try {
await client.ping();
} catch (e) {
Log.error("Data Sync", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try {
var files = await client.readDir('/');
files.sort((a, b) => b.name!.compareTo(a.name!));
var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera'));
var version =
file!.name!.split('-').elementAtOrNull(1)?.split('.').first;
if (version != null && int.tryParse(version) != null) {
var currentVersion = appdata.settings['dataVersion'];
if (currentVersion != null && int.parse(version) <= currentVersion) {
Log.info("Data Sync", 'No new data to download');
return const Res(true);
}
}
Log.info("Data Sync", "Downloading data from WebDAV server");
var localFile = File(FilePath.join(App.cachePath, file.name!));
await client.read2File(file.name!, localFile.path);
await importAppData(localFile, true);
await localFile.delete();
Log.info("Data Sync", "Data downloaded successfully");
return const Res(true);
} catch (e, s) {
Log.error("Data Sync", e, s);
return Res.error(e.toString());
}
} finally {
isDownloading = false;
notifyListeners();
}
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:typed_data';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
class FileType { class FileType {
@@ -7,6 +5,14 @@ class FileType {
final String mime; final String mime;
const FileType(this.ext, this.mime); const FileType(this.ext, this.mime);
static FileType fromExtension(String ext) {
if(ext.startsWith('.')) {
ext = ext.substring(1);
}
var mime = lookupMimeType('no-file.$ext');
return FileType(".$ext", mime ?? 'application/octet-stream');
}
} }
FileType detectFileType(List<int> data) { FileType detectFileType(List<int> data) {

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_file_dialog/flutter_file_dialog.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart';
@@ -8,6 +9,7 @@ import 'package:venera/utils/ext.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:share_plus/share_plus.dart' as s; import 'package:share_plus/share_plus.dart' as s;
import 'package:file_selector/file_selector.dart' as file_selector; import 'package:file_selector/file_selector.dart' as file_selector;
import 'package:venera/utils/file_type.dart';
export 'dart:io'; export 'dart:io';
export 'dart:typed_data'; export 'dart:typed_data';
@@ -44,6 +46,18 @@ extension FileSystemEntityExt on FileSystemEntity {
// ignore // ignore
} }
} }
Future<void> deleteIfExists({bool recursive = false}) async {
if (existsSync()) {
await delete(recursive: recursive);
}
}
void deleteIfExistsSync({bool recursive = false}) {
if (existsSync()) {
deleteSync(recursive: recursive);
}
}
} }
extension FileExtension on File { extension FileExtension on File {
@@ -73,7 +87,7 @@ extension DirectoryExtension on Directory {
} }
String sanitizeFileName(String fileName) { String sanitizeFileName(String fileName) {
if(fileName.endsWith('.')) { if (fileName.endsWith('.')) {
fileName = fileName.substring(0, fileName.length - 1); fileName = fileName.substring(0, fileName.length - 1);
} }
const maxLength = 255; const maxLength = 255;
@@ -113,6 +127,13 @@ Future<void> copyDirectory(Directory source, Directory destination) async {
} }
} }
Future<void> copyDirectoryIsolate(
Directory source, Directory destination) async {
await Isolate.run(() {
copyDirectory(source, destination);
});
}
String findValidDirectoryName(String path, String directory) { String findValidDirectoryName(String path, String directory) {
var name = sanitizeFileName(directory); var name = sanitizeFileName(directory);
var dir = Directory("$path/$name"); var dir = Directory("$path/$name");
@@ -160,15 +181,48 @@ class DirectoryPicker {
} }
} }
class IOSDirectoryPicker {
static const MethodChannel _channel = MethodChannel("venera/method_channel");
// 调用 iOS 目录选择方法
static Future<String?> selectDirectory() async {
try {
final String? path = await _channel.invokeMethod('selectDirectory');
return path;
} catch (e) {
// 返回报错信息
return e.toString();
}
}
}
Future<file_selector.XFile?> selectFile({required List<String> ext}) async { Future<file_selector.XFile?> selectFile({required List<String> ext}) async {
var extensions = App.isMacOS || App.isIOS ? null : ext;
if (App.isAndroid) {
for (var e in ext) {
var fileType = FileType.fromExtension(e);
if (fileType.mime == "application/octet-stream") {
extensions = null;
break;
}
}
}
file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup( file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup(
label: 'files', label: 'files',
extensions: App.isMacOS || App.isIOS ? null : ext, extensions: extensions,
); );
final file_selector.XFile? file = await file_selector.openFile( file_selector.XFile? file;
if (extensions == null && App.isAndroid) {
const selectFileChannel = MethodChannel("venera/select_file");
var filePath = await selectFileChannel.invokeMethod("selectFile");
if (filePath == null) return null;
file = file_selector.XFile(filePath);
} else {
file = await file_selector.openFile(
acceptedTypeGroups: <file_selector.XTypeGroup>[typeGroup], acceptedTypeGroups: <file_selector.XTypeGroup>[typeGroup],
); );
if (file == null) return null; if (file == null) return null;
}
if (!ext.contains(file.path.split(".").last)) { if (!ext.contains(file.path.split(".").last)) {
App.rootContext.showMessage(message: "Invalid file type"); App.rootContext.showMessage(message: "Invalid file type");
return null; return null;
@@ -181,6 +235,11 @@ Future<String?> selectDirectory() async {
return path; return path;
} }
// selectDirectoryIOS
Future<String?> selectDirectoryIOS() async {
return IOSDirectoryPicker.selectDirectory();
}
Future<void> saveFile( Future<void> saveFile(
{Uint8List? data, required String filename, File? file}) async { {Uint8List? data, required String filename, File? file}) async {
if (data == null && file == null) { if (data == null && file == null) {

31
lib/utils/volume.dart Normal file
View File

@@ -0,0 +1,31 @@
import 'dart:async';
import 'package:flutter/services.dart';
class VolumeListener {
static const channel = EventChannel('venera/volume');
void Function()? onUp;
void Function()? onDown;
VolumeListener({this.onUp, this.onDown});
StreamSubscription? stream;
void listen() {
stream = channel.receiveBroadcastStream().listen(onEvent);
}
void onEvent(event) {
if (event == 1) {
onUp!();
} else if (event == 2) {
onDown!();
}
}
void cancel() {
stream?.cancel();
}
}

View File

@@ -1,43 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_qjs/flutter_qjs_plugin.h>
#include <gtk/gtk_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin");
desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_qjs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterQjsPlugin");
flutter_qjs_plugin_register_with_registrar(flutter_qjs_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
g_autoptr(FlPluginRegistrar) window_manager_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
window_manager_plugin_register_with_registrar(window_manager_registrar);
}

View File

@@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@@ -1,34 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
desktop_webview_window
file_selector_linux
flutter_qjs
gtk
screen_retriever
sqlite3_flutter_libs
url_launcher_linux
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
lodepng_flutter
rhttp
zip_flutter
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@@ -1,30 +0,0 @@
//
// Generated file. Do not edit.
//
import FlutterMacOS
import Foundation
import app_links
import desktop_webview_window
import file_selector_macos
import flutter_inappwebview_macos
import path_provider_foundation
import screen_retriever
import share_plus
import sqlite3_flutter_libs
import url_launcher_macos
import window_manager
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
}

View File

@@ -49,6 +49,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
battery_plus:
dependency: "direct main"
description:
name: battery_plus
sha256: "220c8f1961efb01d6870493b5ac5a80afaeaffc8757f7a11ed3025a8570d29e7"
url: "https://pub.dev"
source: hosted
version: "6.2.0"
battery_plus_platform_interface:
dependency: transitive
description:
name: battery_plus_platform_interface
sha256: e8342c0f32de4b1dfd0223114b6785e48e579bfc398da9471c9179b907fa4910
url: "https://pub.dev"
source: hosted
version: "2.0.1"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -109,10 +125,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.5" version: "3.0.6"
csslib: csslib:
dependency: transitive dependency: transitive
description: description:
@@ -121,6 +137,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
dbus:
dependency: transitive
description:
name: dbus
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
desktop_webview_window: desktop_webview_window:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -332,6 +356,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398"
url: "https://pub.dev"
source: hosted
version: "2.0.23"
flutter_qjs: flutter_qjs:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -345,10 +377,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_reorderable_grid_view name: flutter_reorderable_grid_view
sha256: "40abcc5bff228ebff119326502e7357ee6399956b60b80b17385e9770b7458c0" sha256: "93a2b9e279bf40b9333428a67e70e520ca1528554984eb6f6304538400897e64"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.1" version: "5.3.2"
flutter_rust_bridge: flutter_rust_bridge:
dependency: transitive dependency: transitive
description: description:
@@ -404,10 +436,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: html name: html
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.15.4" version: "0.15.5"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -488,6 +520,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: "6763aaf8965f21822624cb2fd3c03d2a8b3791037b5efb0fe4b13e110f5afc92"
url: "https://pub.dev"
source: hosted
version: "1.0.46"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "6d2950da311d26d492a89aeb247c72b4653ddc93601ea36a84924a396806d49c"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a"
url: "https://pub.dev"
source: hosted
version: "1.0.10"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
lodepng_flutter: lodepng_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -593,6 +665,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "6.0.2"
photo_view: photo_view:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -638,10 +718,42 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: screen_retriever name: screen_retriever
sha256: "6ee02c8a1158e6dae7ca430da79436e3b1c9563c8cf02f524af997c201ac2b90" sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.9" version: "0.2.0"
screen_retriever_linux:
dependency: transitive
description:
name: screen_retriever_linux
sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18
url: "https://pub.dev"
source: hosted
version: "0.2.0"
screen_retriever_macos:
dependency: transitive
description:
name: screen_retriever_macos
sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
screen_retriever_platform_interface:
dependency: transitive
description:
name: screen_retriever_platform_interface
sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0
url: "https://pub.dev"
source: hosted
version: "0.2.0"
screen_retriever_windows:
dependency: transitive
description:
name: screen_retriever_windows
sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
scrollable_positioned_list: scrollable_positioned_list:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -700,10 +812,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: sqlite3 name: sqlite3
sha256: "45f168ae2213201b54e09429ed0c593dc2c88c924a1488d6f9c523a255d567cb" sha256: bb174b3ec2527f9c5f680f73a89af8149dd99782fbb56ea88ad0807c5638f2ed
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.6" version: "2.4.7"
sqlite3_flutter_libs: sqlite3_flutter_libs:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -760,6 +872,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.2"
upower:
dependency: transitive
description:
name: upower
sha256: cf042403154751180affa1d15614db7fa50234bc2373cd21c3db666c38543ebf
url: "https://pub.dev"
source: hosted
version: "0.7.0"
url_launcher: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -856,6 +976,15 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
webdav_client:
dependency: "direct main"
description:
path: "."
ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
resolved-ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
url: "https://github.com/wgh136/webdav_client"
source: git
version: "1.2.2"
win32: win32:
dependency: transitive dependency: transitive
description: description:
@@ -868,10 +997,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: window_manager name: window_manager
sha256: ab8b2a7f97543d3db2b506c9d875e637149d48ee0c6a5cb5f5fd6e0dac463792 sha256: "732896e1416297c63c9e3fb95aea72d0355f61390263982a47fd519169dc5059"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.4.2" version: "0.4.3"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -880,6 +1009,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml: yaml:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -899,4 +1036,4 @@ packages:
version: "0.0.1" version: "0.0.1"
sdks: sdks:
dart: ">=3.5.4 <4.0.0" dart: ">=3.5.4 <4.0.0"
flutter: ">=3.24.4" flutter: ">=3.24.5"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.0.2+102 version: 1.0.6+106
environment: environment:
sdk: '>=3.5.0 <4.0.0' sdk: '>=3.5.0 <4.0.0'
flutter: 3.24.4 flutter: 3.24.5
dependencies: dependencies:
flutter: flutter:
@@ -15,16 +15,16 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
intl: any intl: any
window_manager: ^0.4.2 window_manager: ^0.4.3
sqlite3: any sqlite3: ^2.4.7
sqlite3_flutter_libs: any sqlite3_flutter_libs: any
flutter_qjs: flutter_qjs:
git: git:
url: https://github.com/wgh136/flutter_qjs url: https://github.com/wgh136/flutter_qjs
ref: ade0b9d ref: ade0b9d
crypto: any crypto: ^3.0.6
dio: any dio: ^5.7.0
html: any html: ^0.15.5
pointycastle: any pointycastle: any
url_launcher: ^6.3.0 url_launcher: ^6.3.0
path: ^1.9.0 path: ^1.9.0
@@ -39,7 +39,7 @@ dependencies:
url: https://github.com/venera-app/flutter.widgets url: https://github.com/venera-app/flutter.widgets
ref: 09e756b1f1b04e6298318d99ec20a787fb360f59 ref: 09e756b1f1b04e6298318d99ec20a787fb360f59
path: packages/scrollable_positioned_list path: packages/scrollable_positioned_list
flutter_reorderable_grid_view: 5.0.1 flutter_reorderable_grid_view: 5.3.2
yaml: any yaml: any
uuid: ^4.5.1 uuid: ^4.5.1
desktop_webview_window: desktop_webview_window:
@@ -59,6 +59,12 @@ dependencies:
url: https://github.com/venera-app/lodepng_flutter url: https://github.com/venera-app/lodepng_flutter
ref: d1c96cd6503103b3270dfe2f320d4a1c93780f53 ref: d1c96cd6503103b3270dfe2f320d4a1c93780f53
rhttp: 0.9.1 rhttp: 0.9.1
webdav_client:
git:
url: https://github.com/wgh136/webdav_client
ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1
battery_plus: ^6.2.0
local_auth: ^2.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -33,7 +33,7 @@ WizardStyle=modern
[Languages] [Languages]
Name: "english"; MessagesFile: "compiler:Default.isl" Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" Name: "chinesesimplified"; MessagesFile: "{#RootPath}\windows\ChineseSimplified.isl"
[Tasks] [Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
@@ -51,10 +51,13 @@ Source: "{#RootPath}\build\windows\x64\runner\Release\desktop_webview_window_plu
Source: "{#RootPath}\build\windows\x64\runner\Release\WebView2Loader.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\WebView2Loader.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\screen_retriever_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\battery_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\screen_retriever_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\local_auth_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\zip_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\zip_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "{#RootPath}\build\windows\x64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files ; NOTE: Don't use "Flags: ignoreversion" on any shared system files

View File

@@ -1,5 +1,6 @@
import subprocess import subprocess
import os import os
import httpx
file = open('pubspec.yaml', 'r') file = open('pubspec.yaml', 'r')
content = file.read() content = file.read()
@@ -26,6 +27,13 @@ file = open('windows/build.iss', 'w')
file.write(newContent) file.write(newContent)
file.close() file.close()
if not os.path.exists("windows/ChineseSimplified.isl"):
# download ChineseSimplified.isl
url = "https://raw.githubusercontent.com/kira-96/Inno-Setup-Chinese-Simplified-Translation/refs/heads/main/ChineseSimplified.isl"
response = httpx.get(url)
with open('windows/ChineseSimplified.isl', 'wb') as file:
file.write(response.content)
subprocess.run(["iscc", "windows/build.iss"], shell=True) subprocess.run(["iscc", "windows/build.iss"], shell=True)
with open('windows/build.iss', 'w') as file: with open('windows/build.iss', 'w') as file:

View File

@@ -1,41 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <desktop_webview_window/desktop_webview_window_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_qjs/flutter_qjs_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
DesktopWebviewWindowPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
FlutterQjsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterQjsPlugin"));
ScreenRetrieverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
}

View File

@@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter/plugin_registry.h>
// Registers Flutter plugins.
void RegisterPlugins(flutter::PluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@@ -1,36 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
app_links
desktop_webview_window
file_selector_windows
flutter_inappwebview_windows
flutter_qjs
screen_retriever
share_plus
sqlite3_flutter_libs
url_launcher_windows
window_manager
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
lodepng_flutter
rhttp
zip_flutter
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@@ -123,6 +123,26 @@ Win32Window::~Win32Window() {
bool Win32Window::Create(const std::wstring& title, bool Win32Window::Create(const std::wstring& title,
const Point& origin, const Point& origin,
const Size& size) { const Size& size) {
HWND hwnd = ::FindWindow(kWindowClassName, title.c_str());
if (hwnd) {
WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) };
GetWindowPlacement(hwnd, &place);
SetForegroundWindow(hwnd);
switch (place.showCmd) {
case SW_SHOWMAXIMIZED:
ShowWindow(hwnd, SW_SHOWMAXIMIZED);
break;
case SW_SHOWMINIMIZED:
ShowWindow(hwnd, SW_RESTORE);
break;
default:
ShowWindow(hwnd, SW_NORMAL);
break;
}
SetWindowPos(0, HWND_TOP, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE);
return false;
}
Destroy(); Destroy();
const wchar_t* window_class = const wchar_t* window_class =