mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
Compare commits
56 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ce48a89cc1 | ||
f155bed694 | |||
1500d2a1d2 | |||
2408096a7c | |||
bf1930cea2 | |||
5d99b6ed99 | |||
e2aceb857d | |||
4b32165aae | |||
5bc3ddaf26 | |||
904e4f1186 | |||
511a9fdc09 | |||
c2b8760d86 | |||
![]() |
a1474ca9c3 | ||
![]() |
c3474b1dff | ||
![]() |
2f290f0c86 | ||
![]() |
8b1f13cd33 | ||
f3aa0e9f27 | |||
f4b9cb5abe | |||
4d55e6a72f | |||
ad3f2fab45 | |||
b1cdcc2a91 | |||
7fcb63c0cb | |||
454497fd65 | |||
![]() |
c4aab2369f | ||
ce175a2135 | |||
6aeaeadb10 | |||
![]() |
8402c1c9f3 | ||
ed67bc80ea | |||
![]() |
eb3a7f9d52 | ||
![]() |
0d77803e8c | ||
8db52c9db1 | |||
ce6f65f912 | |||
689700f52a | |||
250f458029 | |||
1489e6c86d | |||
b4921c8e14 | |||
800b67fb28 | |||
![]() |
036474a5d2 | ||
a1d1f504bd | |||
458bc261f3 | |||
00af5f1989 | |||
9988e76149 | |||
213179b8c2 | |||
708cf83a32 | |||
0ee99a8760 | |||
30a1c806cd | |||
![]() |
7bc0aeb4af | ||
8513a739ec | |||
![]() |
d749e7421e | ||
165e5f2850 | |||
edff9c7a0c | |||
![]() |
65b41b2873 | ||
![]() |
f912e57bfd | ||
![]() |
2ef03ad7ae | ||
![]() |
47eb597d96 | ||
0ac9ee7061 |
33
.github/workflows/linux.yml
vendored
33
.github/workflows/linux.yml
vendored
@@ -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/
|
80
.github/workflows/main.yml
vendored
80
.github/workflows/main.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Build IOS
|
||||
run-name: Build IOS
|
||||
name: Build ALL
|
||||
run-name: Build ALL
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
jobs:
|
||||
@@ -63,3 +63,79 @@ jobs:
|
||||
with:
|
||||
name: app-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/
|
||||
|
||||
|
1
android/.gitignore
vendored
1
android/.gitignore
vendored
@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
/app/.cxx/
|
||||
|
@@ -34,6 +34,8 @@ android {
|
||||
|
||||
splits{
|
||||
abi {
|
||||
reset()
|
||||
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||
enable true
|
||||
universalApk true
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@
|
||||
<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
|
||||
android:label="venera"
|
||||
android:name="${applicationName}"
|
||||
|
@@ -1,101 +1,68 @@
|
||||
package com.github.wgh136.venera
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.view.KeyEvent
|
||||
import android.Manifest
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
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 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.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugins.GeneratedPluginRegistrant
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.lang.Exception
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
var volumeListen = VolumeListen()
|
||||
var listening = false
|
||||
|
||||
private val pickDirectoryCode = 1
|
||||
|
||||
private lateinit var result: MethodChannel.Result
|
||||
|
||||
private val storageRequestCode = 0x10
|
||||
private var storagePermissionRequest: ((Boolean) -> Unit)? = null
|
||||
|
||||
private val selectFileCode = 0x11
|
||||
private val nextLocalRequestCode = AtomicInteger()
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == pickDirectoryCode) {
|
||||
if(resultCode != Activity.RESULT_OK) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
val pickedDirectoryUri = data?.data
|
||||
if (pickedDirectoryUri == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
Thread {
|
||||
try {
|
||||
result.success(onPickedDirectory(pickedDirectoryUri))
|
||||
private fun <I, O> startContractForResult(
|
||||
contract: ActivityResultContract<I, O>,
|
||||
input: I,
|
||||
callback: ActivityResultCallback<O>
|
||||
) {
|
||||
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)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
result.error("Failed to Copy Files", e.toString(), null)
|
||||
}
|
||||
}.start()
|
||||
} else if (requestCode == storageRequestCode) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
storagePermissionRequest?.invoke(Environment.isExternalStorageManager())
|
||||
}
|
||||
storagePermissionRequest = null
|
||||
} else if (requestCode == selectFileCode) {
|
||||
if (resultCode != Activity.RESULT_OK) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
val uri = data?.data
|
||||
if (uri == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
val contentResolver = context.contentResolver
|
||||
val file = DocumentFile.fromSingleUri(context, uri)
|
||||
if (file == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
val fileName = file.name
|
||||
if (fileName == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
// copy file to cache directory
|
||||
val cacheDir = context.cacheDir
|
||||
val newFile = File(cacheDir, fileName)
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
val outputStream = FileOutputStream(newFile)
|
||||
inputStream.copyTo(outputStream)
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
// send file path to flutter
|
||||
result.success(newFile.absolutePath)
|
||||
}
|
||||
lifecycle.addObserver(observer)
|
||||
val newCallback = ActivityResultCallback<O> {
|
||||
launcher?.unregister()
|
||||
lifecycle.removeObserver(observer)
|
||||
callback.onActivityResult(it)
|
||||
}
|
||||
launcher = registry.register(key, contract, newCallback)
|
||||
launcher.launch(input)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
@@ -115,12 +82,23 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
res.success(null)
|
||||
}
|
||||
|
||||
"getDirectoryPath" -> {
|
||||
this.result = res
|
||||
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)
|
||||
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
|
||||
onPickedDirectory(pickedDirectoryUri, res)
|
||||
}
|
||||
}
|
||||
|
||||
else -> res.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -137,6 +115,7 @@ class MainActivity : FlutterActivity() {
|
||||
events.success(2)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
listening = false
|
||||
}
|
||||
@@ -144,15 +123,15 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
val storageChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/storage")
|
||||
storageChannel.setMethodCallHandler { _, res ->
|
||||
requestStoragePermission {result ->
|
||||
requestStoragePermission { result ->
|
||||
res.success(result)
|
||||
}
|
||||
}
|
||||
|
||||
val selectFileChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/select_file")
|
||||
selectFileChannel.setMethodCallHandler { _, res ->
|
||||
openFile()
|
||||
result = res
|
||||
selectFileChannel.setMethodCallHandler { req, res ->
|
||||
val mimeType = req.arguments<String>()
|
||||
openFile(res, mimeType!!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,12 +146,13 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
if(listening){
|
||||
if (listening) {
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
volumeListen.down()
|
||||
return true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> {
|
||||
volumeListen.up()
|
||||
return true
|
||||
@@ -182,37 +162,77 @@ class MainActivity : FlutterActivity() {
|
||||
return super.onKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
/// copy the directory to tmp directory, return copied directory
|
||||
private fun onPickedDirectory(uri: Uri): String {
|
||||
val contentResolver = context.contentResolver
|
||||
var tmp = context.cacheDir
|
||||
tmp = File(tmp, "getDirectoryPathTemp")
|
||||
/// Ensure that the directory is accessible by dart:io
|
||||
private fun onPickedDirectory(uri: Uri, result: MethodChannel.Result) {
|
||||
if (hasStoragePermission()) {
|
||||
var plain = uri.toString()
|
||||
if(plain.contains("%3A")) {
|
||||
plain = Uri.decode(plain)
|
||||
}
|
||||
val externalStoragePrefix = "content://com.android.externalstorage.documents/tree/primary:";
|
||||
if(plain.startsWith(externalStoragePrefix)) {
|
||||
val path = plain.substring(externalStoragePrefix.length)
|
||||
result.success(Environment.getExternalStorageDirectory().absolutePath + "/" + path)
|
||||
}
|
||||
// The uri cannot be parsed to plain path, use copy method
|
||||
}
|
||||
// dart:io cannot access the directory without permission.
|
||||
// so we need to copy the directory to cache directory
|
||||
val contentResolver = contentResolver
|
||||
var tmp = cacheDir
|
||||
var dirName = DocumentFile.fromTreeUri(this, uri)?.name
|
||||
tmp = File(tmp, dirName!!)
|
||||
if(tmp.exists()) {
|
||||
tmp.deleteRecursively()
|
||||
}
|
||||
tmp.mkdir()
|
||||
copyDirectory(contentResolver, uri, tmp)
|
||||
Thread {
|
||||
try {
|
||||
copyDirectory(contentResolver, uri, tmp)
|
||||
result.success(tmp.absolutePath)
|
||||
}
|
||||
catch (e: Exception) {
|
||||
result.error("copy error", e.message, null)
|
||||
}
|
||||
}.start()
|
||||
|
||||
return tmp.absolutePath
|
||||
}
|
||||
|
||||
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()) {
|
||||
if(file.isDirectory) {
|
||||
if (file.isDirectory) {
|
||||
val newDir = File(destDir, file.name!!)
|
||||
newDir.mkdir()
|
||||
copyDirectory(resolver, file.uri, newDir)
|
||||
} else {
|
||||
val newFile = File(destDir, file.name!!)
|
||||
val inputStream = resolver.openInputStream(file.uri) ?: return
|
||||
val outputStream = FileOutputStream(newFile)
|
||||
inputStream.copyTo(outputStream)
|
||||
inputStream.close()
|
||||
outputStream.close()
|
||||
resolver.openInputStream(file.uri)?.use { input ->
|
||||
FileOutputStream(newFile).use { output ->
|
||||
input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE)
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
val readPermission = ContextCompat.checkSelfPermission(
|
||||
this,
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
@@ -241,8 +261,10 @@ class MainActivity : FlutterActivity() {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.addCategory("android.intent.category.DEFAULT")
|
||||
intent.data = Uri.parse("package:" + context.packageName)
|
||||
startActivityForResult(intent, storageRequestCode)
|
||||
intent.data = Uri.parse("package:$packageName")
|
||||
startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ _ ->
|
||||
result(Environment.isExternalStorageManager())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result(false)
|
||||
}
|
||||
@@ -258,7 +280,7 @@ class MainActivity : FlutterActivity() {
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if(requestCode == storageRequestCode) {
|
||||
if (requestCode == storageRequestCode) {
|
||||
storagePermissionRequest?.invoke(grantResults.all {
|
||||
it == PackageManager.PERMISSION_GRANTED
|
||||
})
|
||||
@@ -266,21 +288,56 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun openFile() {
|
||||
private fun openFile(result: MethodChannel.Result, mimeType: String) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "*/*"
|
||||
startActivityForResult(intent, selectFileCode)
|
||||
intent.type = mimeType
|
||||
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
|
||||
}
|
||||
}
|
||||
// use copy method
|
||||
val filePath = FileUtils.getPathFromCopyOfFileFromUri(this, uri)
|
||||
result.success(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VolumeListen{
|
||||
class VolumeListen {
|
||||
var onUp = fun() {}
|
||||
var onDown = fun() {}
|
||||
fun up(){
|
||||
fun up() {
|
||||
onUp()
|
||||
}
|
||||
fun down(){
|
||||
|
||||
fun down() {
|
||||
onDown()
|
||||
}
|
||||
}
|
||||
|
@@ -880,8 +880,8 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
|
||||
* @param cover {string}
|
||||
* @param description {string?}
|
||||
* @param tags {Map<string, string[]> | {} | null | undefined}
|
||||
* @param chapters {Map<string, string> | {} | null | undefined}} - key: chapter id, value: chapter title
|
||||
* @param isFavorite {boolean | null | undefined}} - favorite status. If the comic source supports multiple folders, this field should be null
|
||||
* @param chapters {Map<string, string> | {} | null | undefined} - key: chapter id, value: chapter title
|
||||
* @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null
|
||||
* @param subId {string?} - a param which is passed to comments api
|
||||
* @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
|
||||
* @param recommend {Comic[]?} - related comics
|
||||
@@ -894,9 +894,10 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
|
||||
* @param url {string?}
|
||||
* @param stars {number?} - 0-5, double
|
||||
* @param maxPage {number?}
|
||||
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
|
||||
* @constructor
|
||||
*/
|
||||
function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage}) {
|
||||
function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
|
||||
this.title = title;
|
||||
this.cover = cover;
|
||||
this.description = description;
|
||||
@@ -915,6 +916,7 @@ function ComicDetails({title, cover, description, tags, chapters, isFavorite, su
|
||||
this.url = url;
|
||||
this.stars = stars;
|
||||
this.maxPage = maxPage;
|
||||
this.comments = comments;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -41,12 +41,18 @@
|
||||
"Select a folder": "选择一个文件夹",
|
||||
"Folder": "文件夹",
|
||||
"Confirm": "确认",
|
||||
"Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?",
|
||||
"Are you sure you want to delete @a selected comics?": "您确定要删除 @a 部漫画吗?",
|
||||
"Add comic source": "添加漫画来源",
|
||||
"Remove comic from favorite?": "从收藏中移除漫画?",
|
||||
"Move": "移动",
|
||||
"Move to folder": "移动到文件夹",
|
||||
"Copy to folder": "复制到文件夹",
|
||||
"Delete Comic": "删除漫画",
|
||||
"Delete @c comics?": "删除 @c 本漫画?",
|
||||
"Add comic source": "添加漫画源",
|
||||
"Delete comic source '@n' ?": "删除漫画源 '@n' ?",
|
||||
"Select file": "选择文件",
|
||||
"View list": "查看列表",
|
||||
"Open help": "打开帮助",
|
||||
"Open in Browser": "打开网页",
|
||||
"Check updates": "检查更新",
|
||||
"Edit": "编辑",
|
||||
"Update": "更新",
|
||||
@@ -101,8 +107,8 @@
|
||||
"Auto page turning interval": "自动翻页间隔",
|
||||
"Theme Mode": "主题模式",
|
||||
"System": "系统",
|
||||
"Light": "明亮",
|
||||
"Dark": "黑暗",
|
||||
"Light": "浅色",
|
||||
"Dark": "深色",
|
||||
"Theme Color": "主题颜色",
|
||||
"Red": "红色",
|
||||
"Pink": "粉色",
|
||||
@@ -131,7 +137,8 @@
|
||||
"Block": "屏蔽",
|
||||
"Add new favorite to": "添加新收藏到",
|
||||
"Move favorite after reading": "阅读后移动收藏",
|
||||
"Are you sure you want to delete this folder?" : "确定要删除这个收藏夹吗?",
|
||||
"Delete folder?" : "刪除文件夾?",
|
||||
"Delete folder '@f' ?" : "删除文件夹 '@f' ?",
|
||||
"Import from file": "从文件导入",
|
||||
"Failed to import": "导入失败",
|
||||
"Cache Limit": "缓存限制",
|
||||
@@ -209,7 +216,35 @@
|
||||
"Update Comics Info": "更新漫画信息",
|
||||
"Create Folder": "新建文件夹",
|
||||
"Select an image on screen": "选择屏幕上的图片",
|
||||
"Added @count comics to download queue.": "已添加 @count 本漫画到下载队列"
|
||||
"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": "没有分类页面",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
"Page @page": "第 @page 页",
|
||||
"Also remove files on disk": "同时删除磁盘上的文件",
|
||||
"Copy to app local path": "将漫画复制到本地存储目录中",
|
||||
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
|
||||
"Deleted @a favorite items.": "已删除 @a 条无效收藏",
|
||||
"New version available": "有新版本可用",
|
||||
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
|
||||
"No new version available": "没有新版本可用"
|
||||
},
|
||||
"zh_TW": {
|
||||
"Home": "首頁",
|
||||
@@ -255,12 +290,18 @@
|
||||
"Select a folder": "選擇一個文件夾",
|
||||
"Folder": "文件夾",
|
||||
"Confirm": "確認",
|
||||
"Are you sure you want to delete this comic?": "您確定要刪除這部漫畫嗎?",
|
||||
"Are you sure you want to delete @a selected comics?": "您確定要刪除 @a 部漫畫嗎?",
|
||||
"Add comic source": "添加漫畫來源",
|
||||
"Remove comic from favorite?": "從收藏中移除漫畫?",
|
||||
"Move": "移動",
|
||||
"Move to folder": "移動到文件夾",
|
||||
"Copy to folder": "複製到文件夾",
|
||||
"Delete Comic": "刪除漫畫",
|
||||
"Delete @c comics?": "刪除 @c 本漫畫?",
|
||||
"Add comic source": "添加漫畫源",
|
||||
"Delete comic source '@n' ?": "刪除漫畫源 '@n' ?",
|
||||
"Select file": "選擇文件",
|
||||
"View list": "查看列表",
|
||||
"Open help": "打開幫助",
|
||||
"Open in Browser": "打開網頁",
|
||||
"Check updates": "檢查更新",
|
||||
"Edit": "編輯",
|
||||
"Update": "更新",
|
||||
@@ -313,8 +354,8 @@
|
||||
"Auto page turning interval": "自動翻頁間隔",
|
||||
"Theme Mode": "主題模式",
|
||||
"System": "系統",
|
||||
"Light": "明亮",
|
||||
"Dark": "黑暗",
|
||||
"Light": "浅色",
|
||||
"Dark": "深色",
|
||||
"Theme Color": "主題顏色",
|
||||
"Red": "紅色",
|
||||
"Pink": "粉色",
|
||||
@@ -343,7 +384,8 @@
|
||||
"Block": "屏蔽",
|
||||
"Add new favorite to": "添加新收藏到",
|
||||
"Move favorite after reading": "閱讀後移動收藏",
|
||||
"Are you sure you want to delete this folder?" : "確定要刪除這個收藏夾嗎?",
|
||||
"Delete folder?" : "刪除文件夾?",
|
||||
"Delete folder '@f' ?" : "刪除文件夾 '@f' ?",
|
||||
"Import from file": "從文件匯入",
|
||||
"Failed to import": "匯入失敗",
|
||||
"Cache Limit": "緩存限制",
|
||||
@@ -421,6 +463,34 @@
|
||||
"Update Comics Info": "更新漫畫信息",
|
||||
"Create Folder": "新建文件夾",
|
||||
"Select an image on screen": "選擇屏幕上的圖片",
|
||||
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載隊列"
|
||||
"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": "沒有分類頁面",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
"Page @page": "第 @page 頁",
|
||||
"Also remove files on disk": "同時刪除磁盤上的文件",
|
||||
"Copy to app local path": "將漫畫複製到本地儲存目錄中",
|
||||
"Delete all unavailable local favorite items": "刪除所有無效的本地收藏",
|
||||
"Deleted @a favorite items.": "已刪除 @a 條無效收藏",
|
||||
"New version available": "有新版本可用",
|
||||
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
|
||||
"No new version available": "沒有新版本可用"
|
||||
}
|
||||
}
|
@@ -51,5 +51,7 @@
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Ensure that the operation is being performed by the user themselves.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@@ -115,6 +115,11 @@ class _AppbarState extends State<Appbar> {
|
||||
}
|
||||
}
|
||||
|
||||
enum AppbarStyle {
|
||||
blur,
|
||||
shadow,
|
||||
}
|
||||
|
||||
class SliverAppbar extends StatelessWidget {
|
||||
const SliverAppbar({
|
||||
super.key,
|
||||
@@ -122,6 +127,7 @@ class SliverAppbar extends StatelessWidget {
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.radius = 0,
|
||||
this.style = AppbarStyle.blur,
|
||||
});
|
||||
|
||||
final Widget? leading;
|
||||
@@ -132,6 +138,8 @@ class SliverAppbar extends StatelessWidget {
|
||||
|
||||
final double radius;
|
||||
|
||||
final AppbarStyle style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverPersistentHeader(
|
||||
@@ -142,6 +150,7 @@ class SliverAppbar extends StatelessWidget {
|
||||
actions: actions,
|
||||
topPadding: MediaQuery.of(context).padding.top,
|
||||
radius: radius,
|
||||
style: style,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -160,57 +169,74 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
|
||||
final double radius;
|
||||
|
||||
_MySliverAppBarDelegate(
|
||||
{this.leading,
|
||||
required this.title,
|
||||
this.actions,
|
||||
required this.topPadding,
|
||||
this.radius = 0});
|
||||
final AppbarStyle style;
|
||||
|
||||
_MySliverAppBarDelegate({
|
||||
this.leading,
|
||||
required this.title,
|
||||
this.actions,
|
||||
required this.topPadding,
|
||||
this.radius = 0,
|
||||
this.style = AppbarStyle.blur,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(
|
||||
BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||||
return SizedBox.expand(
|
||||
child: BlurEffect(
|
||||
blur: 15,
|
||||
child: Material(
|
||||
color: context.colorScheme.surface.withOpacity(0.72),
|
||||
elevation: 0,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
leading ??
|
||||
(Navigator.of(context).canPop()
|
||||
? Tooltip(
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
)
|
||||
: const SizedBox()),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
var body = Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
leading ??
|
||||
(Navigator.of(context).canPop()
|
||||
? Tooltip(
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: title,
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
)
|
||||
],
|
||||
).paddingTop(topPadding),
|
||||
)
|
||||
: const SizedBox()),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style:
|
||||
DefaultTextStyle.of(context).style.copyWith(fontSize: 20),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: title,
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
)
|
||||
],
|
||||
).paddingTop(topPadding);
|
||||
|
||||
if(style == AppbarStyle.blur) {
|
||||
return SizedBox.expand(
|
||||
child: BlurEffect(
|
||||
blur: 15,
|
||||
child: Material(
|
||||
color: context.colorScheme.surface.withOpacity(0.72),
|
||||
elevation: 0,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SizedBox.expand(
|
||||
child: Material(
|
||||
color: context.colorScheme.surface,
|
||||
elevation: shrinkOffset == 0 ? 0 : 2,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -224,7 +250,10 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
return oldDelegate is! _MySliverAppBarDelegate ||
|
||||
leading != oldDelegate.leading ||
|
||||
title != oldDelegate.title ||
|
||||
actions != oldDelegate.actions;
|
||||
actions != oldDelegate.actions ||
|
||||
topPadding != oldDelegate.topPadding ||
|
||||
radius != oldDelegate.radius ||
|
||||
style != oldDelegate.style;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,10 +398,14 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
final double tabWidth = tabRight - tabLeft;
|
||||
final double tabCenter = tabLeft + tabWidth / 2;
|
||||
final double tabBarWidth = tabBarBox.size.width;
|
||||
final double scrollOffset = tabCenter - tabBarWidth / 2;
|
||||
double scrollOffset = tabCenter - tabBarWidth / 2;
|
||||
if (scrollOffset == scrollController.offset) {
|
||||
return;
|
||||
}
|
||||
scrollOffset = scrollOffset.clamp(
|
||||
0.0,
|
||||
scrollController.position.maxScrollExtent,
|
||||
);
|
||||
scrollController.animateTo(
|
||||
scrollOffset,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
|
@@ -1,14 +1,14 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class ComicTile extends StatelessWidget {
|
||||
const ComicTile({
|
||||
super.key,
|
||||
required this.comic,
|
||||
this.enableLongPressed = true,
|
||||
this.badge,
|
||||
this.menuOptions,
|
||||
this.onTap,
|
||||
});
|
||||
const ComicTile(
|
||||
{super.key,
|
||||
required this.comic,
|
||||
this.enableLongPressed = true,
|
||||
this.badge,
|
||||
this.menuOptions,
|
||||
this.onTap,
|
||||
this.onLongPressed});
|
||||
|
||||
final Comic comic;
|
||||
|
||||
@@ -20,6 +20,8 @@ class ComicTile extends StatelessWidget {
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
final VoidCallback? onLongPressed;
|
||||
|
||||
void _onTap() {
|
||||
if (onTap != null) {
|
||||
onTap!();
|
||||
@@ -29,6 +31,14 @@ class ComicTile extends StatelessWidget {
|
||||
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
|
||||
}
|
||||
|
||||
void _onLongPressed(context) {
|
||||
if (onLongPressed != null) {
|
||||
onLongPressed!();
|
||||
return;
|
||||
}
|
||||
onLongPress(context);
|
||||
}
|
||||
|
||||
void onLongPress(BuildContext context) {
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
var size = renderBox.size;
|
||||
@@ -154,8 +164,6 @@ class ComicTile extends StatelessWidget {
|
||||
ImageProvider image;
|
||||
if (comic is LocalComic) {
|
||||
image = FileImage((comic as LocalComic).coverFile);
|
||||
} else if (comic.cover.startsWith('file://')) {
|
||||
image = FileImage(File(comic.cover.substring(7)));
|
||||
} else if (comic.sourceKey == 'local') {
|
||||
var localComic = LocalManager().find(comic.id, ComicType.local);
|
||||
if (localComic == null) {
|
||||
@@ -183,7 +191,7 @@ class ComicTile extends StatelessWidget {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: _onTap,
|
||||
onLongPress: enableLongPressed ? () => onLongPress(context) : null,
|
||||
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
|
||||
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
|
||||
@@ -226,75 +234,137 @@ class ComicTile extends StatelessWidget {
|
||||
|
||||
Widget _buildBriefMode(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
elevation: 1,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: buildImage(context),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 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),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: _onTap,
|
||||
onLongPress:
|
||||
enableLongPressed ? () => _onLongPressed(context) : null,
|
||||
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: buildImage(context),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: (() {
|
||||
final subtitle =
|
||||
comic.subtitle?.replaceAll('\n', '').trim();
|
||||
final text = comic.description.isNotEmpty
|
||||
? comic.description.split('|').join('\n')
|
||||
: (subtitle?.isNotEmpty == true
|
||||
? subtitle
|
||||
: null);
|
||||
final scale =
|
||||
(appdata.settings['comicTileScale'] as num)
|
||||
.toDouble();
|
||||
final fortSize = scale < 0.85
|
||||
? 8.0 // 小尺寸
|
||||
: (scale < 1.0 ? 10.0 : 12.0);
|
||||
|
||||
if (text == null) {
|
||||
return const SizedBox
|
||||
.shrink(); // 如果没有文本,则不显示任何内容
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 2, vertical: 2),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
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,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: fortSize,
|
||||
color: Colors.white,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
})(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
||||
child: Text(
|
||||
comic.title.replaceAll("\n", ""),
|
||||
comic.title.replaceAll('\n', ''),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14.0,
|
||||
color: Colors.white,
|
||||
),
|
||||
maxLines: 2,
|
||||
maxLines: 1,
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -303,7 +373,7 @@ class ComicTile extends StatelessWidget {
|
||||
builder: (context) {
|
||||
var words = <String>[];
|
||||
var all = <String>[];
|
||||
all.addAll(comic.title.split(' ').where((element) => element != ''));
|
||||
all.addAll(_splitText(comic.title));
|
||||
if (comic.subtitle != null && comic.subtitle != "") {
|
||||
all.add(comic.subtitle!);
|
||||
}
|
||||
@@ -584,6 +654,7 @@ class SliverGridComics extends StatefulWidget {
|
||||
this.badgeBuilder,
|
||||
this.menuBuilder,
|
||||
this.onTap,
|
||||
this.onLongPressed,
|
||||
this.selections});
|
||||
|
||||
final List<Comic> comics;
|
||||
@@ -598,6 +669,8 @@ class SliverGridComics extends StatefulWidget {
|
||||
|
||||
final void Function(Comic)? onTap;
|
||||
|
||||
final void Function(Comic)? onLongPressed;
|
||||
|
||||
@override
|
||||
State<SliverGridComics> createState() => _SliverGridComicsState();
|
||||
}
|
||||
@@ -648,6 +721,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
|
||||
badgeBuilder: widget.badgeBuilder,
|
||||
menuBuilder: widget.menuBuilder,
|
||||
onTap: widget.onTap,
|
||||
onLongPressed: widget.onLongPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -659,6 +733,7 @@ class _SliverGridComics extends StatelessWidget {
|
||||
this.badgeBuilder,
|
||||
this.menuBuilder,
|
||||
this.onTap,
|
||||
this.onLongPressed,
|
||||
this.selection,
|
||||
});
|
||||
|
||||
@@ -674,6 +749,8 @@ class _SliverGridComics extends StatelessWidget {
|
||||
|
||||
final void Function(Comic)? onTap;
|
||||
|
||||
final void Function(Comic)? onLongPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverGrid(
|
||||
@@ -690,11 +767,18 @@ class _SliverGridComics extends StatelessWidget {
|
||||
badge: badge,
|
||||
menuOptions: menuBuilder?.call(comics[index]),
|
||||
onTap: onTap != null ? () => onTap!(comics[index]) : null,
|
||||
onLongPressed: onLongPressed != null
|
||||
? () => onLongPressed!(comics[index])
|
||||
: null,
|
||||
);
|
||||
return Container(
|
||||
if (selection == null) {
|
||||
return comic;
|
||||
}
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.surfaceContainer
|
||||
? Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.72)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
|
224
lib/components/custom_slider.dart
Normal file
224
lib/components/custom_slider.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
@@ -51,6 +51,10 @@ class Flyout extends StatefulWidget {
|
||||
|
||||
@override
|
||||
State<Flyout> createState() => FlyoutState();
|
||||
|
||||
static FlyoutState of(BuildContext context) {
|
||||
return context.findAncestorStateOfType<FlyoutState>()!;
|
||||
}
|
||||
}
|
||||
|
||||
class FlyoutState extends State<Flyout> {
|
||||
|
@@ -2,10 +2,7 @@ part of 'components.dart';
|
||||
|
||||
class SliverGridViewWithFixedItemHeight extends StatelessWidget {
|
||||
const SliverGridViewWithFixedItemHeight(
|
||||
{required this.delegate,
|
||||
required this.maxCrossAxisExtent,
|
||||
required this.itemHeight,
|
||||
super.key});
|
||||
{required this.delegate, required this.maxCrossAxisExtent, required this.itemHeight, super.key});
|
||||
|
||||
final SliverChildDelegate delegate;
|
||||
|
||||
@@ -65,8 +62,7 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
|
||||
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
|
||||
oldDelegate.itemHeight != itemHeight) {
|
||||
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || oldDelegate.itemHeight != itemHeight) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -95,8 +91,7 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
SliverGridLayout getDetailedModeLayout(
|
||||
SliverConstraints constraints, double scale) {
|
||||
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale) {
|
||||
const minCrossAxisExtent = 360;
|
||||
final itemHeight = 152 * scale;
|
||||
final width = constraints.crossAxisExtent;
|
||||
@@ -111,14 +106,11 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
reverseCrossAxis: false);
|
||||
}
|
||||
|
||||
SliverGridLayout getBriefModeLayout(
|
||||
SliverConstraints constraints, double scale) {
|
||||
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale) {
|
||||
final maxCrossAxisExtent = 192.0 * scale;
|
||||
const childAspectRatio = 0.72;
|
||||
const childAspectRatio = 0.68;
|
||||
const crossAxisSpacing = 0.0;
|
||||
int crossAxisCount =
|
||||
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
|
||||
.ceil();
|
||||
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil();
|
||||
// Ensure a minimum count of 1, can be zero and result in an infinite extent
|
||||
// below when the window size is 0.
|
||||
crossAxisCount = math.max(1, crossAxisCount);
|
||||
|
@@ -92,9 +92,13 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
Icon(
|
||||
entry.icon,
|
||||
size: 18,
|
||||
color: entry.color
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(entry.text),
|
||||
Text(
|
||||
entry.text,
|
||||
style: TextStyle(color: entry.color)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -119,7 +123,8 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
||||
class MenuEntry {
|
||||
final String text;
|
||||
final IconData? icon;
|
||||
final Color? color;
|
||||
final void Function() onClick;
|
||||
|
||||
MenuEntry({required this.text, this.icon, required this.onClick});
|
||||
MenuEntry({required this.text, this.icon, this.color, required this.onClick});
|
||||
}
|
||||
|
@@ -135,6 +135,7 @@ Future<void> showConfirmDialog({
|
||||
required String content,
|
||||
required void Function() onConfirm,
|
||||
String confirmText = "Confirm",
|
||||
Color? btnColor,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
@@ -147,6 +148,9 @@ Future<void> showConfirmDialog({
|
||||
context.pop();
|
||||
onConfirm();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: btnColor,
|
||||
),
|
||||
child: Text(confirmText.tl),
|
||||
),
|
||||
],
|
||||
|
@@ -31,8 +31,9 @@ class Select extends StatelessWidget {
|
||||
var size = renderBox.size;
|
||||
showMenu(
|
||||
elevation: 3,
|
||||
color: context.colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
color: context.brightness == Brightness.light
|
||||
? const Color(0xFFF6F6F6)
|
||||
: const Color(0xFF1E1E1E),
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
constraints: BoxConstraints(
|
||||
@@ -41,8 +42,8 @@ class Select extends StatelessWidget {
|
||||
),
|
||||
position: RelativeRect.fromLTRB(
|
||||
offset.dx,
|
||||
offset.dy + size.height,
|
||||
offset.dx + size.height,
|
||||
offset.dy + size.height + 2,
|
||||
offset.dx + size.height + 2,
|
||||
offset.dy,
|
||||
),
|
||||
items: values
|
||||
|
@@ -10,7 +10,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.0.5";
|
||||
final version = "1.0.7";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
|
@@ -119,6 +119,8 @@ class _Settings with ChangeNotifier {
|
||||
'quickFavorite': null,
|
||||
'enableTurnPageByVolumeKey': true,
|
||||
'enableClockAndBatteryInfoInReader': true,
|
||||
'ignoreCertificateErrors': false,
|
||||
'authorizationRequired': false,
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
|
@@ -136,6 +136,8 @@ class ComicSource {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
static bool get isEmpty => _sources.isEmpty;
|
||||
|
||||
/// Name of this source.
|
||||
final String name;
|
||||
|
||||
@@ -213,6 +215,8 @@ class ComicSource {
|
||||
|
||||
final StarRatingFunc? starRatingFunc;
|
||||
|
||||
final ArchiveDownloader? archiveDownloader;
|
||||
|
||||
Future<void> loadData() async {
|
||||
var file = File("${App.dataPath}/comic_source/$key.data");
|
||||
if (await file.exists()) {
|
||||
@@ -282,6 +286,7 @@ class ComicSource {
|
||||
this.enableTagsSuggestions,
|
||||
this.enableTagsTranslate,
|
||||
this.starRatingFunc,
|
||||
this.archiveDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -463,3 +468,11 @@ class LinkHandler {
|
||||
|
||||
const LinkHandler(this.domains, this.linkToId);
|
||||
}
|
||||
|
||||
class ArchiveDownloader {
|
||||
final Future<Res<List<ArchiveInfo>>> Function(String cid) getArchives;
|
||||
|
||||
final Future<Res<String>> Function(String cid, String aid) getDownloadUrl;
|
||||
|
||||
const ArchiveDownloader(this.getArchives, this.getDownloadUrl);
|
||||
}
|
@@ -160,6 +160,8 @@ class ComicDetails with HistoryMixin {
|
||||
@override
|
||||
final int? maxPage;
|
||||
|
||||
final List<Comment>? comments;
|
||||
|
||||
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
|
||||
var res = <String, List<String>>{};
|
||||
map.forEach((key, value) {
|
||||
@@ -193,7 +195,10 @@ class ComicDetails with HistoryMixin {
|
||||
updateTime = json["updateTime"],
|
||||
url = json["url"],
|
||||
stars = (json["stars"] as num?)?.toDouble(),
|
||||
maxPage = json["maxPage"];
|
||||
maxPage = json["maxPage"],
|
||||
comments = (json["comments"] as List?)
|
||||
?.map((e) => Comment.fromJson(e))
|
||||
.toList();
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
@@ -227,3 +232,14 @@ class ComicDetails with HistoryMixin {
|
||||
|
||||
ComicType get comicType => ComicType(sourceKey.hashCode);
|
||||
}
|
||||
|
||||
class ArchiveInfo {
|
||||
final String title;
|
||||
final String description;
|
||||
final String id;
|
||||
|
||||
ArchiveInfo.fromJson(Map<String, dynamic> json)
|
||||
: title = json["title"],
|
||||
description = json["description"],
|
||||
id = json["id"];
|
||||
}
|
@@ -153,13 +153,16 @@ class ComicSourceParser {
|
||||
_getValue("search.enableTagsSuggestions") ?? false,
|
||||
_getValue("comic.enableTagsTranslate") ?? false,
|
||||
_parseStarRatingFunc(),
|
||||
_parseArchiveDownloader(),
|
||||
);
|
||||
|
||||
await source.loadData();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
JsEngine().runCode("ComicSource.sources.$_key.init()");
|
||||
});
|
||||
if (_checkExists("init")) {
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
JsEngine().runCode("ComicSource.sources.$_key.init()");
|
||||
});
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
@@ -986,4 +989,35 @@ class ComicSourceParser {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
ArchiveDownloader? _parseArchiveDownloader() {
|
||||
if (!_checkExists("comic.archive")) {
|
||||
return null;
|
||||
}
|
||||
return ArchiveDownloader(
|
||||
(cid) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
|
||||
""");
|
||||
return Res(
|
||||
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList());
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
},
|
||||
(cid, aid) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.archive.getDownloadUrl(${jsonEncode(cid)}, ${jsonEncode(aid)})
|
||||
""");
|
||||
return Res(res as String);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/image_provider/local_favorite_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -12,10 +13,7 @@ import 'comic_source/comic_source.dart';
|
||||
import 'comic_type.dart';
|
||||
|
||||
String _getTimeString(DateTime time) {
|
||||
return time
|
||||
.toIso8601String()
|
||||
.replaceFirst("T", " ")
|
||||
.substring(0, 19);
|
||||
return time.toIso8601String().replaceFirst("T", " ").substring(0, 19);
|
||||
}
|
||||
|
||||
class FavoriteItem implements Comic {
|
||||
@@ -29,15 +27,14 @@ class FavoriteItem implements Comic {
|
||||
String coverPath;
|
||||
late String time;
|
||||
|
||||
FavoriteItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.coverPath,
|
||||
required this.author,
|
||||
required this.type,
|
||||
required this.tags,
|
||||
DateTime? favoriteTime
|
||||
}) {
|
||||
FavoriteItem(
|
||||
{required this.id,
|
||||
required this.name,
|
||||
required this.coverPath,
|
||||
required this.author,
|
||||
required this.type,
|
||||
required this.tags,
|
||||
DateTime? favoriteTime}) {
|
||||
var t = favoriteTime ?? DateTime.now();
|
||||
time = _getTimeString(t);
|
||||
}
|
||||
@@ -75,7 +72,9 @@ class FavoriteItem implements Comic {
|
||||
|
||||
@override
|
||||
String get description {
|
||||
return "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}";
|
||||
return appdata.settings['comicDisplayMode'] == 'detailed'
|
||||
? "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}"
|
||||
: "${type.comicSource?.name ?? "Unknown"} | $time";
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -346,6 +345,33 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
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) {
|
||||
var res = _db.select("""
|
||||
select * from "$folder"
|
||||
@@ -365,20 +391,19 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return FavoriteItem.fromRow(res.first);
|
||||
}
|
||||
|
||||
/// add comic to a folder
|
||||
///
|
||||
/// This method will download cover to local, to avoid problems like changing url
|
||||
void addComic(String folder, FavoriteItem comic, [int? order]) async {
|
||||
/// add comic to a folder.
|
||||
/// return true if success, false if already exists
|
||||
bool addComic(String folder, FavoriteItem comic, [int? order]) {
|
||||
_modifiedAfterLastCache = true;
|
||||
if (!existsFolder(folder)) {
|
||||
throw Exception("Folder does not exists");
|
||||
}
|
||||
var res = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == '${comic.id}';
|
||||
""");
|
||||
where id == ? and type == ?;
|
||||
""", [comic.id, comic.type.value]);
|
||||
if (res.isNotEmpty) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
final params = [
|
||||
comic.id,
|
||||
@@ -406,6 +431,42 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
""", [...params, minValue(folder) - 1]);
|
||||
}
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void moveFavorite(
|
||||
String sourceFolder, String targetFolder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
if (!existsFolder(targetFolder)) {
|
||||
throw Exception("Target folder does not exist");
|
||||
}
|
||||
|
||||
var res = _db.select("""
|
||||
select * from "$targetFolder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
|
||||
if (res.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_db.execute("""
|
||||
insert into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
select id, name, author, type, tags, cover_path, time, ?
|
||||
from "$sourceFolder"
|
||||
where id == ? and type == ?;
|
||||
""", [minValue(targetFolder) - 1, id, type.value]);
|
||||
|
||||
_db.execute("""
|
||||
delete from "$sourceFolder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// delete a folder
|
||||
@@ -414,6 +475,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
_db.execute("""
|
||||
drop table "$name";
|
||||
""");
|
||||
_db.execute("""
|
||||
delete from folder_order
|
||||
where folder_name == ?;
|
||||
""", [name]);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -432,6 +497,22 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<int> removeInvalid() async {
|
||||
int count = 0;
|
||||
await Future.microtask(() {
|
||||
var all = allComics();
|
||||
for(var c in all) {
|
||||
var comicSource = c.type.comicSource;
|
||||
if ((c.type == ComicType.local && LocalManager().find(c.id, c.type) == null)
|
||||
|| (c.type != ComicType.local && comicSource == null)) {
|
||||
deleteComicWithId(c.folder, c.id, c.type);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_db.dispose();
|
||||
File("${App.dataPath}/local_favorite.db").deleteSync();
|
||||
@@ -461,6 +542,16 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
ALTER TABLE "$before"
|
||||
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();
|
||||
}
|
||||
|
||||
|
@@ -1,13 +1,17 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/network/images.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'cached_image.dart' as image_provider;
|
||||
|
||||
class CachedImageProvider
|
||||
extends BaseImageProvider<image_provider.CachedImageProvider> {
|
||||
/// Image provider for normal image.
|
||||
///
|
||||
/// [url] is the url of the image. Local file path is also supported.
|
||||
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid});
|
||||
|
||||
final String url;
|
||||
@@ -20,6 +24,10 @@ class CachedImageProvider
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||
if(url.startsWith("file://")) {
|
||||
var file = openFilePlatform(url.substring(7));
|
||||
return file.readAsBytes();
|
||||
}
|
||||
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: progress.currentBytes,
|
||||
|
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
@@ -19,6 +20,7 @@ import 'package:pointycastle/block/modes/cfb.dart';
|
||||
import 'package:pointycastle/block/modes/ecb.dart';
|
||||
import 'package:pointycastle/block/modes/ofb.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
|
||||
@@ -70,6 +72,7 @@ class JsEngine with _JSEngineApi {
|
||||
var setGlobalFunc =
|
||||
_engine!.evaluate("(key, value) => { this[key] = value; }");
|
||||
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
|
||||
setGlobalFunc(["appVersion", App.version]);
|
||||
setGlobalFunc.free();
|
||||
var jsInit = await rootBundle.load("assets/init.js");
|
||||
_engine!
|
||||
@@ -182,7 +185,23 @@ class JsEngine with _JSEngineApi {
|
||||
if (headers["user-agent"] == null && headers["User-Agent"] == null) {
|
||||
headers["User-Agent"] = webUA;
|
||||
}
|
||||
response = await _dio!.request(req["url"],
|
||||
var dio = _dio;
|
||||
if (headers['http_client'] == "dart:io") {
|
||||
dio = Dio(BaseOptions(
|
||||
responseType: ResponseType.plain,
|
||||
validateStatus: (status) => true,
|
||||
));
|
||||
var proxy = await AppDio.getProxy();
|
||||
dio.httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
return HttpClient()
|
||||
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
|
||||
},
|
||||
);
|
||||
dio.interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||
dio.interceptors.add(LogInterceptor());
|
||||
}
|
||||
response = await dio!.request(req["url"],
|
||||
data: req["data"],
|
||||
options: Options(
|
||||
method: req['http_method'],
|
||||
|
@@ -71,12 +71,13 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
downloadedChapters = List.from(jsonDecode(row[8] as String)),
|
||||
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
|
||||
|
||||
File get coverFile => File(FilePath.join(
|
||||
LocalManager().path,
|
||||
directory,
|
||||
File get coverFile => openFilePlatform(FilePath.join(
|
||||
baseDir,
|
||||
cover,
|
||||
));
|
||||
|
||||
String get baseDir => directory.contains("/") ? directory : FilePath.join(LocalManager().path, directory);
|
||||
|
||||
@override
|
||||
String get description => "";
|
||||
|
||||
@@ -174,6 +175,27 @@ class LocalManager with ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String> findDefaultPath() async {
|
||||
if (App.isAndroid) {
|
||||
var external = await getExternalStorageDirectories();
|
||||
if (external != null && external.isNotEmpty) {
|
||||
return FilePath.join(external.first.path, 'local');
|
||||
} else {
|
||||
return FilePath.join(App.dataPath, 'local');
|
||||
}
|
||||
} else if (App.isIOS) {
|
||||
var oldPath = FilePath.join(App.dataPath, 'local');
|
||||
if (Directory(oldPath).existsSync() && Directory(oldPath).listSync().isNotEmpty) {
|
||||
return oldPath;
|
||||
} else {
|
||||
var directory = await getApplicationDocumentsDirectory();
|
||||
return FilePath.join(directory.path, 'local');
|
||||
}
|
||||
} else {
|
||||
return FilePath.join(App.dataPath, 'local');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open(
|
||||
'${App.dataPath}/local.db',
|
||||
@@ -195,20 +217,19 @@ class LocalManager with ChangeNotifier {
|
||||
''');
|
||||
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
|
||||
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
|
||||
if (!Directory(path).existsSync()) {
|
||||
path = await findDefaultPath();
|
||||
}
|
||||
} else {
|
||||
if (App.isAndroid) {
|
||||
var external = await getExternalStorageDirectories();
|
||||
if (external != null && external.isNotEmpty) {
|
||||
path = FilePath.join(external.first.path, 'local');
|
||||
} else {
|
||||
path = FilePath.join(App.dataPath, 'local');
|
||||
}
|
||||
} else {
|
||||
path = FilePath.join(App.dataPath, 'local');
|
||||
path = await findDefaultPath();
|
||||
}
|
||||
try {
|
||||
if (!Directory(path).existsSync()) {
|
||||
await Directory(path).create();
|
||||
}
|
||||
}
|
||||
if (!Directory(path).existsSync()) {
|
||||
await Directory(path).create();
|
||||
catch(e, s) {
|
||||
Log.error("IO", "Failed to create local folder: $e", s);
|
||||
}
|
||||
restoreDownloadingTasks();
|
||||
}
|
||||
@@ -333,18 +354,19 @@ class LocalManager with ChangeNotifier {
|
||||
throw "Invalid ep";
|
||||
}
|
||||
var comic = find(id, type) ?? (throw "Comic Not Found");
|
||||
var directory = Directory(FilePath.join(path, comic.directory));
|
||||
var directory = openDirectoryPlatform(comic.baseDir);
|
||||
if (comic.chapters != null) {
|
||||
var cid = ep is int
|
||||
? comic.chapters!.keys.elementAt(ep - 1)
|
||||
: (ep as String);
|
||||
directory = Directory(FilePath.join(directory.path, cid));
|
||||
directory = openDirectoryPlatform(FilePath.join(directory.path, cid));
|
||||
}
|
||||
var files = <File>[];
|
||||
await for (var entity in directory.list()) {
|
||||
if (entity is File) {
|
||||
if (entity.absolute.path.replaceFirst(path, '').substring(1) ==
|
||||
comic.cover) {
|
||||
// Do not exclude comic.cover, since it may be the first page of the chapter.
|
||||
// A file with name starting with 'cover.' is not a comic page.
|
||||
if (entity.name.startsWith('cover.')) {
|
||||
continue;
|
||||
}
|
||||
//Hidden file in some file system
|
||||
@@ -384,10 +406,10 @@ class LocalManager with ChangeNotifier {
|
||||
String id, ComicType type, String name) async {
|
||||
var comic = find(id, type);
|
||||
if (comic != null) {
|
||||
return Directory(FilePath.join(path, comic.directory));
|
||||
return openDirectoryPlatform(FilePath.join(path, comic.directory));
|
||||
}
|
||||
var dir = findValidDirectoryName(path, name);
|
||||
return Directory(FilePath.join(path, dir)).create().then((value) => value);
|
||||
return openDirectoryPlatform(FilePath.join(path, dir)).create().then((value) => value);
|
||||
}
|
||||
|
||||
void completeTask(DownloadTask task) {
|
||||
@@ -446,14 +468,13 @@ class LocalManager with ChangeNotifier {
|
||||
|
||||
void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
|
||||
if(removeFileOnDisk) {
|
||||
var dir = Directory(FilePath.join(path, c.directory));
|
||||
var dir = openDirectoryPlatform(FilePath.join(path, c.directory));
|
||||
dir.deleteIgnoreError(recursive: true);
|
||||
}
|
||||
//Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
|
||||
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);
|
||||
|
@@ -32,11 +32,11 @@ class Log {
|
||||
static const String? logFile = null;
|
||||
|
||||
static void printWarning(String text) {
|
||||
print('\x1B[33m$text\x1B[0m');
|
||||
debugPrint('\x1B[33m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static void printError(String text) {
|
||||
print('\x1B[31m$text\x1B[0m');
|
||||
debugPrint('\x1B[31m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static void addLog(LogLevel level, String title, String content) {
|
||||
@@ -44,15 +44,15 @@ class Log {
|
||||
content = "${content.substring(0, maxLogLength)}...";
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
switch (level) {
|
||||
case LogLevel.error:
|
||||
printError(content);
|
||||
case LogLevel.warning:
|
||||
printWarning(content);
|
||||
case LogLevel.info:
|
||||
print(content);
|
||||
}
|
||||
switch (level) {
|
||||
case LogLevel.error:
|
||||
printError(content);
|
||||
case LogLevel.warning:
|
||||
printWarning(content);
|
||||
case LogLevel.info:
|
||||
if(kDebugMode) {
|
||||
debugPrint(content);
|
||||
}
|
||||
}
|
||||
|
||||
var newLog = LogItem(level, title, content);
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
@@ -12,6 +13,7 @@ import 'package:venera/utils/translations.dart';
|
||||
import 'foundation/appdata.dart';
|
||||
|
||||
Future<void> init() async {
|
||||
await SAFTaskWorker().init();
|
||||
await AppTranslation.init();
|
||||
await appdata.init();
|
||||
await App.init();
|
||||
|
@@ -5,11 +5,10 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:rhttp/rhttp.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/pages/comic_source_page.dart';
|
||||
import 'package:venera/pages/auth_page.dart';
|
||||
import 'package:venera/pages/main_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'components/components.dart';
|
||||
import 'components/window_frame.dart';
|
||||
@@ -65,15 +64,58 @@ class MyApp extends StatefulWidget {
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
checkUpdates();
|
||||
App.registerForceRebuild(forceRebuild);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
bool isAuthPageActive = false;
|
||||
|
||||
OverlayEntry? hideContentOverlay;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (!App.isMobile || !appdata.settings['authorizationRequired']) {
|
||||
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 &&
|
||||
!isAuthPageActive &&
|
||||
!IO.isSelectingFiles) {
|
||||
isAuthPageActive = true;
|
||||
App.rootContext.to(
|
||||
() => AuthPage(
|
||||
onSuccessfulAuth: () {
|
||||
App.rootContext.pop();
|
||||
isAuthPageActive = false;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
super.didChangeAppLifecycleState(state);
|
||||
}
|
||||
|
||||
void forceRebuild() {
|
||||
void rebuild(Element el) {
|
||||
el.markNeedsBuild();
|
||||
@@ -86,14 +128,25 @@ class _MyAppState extends State<MyApp> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget home;
|
||||
if (appdata.settings['authorizationRequired']) {
|
||||
home = AuthPage(
|
||||
onSuccessfulAuth: () {
|
||||
App.rootContext.toReplacement(() => const MainPage());
|
||||
},
|
||||
);
|
||||
} else {
|
||||
home = const MainPage();
|
||||
}
|
||||
return MaterialApp(
|
||||
home: const MainPage(),
|
||||
home: home,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: App.mainColor,
|
||||
surface: Colors.white,
|
||||
primary: App.mainColor.shade600,
|
||||
// ignore: deprecated_member_use
|
||||
background: Colors.white,
|
||||
),
|
||||
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
|
||||
@@ -105,6 +158,7 @@ class _MyAppState extends State<MyApp> {
|
||||
brightness: Brightness.dark,
|
||||
surface: Colors.black,
|
||||
primary: App.mainColor.shade400,
|
||||
// ignore: deprecated_member_use
|
||||
background: Colors.black,
|
||||
),
|
||||
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
|
||||
@@ -169,22 +223,6 @@ class _MyAppState extends State<MyApp> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void checkUpdates() async {
|
||||
if(!appdata.settings['checkUpdateOnStart']) {
|
||||
return;
|
||||
}
|
||||
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
|
||||
var now = DateTime.now().millisecondsSinceEpoch;
|
||||
if(now - lastCheck < 24 * 60 * 60 * 1000) {
|
||||
return;
|
||||
}
|
||||
appdata.implicitData['lastCheckUpdate'] = now;
|
||||
appdata.writeImplicitData();
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
await checkUpdateUi(false);
|
||||
await ComicSourcePage.checkComicSourceUpdate(true);
|
||||
}
|
||||
}
|
||||
|
||||
class _SystemUiProvider extends StatelessWidget {
|
||||
|
@@ -97,6 +97,9 @@ class MyLogInterceptor implements Interceptor {
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
Log.info("Network", "${options.method} ${options.uri}\n"
|
||||
"headers:\n${options.headers}\n"
|
||||
"data:\n${options.data}");
|
||||
options.connectTimeout = const Duration(seconds: 15);
|
||||
options.receiveTimeout = const Duration(seconds: 15);
|
||||
options.sendTimeout = const Duration(seconds: 15);
|
||||
@@ -106,34 +109,24 @@ class MyLogInterceptor implements Interceptor {
|
||||
|
||||
class AppDio with DioMixin {
|
||||
String? _proxy = proxy;
|
||||
static bool get ignoreCertificateErrors => appdata.settings['ignoreCertificateErrors'] == true;
|
||||
|
||||
AppDio([BaseOptions? options]) {
|
||||
this.options = options ?? BaseOptions();
|
||||
httpClientAdapter = RHttpAdapter(const rhttp.ClientSettings());
|
||||
httpClientAdapter = RHttpAdapter(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(NetworkCacheManager());
|
||||
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 Future<String?> getProxy() async {
|
||||
@@ -189,8 +182,8 @@ class AppDio with DioMixin {
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) async {
|
||||
if(options?.headers?['prevent-parallel'] == 'true') {
|
||||
while(_requests.containsKey(path)) {
|
||||
if (options?.headers?['prevent-parallel'] == 'true') {
|
||||
while (_requests.containsKey(path)) {
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
_requests[path] = true;
|
||||
@@ -204,6 +197,9 @@ class AppDio with DioMixin {
|
||||
proxySettings: proxy == null
|
||||
? const rhttp.ProxySettings.noProxy()
|
||||
: rhttp.ProxySettings.proxy(proxy!),
|
||||
tlsSettings: rhttp.TlsSettings(
|
||||
verifyCertificates: !ignoreCertificateErrors,
|
||||
),
|
||||
));
|
||||
}
|
||||
try {
|
||||
@@ -216,9 +212,8 @@ class AppDio with DioMixin {
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
finally {
|
||||
if(_requests.containsKey(path)) {
|
||||
} finally {
|
||||
if (_requests.containsKey(path)) {
|
||||
_requests.remove(path);
|
||||
}
|
||||
}
|
||||
@@ -237,6 +232,9 @@ class RHttpAdapter implements HttpClientAdapter {
|
||||
keepAlivePing: Duration(seconds: 30),
|
||||
),
|
||||
throwOnStatusCode: false,
|
||||
tlsSettings: rhttp.TlsSettings(
|
||||
verifyCertificates: !AppDio.ignoreCertificateErrors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,7 +282,7 @@ class RHttpAdapter implements HttpClientAdapter {
|
||||
headers[key]!.add(entry.$2);
|
||||
}
|
||||
var data = res.body;
|
||||
if(headers['content-encoding']?.contains('gzip') ?? false) {
|
||||
if (headers['content-encoding']?.contains('gzip') ?? false) {
|
||||
// rhttp does not support gzip decoding
|
||||
data = gzip.decoder.bind(data).map((data) => Uint8List.fromList(data));
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
@@ -11,13 +12,14 @@ import 'package:venera/network/images.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/file_type.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:zip_flutter/zip_flutter.dart';
|
||||
|
||||
import 'file_downloader.dart';
|
||||
|
||||
abstract class DownloadTask with ChangeNotifier {
|
||||
/// 0-1
|
||||
double get progress;
|
||||
|
||||
bool get isComplete;
|
||||
|
||||
bool get isError;
|
||||
|
||||
bool get isPaused;
|
||||
@@ -106,10 +108,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
}
|
||||
|
||||
@override
|
||||
String? get cover => _cover;
|
||||
|
||||
@override
|
||||
bool get isComplete => _totalCount == _downloadedCount;
|
||||
String? get cover => _cover ?? comic?.cover;
|
||||
|
||||
@override
|
||||
String get message => _message;
|
||||
@@ -159,7 +158,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
|
||||
var tasks = <int, _ImageDownloadWrapper>{};
|
||||
|
||||
int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt();
|
||||
int get _maxConcurrentTasks =>
|
||||
(appdata.settings["downloadThreads"] as num).toInt();
|
||||
|
||||
void _scheduleTasks() {
|
||||
var images = _images![_images!.keys.elementAt(_chapter)]!;
|
||||
@@ -253,7 +253,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
|
||||
await LocalManager().saveCurrentDownloadingTasks();
|
||||
|
||||
if (cover == null) {
|
||||
if (_cover == null) {
|
||||
var res = await runWithRetry(() async {
|
||||
Uint8List? data;
|
||||
await for (var progress
|
||||
@@ -268,7 +268,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
var fileType = detectFileType(data);
|
||||
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
|
||||
file.writeAsBytesSync(data);
|
||||
return file.path;
|
||||
return "file://${file.path}";
|
||||
});
|
||||
if (res.error) {
|
||||
_setError("Error: ${res.errorMessage}");
|
||||
@@ -382,7 +382,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
int get speed => currentSpeed;
|
||||
|
||||
@override
|
||||
String get title => comic?.title ?? comicTitle ?? "Loading...";
|
||||
String get title => comic?.title ?? comicTitle ?? "Loading...";
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
@@ -448,7 +448,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
}).toList(),
|
||||
directory: Directory(path!).name,
|
||||
chapters: comic!.chapters,
|
||||
cover: File(_cover!).uri.pathSegments.last,
|
||||
cover: File(_cover!.split("file://").last).uri.pathSegments.last,
|
||||
comicType: ComicType(source.key.hashCode),
|
||||
downloadedChapters: chapters ?? [],
|
||||
createdAt: DateTime.now(),
|
||||
@@ -577,7 +577,7 @@ abstract mixin class _TransferSpeedMixin {
|
||||
|
||||
void onData(int length) {
|
||||
if (timer == null) return;
|
||||
if(length < 0) {
|
||||
if (length < 0) {
|
||||
return;
|
||||
}
|
||||
_bytesSinceLastSecond += length;
|
||||
@@ -603,3 +603,217 @@ abstract mixin class _TransferSpeedMixin {
|
||||
_bytesSinceLastSecond = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class ArchiveDownloadTask extends DownloadTask {
|
||||
final String archiveUrl;
|
||||
|
||||
final ComicDetails comic;
|
||||
|
||||
late ComicSource source;
|
||||
|
||||
/// Download comic by archive url
|
||||
///
|
||||
/// Currently only support zip file and comics without chapters
|
||||
ArchiveDownloadTask(this.archiveUrl, this.comic) {
|
||||
source = ComicSource.find(comic.sourceKey)!;
|
||||
}
|
||||
|
||||
FileDownloader? _downloader;
|
||||
|
||||
String _message = "Fetching comic info...";
|
||||
|
||||
bool _isRunning = false;
|
||||
|
||||
bool _isError = false;
|
||||
|
||||
void _setError(String message) {
|
||||
_isRunning = false;
|
||||
_isError = true;
|
||||
_message = message;
|
||||
notifyListeners();
|
||||
Log.error("Download", message);
|
||||
}
|
||||
|
||||
@override
|
||||
void cancel() async {
|
||||
_isRunning = false;
|
||||
await _downloader?.stop();
|
||||
if (path != null) {
|
||||
Directory(path!).deleteIgnoreError(recursive: true);
|
||||
}
|
||||
path = null;
|
||||
LocalManager().removeTask(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ComicType get comicType => ComicType(source.key.hashCode);
|
||||
|
||||
@override
|
||||
String? get cover => comic.cover;
|
||||
|
||||
@override
|
||||
String get id => comic.id;
|
||||
|
||||
@override
|
||||
bool get isError => _isError;
|
||||
|
||||
@override
|
||||
bool get isPaused => !_isRunning;
|
||||
|
||||
@override
|
||||
String get message => _message;
|
||||
|
||||
int _currentBytes = 0;
|
||||
|
||||
int _expectedBytes = 0;
|
||||
|
||||
int _speed = 0;
|
||||
|
||||
@override
|
||||
void pause() {
|
||||
_isRunning = false;
|
||||
_message = "Paused";
|
||||
_downloader?.stop();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
double get progress =>
|
||||
_expectedBytes == 0 ? 0 : _currentBytes / _expectedBytes;
|
||||
|
||||
@override
|
||||
void resume() async {
|
||||
if (_isRunning) {
|
||||
return;
|
||||
}
|
||||
_isError = false;
|
||||
_isRunning = true;
|
||||
notifyListeners();
|
||||
_message = "Downloading...";
|
||||
|
||||
if (path == null) {
|
||||
var dir = await LocalManager().findValidDirectory(
|
||||
comic.id,
|
||||
comicType,
|
||||
comic.title,
|
||||
);
|
||||
if (!(await dir.exists())) {
|
||||
try {
|
||||
await dir.create();
|
||||
} catch (e) {
|
||||
_setError("Error: $e");
|
||||
return;
|
||||
}
|
||||
}
|
||||
path = dir.path;
|
||||
}
|
||||
|
||||
var resultFile = File(FilePath.join(path!, "archive.zip"));
|
||||
|
||||
Log.info("Download", "Downloading $archiveUrl");
|
||||
|
||||
_downloader = FileDownloader(archiveUrl, resultFile.path);
|
||||
|
||||
bool isDownloaded = false;
|
||||
|
||||
try {
|
||||
await for (var status in _downloader!.start()) {
|
||||
_currentBytes = status.downloadedBytes;
|
||||
_expectedBytes = status.totalBytes;
|
||||
_message =
|
||||
"${bytesToReadableString(_currentBytes)}/${bytesToReadableString(_expectedBytes)}";
|
||||
_speed = status.bytesPerSecond;
|
||||
isDownloaded = status.isFinished;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
_setError("Error: $e");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDownloaded) {
|
||||
_setError("Error: Download failed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await extractArchive(path!);
|
||||
} catch (e) {
|
||||
_setError("Failed to extract archive: $e");
|
||||
return;
|
||||
}
|
||||
|
||||
await resultFile.deleteIgnoreError();
|
||||
|
||||
LocalManager().completeTask(this);
|
||||
}
|
||||
|
||||
static Future<void> extractArchive(String path) async {
|
||||
var resultFile = FilePath.join(path, "archive.zip");
|
||||
await Isolate.run(() {
|
||||
ZipFile.openAndExtract(resultFile, path);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
int get speed => _speed;
|
||||
|
||||
@override
|
||||
String get title => comic.title;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"type": "ArchiveDownloadTask",
|
||||
"archiveUrl": archiveUrl,
|
||||
"comic": comic.toJson(),
|
||||
"path": path,
|
||||
};
|
||||
}
|
||||
|
||||
static ArchiveDownloadTask? fromJson(Map<String, dynamic> json) {
|
||||
if (json["type"] != "ArchiveDownloadTask") {
|
||||
return null;
|
||||
}
|
||||
return ArchiveDownloadTask(
|
||||
json["archiveUrl"],
|
||||
ComicDetails.fromJson(json["comic"]),
|
||||
)..path = json["path"];
|
||||
}
|
||||
|
||||
String _findCover() {
|
||||
var files = Directory(path!).listSync();
|
||||
for (var f in files) {
|
||||
if (f.name.startsWith('cover')) {
|
||||
return f.name;
|
||||
}
|
||||
}
|
||||
files.sort((a, b) {
|
||||
return a.name.compareTo(b.name);
|
||||
});
|
||||
return files.first.name;
|
||||
}
|
||||
|
||||
@override
|
||||
LocalComic toLocalComic() {
|
||||
return LocalComic(
|
||||
id: comic.id,
|
||||
title: title,
|
||||
subtitle: comic.subTitle ?? '',
|
||||
tags: comic.tags.entries.expand((e) {
|
||||
return e.value.map((v) => "${e.key}:$v");
|
||||
}).toList(),
|
||||
directory: Directory(path!).name,
|
||||
chapters: null,
|
||||
cover: _findCover(),
|
||||
comicType: ComicType(source.key.hashCode),
|
||||
downloadedChapters: [],
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
298
lib/network/file_downloader.dart
Normal file
298
lib/network/file_downloader.dart
Normal file
@@ -0,0 +1,298 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
class FileDownloader {
|
||||
final String url;
|
||||
final String savePath;
|
||||
final int maxConcurrent;
|
||||
|
||||
FileDownloader(this.url, this.savePath, {this.maxConcurrent = 4});
|
||||
|
||||
int _currentBytes = 0;
|
||||
|
||||
int _lastBytes = 0;
|
||||
|
||||
late int _fileSize;
|
||||
|
||||
final _dio = Dio();
|
||||
|
||||
RandomAccessFile? _file;
|
||||
|
||||
bool _isWriting = false;
|
||||
|
||||
int _kChunkSize = 16 * 1024 * 1024;
|
||||
|
||||
bool _canceled = false;
|
||||
|
||||
late List<_DownloadBlock> _blocks;
|
||||
|
||||
Future<void> _writeStatus() async {
|
||||
var file = File("$savePath.download");
|
||||
await file.writeAsString(_blocks.map((e) => e.toString()).join("\n"));
|
||||
}
|
||||
|
||||
Future<void> _readStatus() async {
|
||||
var file = File("$savePath.download");
|
||||
if (!await file.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = await file.readAsLines();
|
||||
_blocks = lines.map((e) => _DownloadBlock.fromString(e)).toList();
|
||||
}
|
||||
|
||||
/// create file and write empty bytes
|
||||
Future<void> _prepareFile() async {
|
||||
var file = File(savePath);
|
||||
if (await file.exists()) {
|
||||
if (file.lengthSync() == _fileSize &&
|
||||
File("$savePath.download").existsSync()) {
|
||||
_file = await file.open(mode: FileMode.append);
|
||||
return;
|
||||
} else {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
await file.create(recursive: true);
|
||||
_file = await file.open(mode: FileMode.append);
|
||||
await _file!.truncate(_fileSize);
|
||||
}
|
||||
|
||||
Future<void> _createTasks() async {
|
||||
var res = await _dio.head(url);
|
||||
var length = res.headers["content-length"]?.first;
|
||||
_fileSize = length == null ? 0 : int.parse(length);
|
||||
|
||||
await _prepareFile();
|
||||
|
||||
if (File("$savePath.download").existsSync()) {
|
||||
await _readStatus();
|
||||
_currentBytes = _blocks.fold<int>(0,
|
||||
(previousValue, element) => previousValue + element.downloadedBytes);
|
||||
} else {
|
||||
if (_fileSize > 1024 * 1024 * 1024) {
|
||||
_kChunkSize = 64 * 1024 * 1024;
|
||||
} else if (_fileSize > 512 * 1024 * 1024) {
|
||||
_kChunkSize = 32 * 1024 * 1024;
|
||||
}
|
||||
|
||||
_blocks = [];
|
||||
for (var i = 0; i < _fileSize; i += _kChunkSize) {
|
||||
var end = i + _kChunkSize;
|
||||
if (end > _fileSize) {
|
||||
_blocks.add(_DownloadBlock(i, _fileSize, 0, false));
|
||||
} else {
|
||||
_blocks.add(_DownloadBlock(i, i + _kChunkSize, 0, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Stream<DownloadingStatus> start() {
|
||||
var stream = StreamController<DownloadingStatus>();
|
||||
_download(stream);
|
||||
return stream.stream;
|
||||
}
|
||||
|
||||
void _reportStatus(StreamController<DownloadingStatus> stream) {
|
||||
stream.add(DownloadingStatus(_currentBytes, _fileSize, 0));
|
||||
}
|
||||
|
||||
void _download(StreamController<DownloadingStatus> resultStream) async {
|
||||
try {
|
||||
var proxy = await AppDio.getProxy();
|
||||
_dio.httpClientAdapter = IOHttpClientAdapter(
|
||||
createHttpClient: () {
|
||||
return HttpClient()
|
||||
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
|
||||
},
|
||||
);
|
||||
|
||||
// get file size
|
||||
await _createTasks();
|
||||
|
||||
if (_canceled) return;
|
||||
|
||||
// check if file is downloaded
|
||||
if (_currentBytes >= _fileSize) {
|
||||
await _file!.close();
|
||||
_file = null;
|
||||
_reportStatus(resultStream);
|
||||
resultStream.close();
|
||||
return;
|
||||
}
|
||||
|
||||
_reportStatus(resultStream);
|
||||
|
||||
Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_canceled || _currentBytes >= _fileSize) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
resultStream.add(DownloadingStatus(
|
||||
_currentBytes, _fileSize, _currentBytes - _lastBytes));
|
||||
_lastBytes = _currentBytes;
|
||||
});
|
||||
|
||||
// start downloading
|
||||
await _scheduleDownload();
|
||||
if (_canceled) {
|
||||
resultStream.close();
|
||||
return;
|
||||
}
|
||||
await _file!.close();
|
||||
_file = null;
|
||||
await File("$savePath.download").delete();
|
||||
|
||||
// check if download is finished
|
||||
if (_currentBytes < _fileSize) {
|
||||
resultStream
|
||||
.addError(Exception("Download failed: Expected $_fileSize bytes, "
|
||||
"but only $_currentBytes bytes downloaded."));
|
||||
resultStream.close();
|
||||
}
|
||||
|
||||
resultStream.add(DownloadingStatus(_currentBytes, _fileSize, 0, true));
|
||||
resultStream.close();
|
||||
} catch (e, s) {
|
||||
await _file?.close();
|
||||
_file = null;
|
||||
resultStream.addError(e, s);
|
||||
resultStream.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scheduleDownload() async {
|
||||
var tasks = <Future>[];
|
||||
while (true) {
|
||||
if (_canceled) return;
|
||||
if (tasks.length >= maxConcurrent) {
|
||||
await Future.any(tasks);
|
||||
}
|
||||
final block = _blocks.firstWhereOrNull((element) =>
|
||||
!element.downloading &&
|
||||
element.end - element.start > element.downloadedBytes);
|
||||
if (block == null) {
|
||||
break;
|
||||
}
|
||||
block.downloading = true;
|
||||
var task = _fetchBlock(block);
|
||||
task.then((value) => tasks.remove(task), onError: (e) {
|
||||
if(_canceled) return;
|
||||
throw e;
|
||||
});
|
||||
tasks.add(task);
|
||||
}
|
||||
await Future.wait(tasks);
|
||||
}
|
||||
|
||||
Future<void> _fetchBlock(_DownloadBlock block) async {
|
||||
final start = block.start;
|
||||
final end = block.end;
|
||||
|
||||
if (start > _fileSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
var options = Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: {
|
||||
"Range": "bytes=${start + block.downloadedBytes}-${end - 1}",
|
||||
"Accept": "*/*",
|
||||
"Accept-Encoding": "deflate, gzip",
|
||||
},
|
||||
preserveHeaderCase: true,
|
||||
);
|
||||
var res = await _dio.get<ResponseBody>(url, options: options);
|
||||
if (_canceled) return;
|
||||
if (res.data == null) {
|
||||
throw Exception("Failed to block $start-$end");
|
||||
}
|
||||
|
||||
var buffer = <int>[];
|
||||
await for (var data in res.data!.stream) {
|
||||
if (_canceled) return;
|
||||
buffer.addAll(data);
|
||||
if (buffer.length > 16 * 1024) {
|
||||
if (_isWriting) continue;
|
||||
_currentBytes += buffer.length;
|
||||
_isWriting = true;
|
||||
await _file!.setPosition(start + block.downloadedBytes);
|
||||
await _file!.writeFrom(buffer);
|
||||
block.downloadedBytes += buffer.length;
|
||||
buffer.clear();
|
||||
await _writeStatus();
|
||||
_isWriting = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.isNotEmpty) {
|
||||
while (_isWriting) {
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
_isWriting = true;
|
||||
_currentBytes += buffer.length;
|
||||
await _file!.setPosition(start + block.downloadedBytes);
|
||||
await _file!.writeFrom(buffer);
|
||||
block.downloadedBytes += buffer.length;
|
||||
await _writeStatus();
|
||||
_isWriting = false;
|
||||
}
|
||||
|
||||
block.downloading = false;
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_canceled = true;
|
||||
await _file?.close();
|
||||
_file = null;
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadingStatus {
|
||||
/// The current downloaded bytes
|
||||
final int downloadedBytes;
|
||||
|
||||
/// The total bytes of the file
|
||||
final int totalBytes;
|
||||
|
||||
/// Whether the download is finished
|
||||
final bool isFinished;
|
||||
|
||||
/// The download speed in bytes per second
|
||||
final int bytesPerSecond;
|
||||
|
||||
const DownloadingStatus(
|
||||
this.downloadedBytes, this.totalBytes, this.bytesPerSecond,
|
||||
[this.isFinished = false]);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "Downloaded: $downloadedBytes/$totalBytes ${isFinished ? "Finished" : ""}";
|
||||
}
|
||||
}
|
||||
|
||||
class _DownloadBlock {
|
||||
final int start;
|
||||
final int end;
|
||||
int downloadedBytes;
|
||||
bool downloading;
|
||||
|
||||
_DownloadBlock(this.start, this.end, this.downloadedBytes, this.downloading);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$start-$end-$downloadedBytes";
|
||||
}
|
||||
|
||||
_DownloadBlock.fromString(String str)
|
||||
: start = int.parse(str.split("-")[0]),
|
||||
end = int.parse(str.split("-")[1]),
|
||||
downloadedBytes = int.parse(str.split("-")[2]),
|
||||
downloading = false;
|
||||
}
|
71
lib/pages/auth_page.dart
Normal file
71
lib/pages/auth_page.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.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
|
||||
void initState() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if(SchedulerBinding.instance.lifecycleState != AppLifecycleState.paused) {
|
||||
auth();
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -30,8 +30,15 @@ class CategoriesPage extends StatelessWidget {
|
||||
.toList();
|
||||
|
||||
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(
|
||||
message: "No Category Pages".tl,
|
||||
message: msg,
|
||||
retry: () {
|
||||
controller.update();
|
||||
},
|
||||
@@ -248,36 +255,19 @@ class _CategoryPage extends StatelessWidget {
|
||||
|
||||
Widget buildTag(String tag, ClickTagCallback onClick,
|
||||
[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(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return Material(
|
||||
elevation: 0.6,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
color: context.colorScheme.primaryContainer.withOpacity(0.72),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
onTap: () => onClick(tag, param),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(translateTag(tag)),
|
||||
child: Text(tag),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@@ -115,6 +115,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
buildDescription(),
|
||||
buildInfo(),
|
||||
buildChapters(),
|
||||
buildComments(),
|
||||
buildThumbnails(),
|
||||
buildRecommend(),
|
||||
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
|
||||
@@ -287,7 +288,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
onLongPressed: quickFavorite,
|
||||
iconColor: context.useTextColor(Colors.purple),
|
||||
),
|
||||
if (comicSource.commentsLoader != null)
|
||||
if (comicSource.commentsLoader != null &&
|
||||
(comic.comments == null || comic.comments!.isEmpty))
|
||||
_ActionButton(
|
||||
icon: const Icon(Icons.comment),
|
||||
text: (comic.commentsCount ?? 'Comments'.tl).toString(),
|
||||
@@ -327,7 +329,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
}
|
||||
|
||||
Widget buildDescription() {
|
||||
if (comic.description == null) {
|
||||
if (comic.description == null || comic.description!.trim().isEmpty) {
|
||||
return const SliverPadding(padding: EdgeInsets.zero);
|
||||
}
|
||||
return SliverToBoxAdapter(
|
||||
@@ -392,6 +394,27 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
child: InkWell(
|
||||
borderRadius: borderRadius,
|
||||
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),
|
||||
),
|
||||
);
|
||||
@@ -406,6 +429,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}) {
|
||||
return Wrap(
|
||||
runSpacing: 8,
|
||||
@@ -464,14 +507,14 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
buildWrap(
|
||||
children: [
|
||||
buildTag(text: 'Upload Time'.tl, isTitle: true),
|
||||
buildTag(text: comic.uploadTime!),
|
||||
buildTag(text: formatTime(comic.uploadTime!)),
|
||||
],
|
||||
),
|
||||
if (comic.updateTime != null)
|
||||
buildWrap(
|
||||
children: [
|
||||
buildTag(text: 'Update Time'.tl, isTitle: true),
|
||||
buildTag(text: comic.updateTime!),
|
||||
buildTag(text: formatTime(comic.updateTime!)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -508,6 +551,16 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
SliverGridComics(comics: comic.recommend!),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget buildComments() {
|
||||
if (comic.comments == null || comic.comments!.isEmpty) {
|
||||
return const SliverPadding(padding: EdgeInsets.zero);
|
||||
}
|
||||
return _CommentsPart(
|
||||
comments: comic.comments!,
|
||||
showMore: showComments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract mixin class _ComicPageActions {
|
||||
@@ -575,7 +628,7 @@ abstract mixin class _ComicPageActions {
|
||||
|
||||
void quickFavorite() {
|
||||
var folder = appdata.settings['quickFavorite'];
|
||||
if(folder is! String) {
|
||||
if (folder is! String) {
|
||||
return;
|
||||
}
|
||||
LocalFavoritesManager().addComic(
|
||||
@@ -630,6 +683,122 @@ abstract mixin class _ComicPageActions {
|
||||
App.rootContext.showMessage(message: "The comic is downloaded".tl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (comicSource.archiveDownloader != null) {
|
||||
bool useNormalDownload = false;
|
||||
List<ArchiveInfo>? archives;
|
||||
int selected = -1;
|
||||
bool isLoading = false;
|
||||
bool isGettingLink = false;
|
||||
await showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Download".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<int>(
|
||||
value: -1,
|
||||
groupValue: selected,
|
||||
title: Text("Normal".tl),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
title: Text("Archive".tl),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
onExpansionChanged: (b) {
|
||||
if (!isLoading && b && archives == null) {
|
||||
isLoading = true;
|
||||
comicSource.archiveDownloader!
|
||||
.getArchives(comic.id)
|
||||
.then((value) {
|
||||
if (value.success) {
|
||||
archives = value.data;
|
||||
} else {
|
||||
App.rootContext
|
||||
.showMessage(message: value.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
children: [
|
||||
if (archives == null)
|
||||
const ListLoadingIndicator().toCenter()
|
||||
else
|
||||
for (int i = 0; i < archives!.length; i++)
|
||||
RadioListTile<int>(
|
||||
value: i,
|
||||
groupValue: selected,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v!;
|
||||
});
|
||||
},
|
||||
title: Text(archives![i].title),
|
||||
subtitle: Text(archives![i].description),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.filled(
|
||||
isLoading: isGettingLink,
|
||||
onPressed: () async {
|
||||
if (selected == -1) {
|
||||
useNormalDownload = true;
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isGettingLink = true;
|
||||
});
|
||||
var res =
|
||||
await comicSource.archiveDownloader!.getDownloadUrl(
|
||||
comic.id,
|
||||
archives![selected].id,
|
||||
);
|
||||
if (res.error) {
|
||||
App.rootContext.showMessage(message: res.errorMessage!);
|
||||
setState(() {
|
||||
isGettingLink = false;
|
||||
});
|
||||
} else if (context.mounted) {
|
||||
LocalManager()
|
||||
.addTask(ArchiveDownloadTask(res.data, comic));
|
||||
App.rootContext
|
||||
.showMessage(message: "Download started".tl);
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
child: Text("Confirm".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!useNormalDownload) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (comic.chapters == null) {
|
||||
LocalManager().addTask(ImagesDownloadTask(
|
||||
source: comicSource,
|
||||
@@ -1037,6 +1206,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
if (!isInitialLoading && next == null) {
|
||||
return;
|
||||
}
|
||||
if (isLoading) return;
|
||||
Future.microtask(() {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
@@ -1609,10 +1779,12 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: selected.isEmpty ? null : () {
|
||||
widget.finishSelect(selected);
|
||||
context.pop();
|
||||
},
|
||||
onPressed: selected.isEmpty
|
||||
? null
|
||||
: () {
|
||||
widget.finishSelect(selected);
|
||||
context.pop();
|
||||
},
|
||||
child: Text("Download Selected".tl),
|
||||
),
|
||||
),
|
||||
@@ -1626,3 +1798,152 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentsPart extends StatefulWidget {
|
||||
const _CommentsPart({
|
||||
required this.comments,
|
||||
required this.showMore,
|
||||
});
|
||||
|
||||
final List<Comment> comments;
|
||||
|
||||
final void Function() showMore;
|
||||
|
||||
@override
|
||||
State<_CommentsPart> createState() => _CommentsPartState();
|
||||
}
|
||||
|
||||
class _CommentsPartState extends State<_CommentsPart> {
|
||||
final scrollController = ScrollController();
|
||||
|
||||
late List<Comment> comments;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
comments = widget.comments;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiSliver(
|
||||
children: [
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text("Comments".tl),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: () {
|
||||
scrollController.animateTo(
|
||||
scrollController.position.pixels - 340,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
onPressed: () {
|
||||
scrollController.animateTo(
|
||||
scrollController.position.pixels + 340,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.ease,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 184,
|
||||
child: MediaQuery.removePadding(
|
||||
removeTop: true,
|
||||
context: context,
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: comments.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _CommentWidget(comment: comments[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_ActionButton(
|
||||
icon: const Icon(Icons.comment),
|
||||
text: "View more".tl,
|
||||
onPressed: widget.showMore,
|
||||
iconColor: context.useTextColor(Colors.green),
|
||||
).fixHeight(48).paddingRight(8).toAlign(Alignment.centerRight),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: Divider(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentWidget extends StatelessWidget {
|
||||
const _CommentWidget({required this.comment});
|
||||
|
||||
final Comment comment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 0, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
width: 324,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (comment.avatar != null)
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Image(
|
||||
image: CachedImageProvider(comment.avatar!),
|
||||
width: 36,
|
||||
height: 36,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
).paddingRight(8),
|
||||
Text(comment.userName, style: ts.bold),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: RichCommentContent(text: comment.content).fixWidth(324),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (comment.time != null)
|
||||
Text(comment.time!, style: ts.s12).toAlign(Alignment.centerLeft),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -40,7 +40,7 @@ class ComicSourcePage extends StatefulWidget {
|
||||
}
|
||||
controller?.close();
|
||||
if (shouldUpdate.isEmpty) {
|
||||
if(!implicit) {
|
||||
if (!implicit) {
|
||||
App.rootContext.showMessage(message: "No Update Available".tl);
|
||||
}
|
||||
return;
|
||||
@@ -55,10 +55,10 @@ class ComicSourcePage extends StatefulWidget {
|
||||
title: "Updates Available".tl,
|
||||
content: msg,
|
||||
confirmText: "Update",
|
||||
onConfirm: () {
|
||||
onConfirm: () async {
|
||||
for (var key in shouldUpdate) {
|
||||
var source = ComicSource.find(key);
|
||||
_BodyState.update(source!);
|
||||
await _BodyState.update(source!);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -95,24 +95,12 @@ class _BodyState extends State<_Body> {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
buildCard(context),
|
||||
buildSettings(),
|
||||
for (var source in ComicSource.all()) buildSource(context, source),
|
||||
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) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Column(
|
||||
@@ -181,11 +169,12 @@ class _BodyState extends State<_Body> {
|
||||
trailing: Select(
|
||||
current: (current as String).ts(source.key),
|
||||
values: (item.value['options'] as List)
|
||||
.map<String>(
|
||||
(e) => ((e['text'] ?? e['value']) as String).ts(source.key))
|
||||
.map<String>((e) =>
|
||||
((e['text'] ?? e['value']) as String).ts(source.key))
|
||||
.toList(),
|
||||
onTap: (i) {
|
||||
source.data['settings'][key] = item.value['options'][i]['value'];
|
||||
source.data['settings'][key] =
|
||||
item.value['options'][i]['value'];
|
||||
source.saveData();
|
||||
setState(() {});
|
||||
},
|
||||
@@ -209,7 +198,8 @@ class _BodyState extends State<_Body> {
|
||||
source.data['settings'][key] ?? item.value['default'] ?? '';
|
||||
yield ListTile(
|
||||
title: Text((item.value['title'] as String).ts(source.key)),
|
||||
subtitle: Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle:
|
||||
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -242,7 +231,10 @@ class _BodyState extends State<_Body> {
|
||||
showConfirmDialog(
|
||||
context: App.rootContext,
|
||||
title: "Delete".tl,
|
||||
content: "Are you sure you want to delete it?".tl,
|
||||
content: "Delete comic source '@n' ?".tlParams({
|
||||
"n": source.name,
|
||||
}),
|
||||
btnColor: context.colorScheme.error,
|
||||
onConfirm: () {
|
||||
var file = File(source.filePath);
|
||||
file.delete();
|
||||
@@ -277,7 +269,7 @@ class _BodyState extends State<_Body> {
|
||||
}
|
||||
}
|
||||
|
||||
static void update(ComicSource source) async {
|
||||
static Future<void> update(ComicSource source) async {
|
||||
if (!source.url.isURL) {
|
||||
App.rootContext.showMessage(message: "Invalid url config");
|
||||
return;
|
||||
@@ -305,55 +297,73 @@ class _BodyState extends State<_Body> {
|
||||
}
|
||||
|
||||
Widget buildCard(BuildContext context) {
|
||||
Widget buildButton({required Widget child, required VoidCallback onPressed}) {
|
||||
return Button.normal(
|
||||
onPressed: onPressed,
|
||||
child: child,
|
||||
).fixHeight(32);
|
||||
}
|
||||
return SliverToBoxAdapter(
|
||||
child: Card.outlined(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Add comic source".tl),
|
||||
leading: const Icon(Icons.dashboard_customize),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Add comic source".tl),
|
||||
leading: const Icon(Icons.dashboard_customize),
|
||||
),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "URL",
|
||||
border: const UnderlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
suffix: IconButton(
|
||||
onPressed: () => handleAddSource(url),
|
||||
icon: const Icon(Icons.check))),
|
||||
onChanged: (value) {
|
||||
url = value;
|
||||
},
|
||||
onSubmitted: handleAddSource,
|
||||
).paddingHorizontal(16).paddingBottom(8),
|
||||
ListTile(
|
||||
title: Text("Comic Source list".tl),
|
||||
trailing: buildButton(
|
||||
child: Text("View".tl),
|
||||
onPressed: () {
|
||||
showPopUpWidget(
|
||||
App.rootContext,
|
||||
_ComicSourceList(handleAddSource),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: "URL",
|
||||
border: const UnderlineInputBorder(),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12),
|
||||
suffix: IconButton(
|
||||
onPressed: () => handleAddSource(url),
|
||||
icon: const Icon(Icons.check))),
|
||||
onChanged: (value) {
|
||||
url = value;
|
||||
},
|
||||
onSubmitted: handleAddSource)
|
||||
.paddingHorizontal(16)
|
||||
.paddingBottom(32),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _selectFile, child: Text("Select file".tl))
|
||||
.paddingLeft(8),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
showPopUpWidget(
|
||||
App.rootContext, _ComicSourceList(handleAddSource));
|
||||
},
|
||||
child: Text("View list".tl)),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: help, child: Text("Open help".tl))
|
||||
.paddingRight(8),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
title: Text("Use a config file".tl),
|
||||
trailing: buildButton(
|
||||
onPressed: _selectFile,
|
||||
child: Text("Select".tl),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -372,8 +382,7 @@ class _BodyState extends State<_Body> {
|
||||
}
|
||||
|
||||
void help() {
|
||||
launchUrlString(
|
||||
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
|
||||
launchUrlString("https://github.com/venera-app/venera-configs");
|
||||
}
|
||||
|
||||
Future<void> handleAddSource(String url) async {
|
||||
|
@@ -1,8 +1,14 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
class CommentsPage extends StatefulWidget {
|
||||
@@ -268,7 +274,10 @@ class _CommentTileState extends State<_CommentTile> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.comment.userName, style: ts.bold,),
|
||||
Text(
|
||||
widget.comment.userName,
|
||||
style: ts.bold,
|
||||
),
|
||||
if (widget.comment.time != null)
|
||||
Text(widget.comment.time!, style: ts.s12),
|
||||
const SizedBox(height: 4),
|
||||
@@ -426,7 +435,7 @@ class _CommentTileState extends State<_CommentTile> {
|
||||
isCancel,
|
||||
);
|
||||
if (res.success) {
|
||||
if(isCancel) {
|
||||
if (isCancel) {
|
||||
voteStatus = 0;
|
||||
} else {
|
||||
if (isUp) {
|
||||
@@ -498,6 +507,289 @@ class _CommentContent extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SelectableText(text);
|
||||
if (!text.contains('<') && !text.contains('http')) {
|
||||
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))) {
|
||||
Navigator.of(App.rootContext).maybePop();
|
||||
} else {
|
||||
launchUrlString(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentImage {
|
||||
final String url;
|
||||
final String? link;
|
||||
|
||||
const _CommentImage(this.url, this.link);
|
||||
}
|
||||
|
||||
class RichCommentContent extends StatefulWidget {
|
||||
const RichCommentContent({super.key, 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;
|
||||
text = text.replaceAll('\r\n', '\n');
|
||||
text = text.replaceAll('&', '&');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
@@ -161,8 +162,8 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: widget.task.cover == null
|
||||
? null
|
||||
: Image.file(
|
||||
File(widget.task.cover!),
|
||||
: Image(
|
||||
image: CachedImageProvider(widget.task.cover!),
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
@@ -206,6 +207,7 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> {
|
||||
Text(
|
||||
widget.task.message,
|
||||
style: ts.s12,
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
LinearProgressIndicator(
|
||||
|
@@ -93,8 +93,15 @@ class _ExplorePageState extends State<ExplorePage>
|
||||
Widget buildBody(String i) => _SingleExplorePage(i, key: Key(i));
|
||||
|
||||
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(
|
||||
message: "No Explore Pages".tl,
|
||||
message: msg,
|
||||
retry: () {
|
||||
setState(() {
|
||||
pages = ComicSource.all()
|
||||
|
@@ -288,3 +288,178 @@ Future<void> sortFolders() async {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
@@ -8,10 +9,12 @@ import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/pages/comic_page.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
|
@@ -14,187 +14,564 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
|
||||
late List<FavoriteItem> comics;
|
||||
|
||||
String? networkSource;
|
||||
String? networkFolder;
|
||||
|
||||
Map<Comic, bool> selectedComics = {};
|
||||
|
||||
var selectedLocalFolders = <String>{};
|
||||
|
||||
late List<String> added = [];
|
||||
|
||||
String keyword = "";
|
||||
|
||||
bool searchMode = false;
|
||||
|
||||
bool multiSelectMode = false;
|
||||
|
||||
int? lastSelectedIndex;
|
||||
|
||||
void updateComics() {
|
||||
setState(() {
|
||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
||||
});
|
||||
if (keyword.isEmpty) {
|
||||
setState(() {
|
||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
comics = LocalFavoritesManager().search(keyword);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
|
||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
||||
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
||||
networkSource = a;
|
||||
networkFolder = b;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(
|
||||
leading: Tooltip(
|
||||
message: "Folders".tl,
|
||||
child: context.width <= _kTwoPanelChangeWidth
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
color: context.colorScheme.primary,
|
||||
onPressed: favPage.showFolderSelector,
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
title: GestureDetector(
|
||||
onTap: context.width < _kTwoPanelChangeWidth
|
||||
? favPage.showFolderSelector
|
||||
: null,
|
||||
child: Text(favPage.folder ?? "Unselected".tl),
|
||||
),
|
||||
actions: [
|
||||
MenuButton(
|
||||
entries: [
|
||||
void selectAll() {
|
||||
setState(() {
|
||||
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
|
||||
});
|
||||
}
|
||||
|
||||
void invertSelection() {
|
||||
setState(() {
|
||||
comics.asMap().forEach((k, v) {
|
||||
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
|
||||
});
|
||||
selectedComics.removeWhere((k, v) => !v);
|
||||
});
|
||||
}
|
||||
|
||||
var body = Scaffold(
|
||||
body: SmoothCustomScrollView(slivers: [
|
||||
if (!searchMode && !multiSelectMode)
|
||||
SliverAppbar(
|
||||
style: context.width < changePoint
|
||||
? AppbarStyle.shadow
|
||||
: AppbarStyle.blur,
|
||||
leading: Tooltip(
|
||||
message: "Folders".tl,
|
||||
child: context.width <= _kTwoPanelChangeWidth
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
color: context.colorScheme.primary,
|
||||
onPressed: favPage.showFolderSelector,
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
title: GestureDetector(
|
||||
onTap: context.width < _kTwoPanelChangeWidth
|
||||
? favPage.showFolderSelector
|
||||
: null,
|
||||
child: Text(favPage.folder ?? "Unselected".tl),
|
||||
),
|
||||
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();
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Search".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
searchMode = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
MenuButton(
|
||||
entries: [
|
||||
MenuEntry(
|
||||
icon: Icons.edit_outlined,
|
||||
text: "Rename".tl,
|
||||
onClick: () {
|
||||
showInputDialog(
|
||||
context: App.rootContext,
|
||||
title: "Rename".tl,
|
||||
hintText: "New Name".tl,
|
||||
onConfirm: (value) {
|
||||
var err = validateFolderName(value.toString());
|
||||
if (err != null) {
|
||||
return err;
|
||||
}
|
||||
LocalFavoritesManager().rename(
|
||||
widget.folder,
|
||||
value.toString(),
|
||||
);
|
||||
favPage.folderList?.updateFolders();
|
||||
favPage.setFolder(false, value.toString());
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
icon: Icons.reorder,
|
||||
text: "Reorder".tl,
|
||||
onClick: () {
|
||||
context.to(
|
||||
() {
|
||||
return _ReorderComicsPage(
|
||||
widget.folder,
|
||||
(comics) {
|
||||
this.comics = comics;
|
||||
},
|
||||
);
|
||||
},
|
||||
).then(
|
||||
(value) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
icon: Icons.upload_file,
|
||||
text: "Export".tl,
|
||||
onClick: () {
|
||||
var json = LocalFavoritesManager().folderToJson(
|
||||
widget.folder,
|
||||
);
|
||||
saveFile(
|
||||
data: utf8.encode(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.delete_outline,
|
||||
text: "Delete Folder".tl,
|
||||
color: context.colorScheme.error,
|
||||
onClick: () {
|
||||
showConfirmDialog(
|
||||
context: App.rootContext,
|
||||
title: "Delete".tl,
|
||||
content: "Delete folder '@f' ?".tlParams({
|
||||
"f": widget.folder,
|
||||
}),
|
||||
btnColor: context.colorScheme.error,
|
||||
onConfirm: () {
|
||||
favPage.setFolder(false, null);
|
||||
LocalFavoritesManager().deleteFolder(widget.folder);
|
||||
favPage.folderList?.updateFolders();
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (multiSelectMode)
|
||||
SliverAppbar(
|
||||
style: context.width < changePoint
|
||||
? AppbarStyle.shadow
|
||||
: AppbarStyle.blur,
|
||||
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: [
|
||||
MenuButton(entries: [
|
||||
MenuEntry(
|
||||
icon: Icons.drive_file_move,
|
||||
text: "Move to folder".tl,
|
||||
onClick: () => favoriteOption('move')),
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
text: "Copy to folder".tl,
|
||||
onClick: () => favoriteOption('add')),
|
||||
MenuEntry(
|
||||
icon: Icons.select_all,
|
||||
text: "Select All".tl,
|
||||
onClick: selectAll),
|
||||
MenuEntry(
|
||||
icon: Icons.deselect,
|
||||
text: "Deselect".tl,
|
||||
onClick: _cancel),
|
||||
MenuEntry(
|
||||
icon: Icons.flip,
|
||||
text: "Invert Selection".tl,
|
||||
onClick: invertSelection),
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete Folder".tl,
|
||||
text: "Delete Comic".tl,
|
||||
color: context.colorScheme.error,
|
||||
onClick: () {
|
||||
showConfirmDialog(
|
||||
context: App.rootContext,
|
||||
context: context,
|
||||
title: "Delete".tl,
|
||||
content:
|
||||
"Are you sure you want to delete this folder?".tl,
|
||||
content: "Delete @c comics?"
|
||||
.tlParams({"c": selectedComics.length}),
|
||||
btnColor: context.colorScheme.error,
|
||||
onConfirm: () {
|
||||
favPage.setFolder(false, null);
|
||||
LocalFavoritesManager().deleteFolder(widget.folder);
|
||||
favPage.folderList?.updateFolders();
|
||||
_deleteComicWithId();
|
||||
},
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
icon: Icons.edit_outlined,
|
||||
text: "Rename".tl,
|
||||
onClick: () {
|
||||
showInputDialog(
|
||||
context: App.rootContext,
|
||||
title: "Rename".tl,
|
||||
hintText: "New Name".tl,
|
||||
onConfirm: (value) {
|
||||
var err = validateFolderName(value.toString());
|
||||
if (err != null) {
|
||||
return err;
|
||||
]),
|
||||
],
|
||||
)
|
||||
else if (searchMode)
|
||||
SliverAppbar(
|
||||
style: context.width < changePoint
|
||||
? AppbarStyle.shadow
|
||||
: AppbarStyle.blur,
|
||||
leading: Tooltip(
|
||||
message: "Cancel".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
keyword = "";
|
||||
updateComics();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
title: TextField(
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search".tl,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (v) {
|
||||
keyword = v;
|
||||
updateComics();
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
selections: selectedComics,
|
||||
onTap: multiSelectMode
|
||||
? (c) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as FavoriteItem)) {
|
||||
selectedComics.remove(c);
|
||||
_checkExitSelectMode();
|
||||
} else {
|
||||
selectedComics[c] = true;
|
||||
}
|
||||
lastSelectedIndex = comics.indexOf(c);
|
||||
});
|
||||
}
|
||||
: (c) {
|
||||
App.mainNavigatorKey?.currentContext
|
||||
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
|
||||
},
|
||||
onLongPressed: (c) {
|
||||
setState(() {
|
||||
if (!multiSelectMode) {
|
||||
multiSelectMode = true;
|
||||
if (!selectedComics.containsKey(c as FavoriteItem)) {
|
||||
selectedComics[c] = true;
|
||||
}
|
||||
lastSelectedIndex = comics.indexOf(c);
|
||||
} else {
|
||||
if (lastSelectedIndex != null) {
|
||||
int start = lastSelectedIndex!;
|
||||
int end = comics.indexOf(c as FavoriteItem);
|
||||
if (start > end) {
|
||||
int temp = start;
|
||||
start = end;
|
||||
end = temp;
|
||||
}
|
||||
|
||||
for (int i = start; i <= end; i++) {
|
||||
if (i == lastSelectedIndex) continue;
|
||||
|
||||
var comic = comics[i];
|
||||
if (selectedComics.containsKey(comic)) {
|
||||
selectedComics.remove(comic);
|
||||
} else {
|
||||
selectedComics[comic] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
|
||||
}
|
||||
_checkExitSelectMode();
|
||||
});
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
return PopScope(
|
||||
canPop: !multiSelectMode && !searchMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
} else if (searchMode) {
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
keyword = "";
|
||||
updateComics();
|
||||
});
|
||||
}
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
void favoriteOption(String option) {
|
||||
var targetFolders = LocalFavoritesManager()
|
||||
.folderNames
|
||||
.where((folder) => folder != favPage.folder)
|
||||
.toList();
|
||||
|
||||
showPopUpWidget(
|
||||
App.rootContext,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return PopUpWidgetScaffold(
|
||||
title: favPage.folder ?? "Unselected".tl,
|
||||
body: Padding(
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom + 16),
|
||||
child: Container(
|
||||
constraints:
|
||||
const BoxConstraints(maxHeight: 700, maxWidth: 500),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: targetFolders.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == targetFolders.length) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
newFolder().then((v) {
|
||||
setState(() {
|
||||
targetFolders = LocalFavoritesManager()
|
||||
.folderNames
|
||||
.where((folder) =>
|
||||
folder != favPage.folder)
|
||||
.toList();
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text("New Folder".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
LocalFavoritesManager().rename(
|
||||
widget.folder,
|
||||
value.toString(),
|
||||
);
|
||||
favPage.folderList?.updateFolders();
|
||||
favPage.setFolder(false, value.toString());
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
icon: Icons.reorder,
|
||||
text: "Reorder".tl,
|
||||
onClick: () {
|
||||
context.to(
|
||||
() {
|
||||
return _ReorderComicsPage(
|
||||
widget.folder,
|
||||
(comics) {
|
||||
this.comics = comics;
|
||||
var folder = targetFolders[index];
|
||||
var disabled = false;
|
||||
if (selectedLocalFolders.isNotEmpty) {
|
||||
if (added.contains(folder) &&
|
||||
!added.contains(selectedLocalFolders.first)) {
|
||||
disabled = true;
|
||||
} else if (!added.contains(folder) &&
|
||||
added.contains(selectedLocalFolders.first)) {
|
||||
disabled = true;
|
||||
}
|
||||
}
|
||||
return CheckboxListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(folder),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
value: selectedLocalFolders.contains(folder),
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
if (v!) {
|
||||
selectedLocalFolders.add(folder);
|
||||
} else {
|
||||
selectedLocalFolders.remove(folder);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
).then(
|
||||
(value) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (selectedLocalFolders.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (option == 'move') {
|
||||
for (var c in selectedComics.keys) {
|
||||
for (var s in selectedLocalFolders) {
|
||||
LocalFavoritesManager().moveFavorite(
|
||||
favPage.folder as String,
|
||||
s,
|
||||
c.id,
|
||||
(c as FavoriteItem).type);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (var c in selectedComics.keys) {
|
||||
for (var s in selectedLocalFolders) {
|
||||
LocalFavoritesManager().addComic(
|
||||
s,
|
||||
FavoriteItem(
|
||||
id: c.id,
|
||||
name: c.title,
|
||||
coverPath: c.cover,
|
||||
author: c.subtitle ?? '',
|
||||
type: ComicType((c.sourceKey == 'local'
|
||||
? 0
|
||||
: c.sourceKey.hashCode)),
|
||||
tags: c.tags ?? [],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
App.rootContext.pop();
|
||||
updateComics();
|
||||
_cancel();
|
||||
},
|
||||
);
|
||||
}),
|
||||
MenuEntry(
|
||||
icon: Icons.upload_file,
|
||||
text: "Export".tl,
|
||||
onClick: () {
|
||||
var json = LocalFavoritesManager().folderToJson(
|
||||
widget.folder,
|
||||
);
|
||||
saveFile(
|
||||
data: utf8.encode(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.update,
|
||||
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(),
|
||||
}));
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete".tl,
|
||||
onClick: () {
|
||||
showConfirmDialog(
|
||||
context: context,
|
||||
title: "Delete".tl,
|
||||
content: "Are you sure you want to delete this comic?".tl,
|
||||
onConfirm: () {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
widget.folder,
|
||||
c.id,
|
||||
(c as FavoriteItem).type,
|
||||
);
|
||||
updateComics();
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Text(option == 'move' ? "Move".tl : "Add".tl),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _checkExitSelectMode() {
|
||||
if (selectedComics.isEmpty) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
setState(() {
|
||||
selectedComics.clear();
|
||||
multiSelectMode = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _deleteComicWithId() {
|
||||
for (var c in selectedComics.keys) {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
widget.folder,
|
||||
c.id,
|
||||
(c as FavoriteItem).type,
|
||||
);
|
||||
}
|
||||
updateComics();
|
||||
_cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class _ReorderComicsPage extends StatefulWidget {
|
||||
@@ -233,6 +610,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var type = appdata.settings['comicDisplayMode'];
|
||||
var tiles = comics.map(
|
||||
(e) {
|
||||
var comicSource = e.type.comicSource;
|
||||
@@ -245,7 +623,9 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
||||
e.id,
|
||||
e.author,
|
||||
e.tags,
|
||||
"${e.time} | ${comicSource?.name ?? "Unknown"}",
|
||||
type == 'detailed'
|
||||
? "${e.time} | ${comicSource?.name ?? "Unknown"}"
|
||||
: "${e.type.comicSource?.name ?? "Unknown"} | ${e.time}",
|
||||
comicSource?.key ??
|
||||
(e.type == ComicType.local ? "local" : "Unknown"),
|
||||
null,
|
||||
|
@@ -19,8 +19,8 @@ Future<bool> _deleteComic(
|
||||
bool loading = false;
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Delete".tl,
|
||||
content: Text("Are you sure you want to delete this comic?".tl)
|
||||
title: "Remove".tl,
|
||||
content: Text("Remove comic from favorite?".tl)
|
||||
.paddingHorizontal(16),
|
||||
actions: [
|
||||
Button.filled(
|
||||
@@ -94,6 +94,9 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
|
||||
return ComicList(
|
||||
key: comicListKey,
|
||||
leadingSliver: SliverAppbar(
|
||||
style: context.width < changePoint
|
||||
? AppbarStyle.shadow
|
||||
: AppbarStyle.blur,
|
||||
leading: Tooltip(
|
||||
message: "Folders".tl,
|
||||
child: context.width <= _kTwoPanelChangeWidth
|
||||
@@ -108,6 +111,17 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
|
||||
onTap: context.width < _kTwoPanelChangeWidth ? showFolders : null,
|
||||
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(
|
||||
leading: Tooltip(
|
||||
@@ -200,6 +214,9 @@ class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var sliverAppBar = SliverAppbar(
|
||||
style: context.width < changePoint
|
||||
? AppbarStyle.shadow
|
||||
: AppbarStyle.blur,
|
||||
leading: Tooltip(
|
||||
message: "Folders".tl,
|
||||
child: context.width <= _kTwoPanelChangeWidth
|
||||
@@ -413,7 +430,7 @@ class _FolderTile extends StatelessWidget {
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Delete".tl,
|
||||
content: Text("Are you sure you want to delete this folder?".tl)
|
||||
content: Text("Delete folder?".tl)
|
||||
.paddingHorizontal(16),
|
||||
actions: [
|
||||
Button.filled(
|
||||
@@ -533,6 +550,17 @@ class _FavoriteFolder extends StatelessWidget {
|
||||
key: comicListKey,
|
||||
leadingSliver: SliverAppbar(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
MenuButton(entries: [
|
||||
MenuEntry(
|
||||
icon: Icons.sync,
|
||||
text: "Convert to local".tl,
|
||||
onClick: () {
|
||||
importNetworkFolder(data.key, title, folderID);
|
||||
},
|
||||
)
|
||||
]),
|
||||
],
|
||||
),
|
||||
errorLeading: Appbar(
|
||||
title: Text(title),
|
||||
|
@@ -97,7 +97,9 @@ class _HistoryPageState extends State<HistoryPage> {
|
||||
e.subtitle,
|
||||
null,
|
||||
getDescription(e),
|
||||
e.type.comicSource?.key ?? "Invalid:${e.type.value}",
|
||||
e.type == ComicType.local
|
||||
? 'local'
|
||||
: e.type.comicSource?.key ?? "Unknown:${e.type.value}",
|
||||
null,
|
||||
null,
|
||||
);
|
||||
@@ -111,12 +113,18 @@ class _HistoryPageState extends State<HistoryPage> {
|
||||
MenuEntry(
|
||||
icon: Icons.remove,
|
||||
text: 'Remove'.tl,
|
||||
color: context.colorScheme.error,
|
||||
onClick: () {
|
||||
if (c.sourceKey.startsWith("Invalid")) {
|
||||
if (c.sourceKey.startsWith("Unknown")) {
|
||||
HistoryManager().remove(
|
||||
c.id,
|
||||
ComicType(int.parse(c.sourceKey.split(':')[1])),
|
||||
);
|
||||
} else if (c.sourceKey == 'local') {
|
||||
HistoryManager().remove(
|
||||
c.id,
|
||||
ComicType.local,
|
||||
);
|
||||
} else {
|
||||
HistoryManager().remove(
|
||||
c.id,
|
||||
|
@@ -1,29 +1,23 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/accounts_page.dart';
|
||||
import 'package:venera/pages/comic_page.dart';
|
||||
import 'package:venera/pages/comic_source_page.dart';
|
||||
import 'package:venera/pages/downloading_page.dart';
|
||||
import 'package:venera/pages/history_page.dart';
|
||||
import 'package:venera/pages/search_page.dart';
|
||||
import 'package:venera/utils/cbz.dart';
|
||||
import 'package:venera/utils/data_sync.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/import_comic.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' as sql;
|
||||
import 'dart:math';
|
||||
|
||||
import 'local_comics_page.dart';
|
||||
|
||||
@@ -89,11 +83,13 @@ class _SyncDataWidget extends StatefulWidget {
|
||||
State<_SyncDataWidget> createState() => _SyncDataWidgetState();
|
||||
}
|
||||
|
||||
class _SyncDataWidgetState extends State<_SyncDataWidget> {
|
||||
class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
DataSync().addListener(update);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
lastCheck = DateTime.now();
|
||||
}
|
||||
|
||||
void update() {
|
||||
@@ -106,6 +102,20 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> {
|
||||
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
|
||||
@@ -486,6 +496,10 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
|
||||
String? selectedFolder;
|
||||
|
||||
bool copyToLocalFolder = true;
|
||||
|
||||
bool cancelled = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
loading = false;
|
||||
@@ -519,22 +533,23 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
...List.generate(importMethods.length, (index) {
|
||||
return RadioListTile(
|
||||
title: Text(importMethods[index]),
|
||||
value: index,
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value as int;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
...List.generate(importMethods.length, (index) {
|
||||
return RadioListTile(
|
||||
title: Text(importMethods[index]),
|
||||
value: index,
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value as int;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
if(type != 3)
|
||||
ListTile(
|
||||
title: Text("Add to favorites".tl),
|
||||
trailing: Select(
|
||||
@@ -548,10 +563,20 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
if(!App.isIOS && !App.isMacOS)
|
||||
CheckboxListTile(
|
||||
enabled: true,
|
||||
title: Text("Copy to app local path".tl),
|
||||
value: copyToLocalFolder,
|
||||
onChanged:(v) {
|
||||
setState(() {
|
||||
copyToLocalFolder = !copyToLocalFolder;
|
||||
});
|
||||
}).paddingHorizontal(8),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
child: Row(
|
||||
@@ -608,323 +633,28 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
}
|
||||
|
||||
void selectAndImport() async {
|
||||
if (type == 2) {
|
||||
var xFile = await selectFile(ext: ['cbz']);
|
||||
var controller = showLoadingDialog(context, allowCancel: false);
|
||||
try {
|
||||
var cache = FilePath.join(App.cachePath, xFile?.name ?? 'temp.cbz');
|
||||
await xFile!.saveTo(cache);
|
||||
var comic = await CBZ.import(File(cache));
|
||||
if (selectedFolder != null) {
|
||||
LocalFavoritesManager().addComic(
|
||||
selectedFolder!,
|
||||
FavoriteItem(
|
||||
id: comic.id,
|
||||
name: comic.title,
|
||||
coverPath: comic.cover,
|
||||
author: comic.subtitle,
|
||||
type: comic.comicType,
|
||||
tags: comic.tags,
|
||||
));
|
||||
}
|
||||
await File(cache).deleteIgnoreError();
|
||||
} catch (e, s) {
|
||||
Log.error("Import Comic", e.toString(), s);
|
||||
context.showMessage(message: e.toString());
|
||||
}
|
||||
controller.close();
|
||||
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;
|
||||
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
final picker = DirectoryPicker();
|
||||
final path = await picker.pickDirectory();
|
||||
if (!loading) {
|
||||
picker.dispose();
|
||||
return;
|
||||
}
|
||||
if (path == null) {
|
||||
var importer = ImportComic(
|
||||
selectedFolder: selectedFolder,
|
||||
copyToLocal: copyToLocalFolder);
|
||||
var result = switch(type) {
|
||||
0 => await importer.directory(true),
|
||||
1 => await importer.directory(false),
|
||||
2 => await importer.cbz(),
|
||||
3 => await importer.ehViewer(),
|
||||
int() => true,
|
||||
};
|
||||
if(result) {
|
||||
context.pop();
|
||||
} else {
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
Map<Directory, LocalComic> comics = {};
|
||||
if (type == 0) {
|
||||
var result = await checkSingleComic(path);
|
||||
if (result != null) {
|
||||
comics[path] = result;
|
||||
} else {
|
||||
context.showMessage(message: "Invalid Comic".tl);
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await for (var entry in path.list()) {
|
||||
if (entry is Directory) {
|
||||
var result = await checkSingleComic(entry);
|
||||
if (result != null) {
|
||||
comics[entry] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bool shouldCopy = true;
|
||||
for (var comic in comics.keys) {
|
||||
if (comic.parent.path == LocalManager().path) {
|
||||
shouldCopy = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shouldCopy && comics.isNotEmpty) {
|
||||
try {
|
||||
// copy the comics to the local directory
|
||||
await compute<Map<String, dynamic>, void>(_copyDirectories, {
|
||||
'toBeCopied': comics.keys.map((e) => e.path).toList(),
|
||||
'destination': LocalManager().path,
|
||||
});
|
||||
} catch (e) {
|
||||
context.showMessage(message: "Failed to import comics".tl);
|
||||
Log.error("Import Comic", e.toString());
|
||||
setState(() {
|
||||
loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (var comic in comics.values) {
|
||||
LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
|
||||
if (selectedFolder != null) {
|
||||
LocalFavoritesManager().addComic(
|
||||
selectedFolder!,
|
||||
FavoriteItem(
|
||||
id: comic.id,
|
||||
name: comic.title,
|
||||
coverPath: comic.cover,
|
||||
author: comic.subtitle,
|
||||
type: comic.comicType,
|
||||
tags: comic.tags,
|
||||
));
|
||||
}
|
||||
}
|
||||
context.pop();
|
||||
context.showMessage(
|
||||
message: "Imported @a comics".tlParams({
|
||||
'a': comics.length,
|
||||
}));
|
||||
}
|
||||
|
||||
static _copyDirectories(Map<String, dynamic> data) {
|
||||
var toBeCopied = data['toBeCopied'] as List<String>;
|
||||
var destination = data['destination'] as String;
|
||||
for (var dir in toBeCopied) {
|
||||
var source = Directory(dir);
|
||||
var dest = Directory("$destination/${source.name}");
|
||||
if (dest.existsSync()) {
|
||||
// The destination directory already exists, and it is not managed by the app.
|
||||
// Rename the old directory to avoid conflicts.
|
||||
Log.info("Import Comic",
|
||||
"Directory already exists: ${source.name}\nRenaming the old directory.");
|
||||
dest.rename(
|
||||
findValidDirectoryName(dest.parent.path, "${dest.path}_old"));
|
||||
}
|
||||
dest.createSync();
|
||||
copyDirectory(source, dest);
|
||||
}
|
||||
}
|
||||
|
||||
Future<LocalComic?> checkSingleComic(Directory directory) async {
|
||||
if (!(await directory.exists())) return null;
|
||||
var name = directory.name;
|
||||
if (LocalManager().findByName(name) != null) {
|
||||
Log.info("Import Comic", "Comic already exists: $name");
|
||||
return null;
|
||||
}
|
||||
bool hasChapters = false;
|
||||
var chapters = <String>[];
|
||||
var coverPath = ''; // relative path to the cover image
|
||||
await for (var entry in directory.list()) {
|
||||
if (entry is Directory) {
|
||||
hasChapters = true;
|
||||
chapters.add(entry.name);
|
||||
await for (var file in entry.list()) {
|
||||
if (file is Directory) {
|
||||
Log.info("Import Comic",
|
||||
"Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else if (entry is File) {
|
||||
if (entry.name.startsWith('cover')) {
|
||||
coverPath = entry.name;
|
||||
}
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'];
|
||||
if (!coverPath.startsWith('cover') &&
|
||||
imageExtensions.contains(entry.extension)) {
|
||||
coverPath = entry.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
chapters.sort();
|
||||
if (hasChapters && coverPath == '') {
|
||||
// use the first image in the first chapter as the cover
|
||||
var firstChapter = Directory('${directory.path}/${chapters.first}');
|
||||
await for (var entry in firstChapter.list()) {
|
||||
if (entry is File) {
|
||||
coverPath = entry.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (coverPath == '') {
|
||||
Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found.");
|
||||
return null;
|
||||
}
|
||||
return LocalComic(
|
||||
id: '0',
|
||||
title: name,
|
||||
subtitle: '',
|
||||
tags: [],
|
||||
directory: directory.name,
|
||||
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
|
||||
cover: coverPath,
|
||||
comicType: ComicType.local,
|
||||
downloadedChapters: chapters,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -298,24 +298,16 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
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),
|
||||
content: CheckboxListTile(
|
||||
title:
|
||||
Text("Also remove files on disk".tl),
|
||||
value: removeComicFile,
|
||||
onChanged: (v) {
|
||||
state(() {
|
||||
removeComicFile = !removeComicFile;
|
||||
});
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
@@ -379,12 +371,12 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
return PopScope(
|
||||
canPop: !multiSelectMode && !searchMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if(multiSelectMode) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedComics.clear();
|
||||
});
|
||||
} else if(searchMode) {
|
||||
} else if (searchMode) {
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
keyword = "";
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/pages/categories_page.dart';
|
||||
import 'package:venera/pages/search_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:venera/utils/translations.dart';
|
||||
|
||||
import '../components/components.dart';
|
||||
import '../foundation/app.dart';
|
||||
import 'comic_source_page.dart';
|
||||
import 'explore_page.dart';
|
||||
import 'favorites/favorites_page.dart';
|
||||
import 'home_page.dart';
|
||||
@@ -34,8 +36,25 @@ class _MainPageState extends State<MainPage> {
|
||||
_navigatorKey!.currentContext!.pop();
|
||||
}
|
||||
|
||||
void checkUpdates() async {
|
||||
if (!appdata.settings['checkUpdateOnStart']) {
|
||||
return;
|
||||
}
|
||||
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
|
||||
var now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (now - lastCheck < 24 * 60 * 60 * 1000) {
|
||||
return;
|
||||
}
|
||||
appdata.implicitData['lastCheckUpdate'] = now;
|
||||
appdata.writeImplicitData();
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
await checkUpdateUi(false);
|
||||
await ComicSourcePage.checkComicSourceUpdate(true);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
checkUpdates();
|
||||
_observer = NaviObserver();
|
||||
_navigatorKey = GlobalKey();
|
||||
App.mainNavigatorKey = _navigatorKey;
|
||||
|
@@ -223,7 +223,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
@override
|
||||
void handleLongPressDown(Offset location) {
|
||||
if(!appdata.settings['enableLongPressToZoom']) {
|
||||
if (!appdata.settings['enableLongPressToZoom']) {
|
||||
return;
|
||||
}
|
||||
var photoViewController = photoViewControllers[reader.page]!;
|
||||
@@ -237,7 +237,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
@override
|
||||
void handleLongPressUp(Offset location) {
|
||||
if(!appdata.settings['enableLongPressToZoom']) {
|
||||
if (!appdata.settings['enableLongPressToZoom']) {
|
||||
return;
|
||||
}
|
||||
var photoViewController = photoViewControllers[reader.page]!;
|
||||
@@ -473,7 +473,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
);
|
||||
var width = MediaQuery.of(context).size.width;
|
||||
var height = MediaQuery.of(context).size.height;
|
||||
if(appdata.settings['limitImageWidth'] && width / height > 0.7) {
|
||||
if (appdata.settings['limitImageWidth'] &&
|
||||
width / height > 0.7 &&
|
||||
reader.mode == ReaderMode.continuousTopToBottom) {
|
||||
width = height * 0.7;
|
||||
}
|
||||
|
||||
@@ -521,7 +523,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
|
||||
@override
|
||||
void handleLongPressDown(Offset location) {
|
||||
if(!appdata.settings['enableLongPressToZoom']) {
|
||||
if (!appdata.settings['enableLongPressToZoom']) {
|
||||
return;
|
||||
}
|
||||
double target = photoViewController.getInitialScale!.call()! * 1.75;
|
||||
@@ -534,7 +536,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
||||
|
||||
@override
|
||||
void handleLongPressUp(Offset location) {
|
||||
if(!appdata.settings['enableLongPressToZoom']) {
|
||||
if (!appdata.settings['enableLongPressToZoom']) {
|
||||
return;
|
||||
}
|
||||
double target = photoViewController.getInitialScale!.call()!;
|
||||
@@ -602,7 +604,7 @@ ImageProvider _createImageProvider(int page, BuildContext context) {
|
||||
var reader = context.reader;
|
||||
var imageKey = reader.images![page - 1];
|
||||
if (imageKey.startsWith('file://')) {
|
||||
return FileImage(File(imageKey.replaceFirst("file://", '')));
|
||||
return FileImage(openFilePlatform(imageKey.replaceFirst("file://", '')));
|
||||
} else {
|
||||
return ReaderImageProvider(
|
||||
imageKey,
|
||||
|
@@ -12,6 +12,7 @@ import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/components/custom_slider.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
@@ -244,7 +245,9 @@ abstract mixin class _ReaderLocation {
|
||||
bool toPage(int page) {
|
||||
if (_validatePage(page)) {
|
||||
if (page == this.page) {
|
||||
return false;
|
||||
if(!(chapter == 1 && page == 1) && !(chapter == maxChapter && page == maxPage)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.page = page;
|
||||
update();
|
||||
|
@@ -18,6 +18,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
|
||||
bool get isOpen => _isOpen;
|
||||
|
||||
bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft ||
|
||||
context.reader.mode == ReaderMode.continuousRightToLeft;
|
||||
|
||||
int showFloatingButtonValue = 0;
|
||||
|
||||
var lastValue = 0;
|
||||
@@ -217,34 +220,26 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
if (!context.reader.toPrevChapter()) {
|
||||
context.reader.toPage(1);
|
||||
} else {
|
||||
if (showFloatingButtonValue != 0) {
|
||||
setState(() {
|
||||
showFloatingButtonValue = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: () => !isReversed
|
||||
? context.reader.chapter > 1
|
||||
? context.reader.toPrevChapter()
|
||||
: context.reader.toPage(1)
|
||||
: context.reader.chapter < context.reader.maxChapter
|
||||
? context.reader.toNextChapter()
|
||||
: context.reader.toPage(context.reader.maxPage),
|
||||
icon: const Icon(Icons.first_page),
|
||||
),
|
||||
Expanded(
|
||||
child: buildSlider(),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: () {
|
||||
if (!context.reader.toNextChapter()) {
|
||||
context.reader.toPage(context.reader.maxPage);
|
||||
} else {
|
||||
if (showFloatingButtonValue != 0) {
|
||||
setState(() {
|
||||
showFloatingButtonValue = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: () => !isReversed
|
||||
? context.reader.chapter < context.reader.maxChapter
|
||||
? context.reader.toNextChapter()
|
||||
: context.reader.toPage(context.reader.maxPage)
|
||||
: context.reader.chapter > 1
|
||||
? context.reader.toPrevChapter()
|
||||
: context.reader.toPage(1),
|
||||
icon: const Icon(Icons.last_page)),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
@@ -379,12 +374,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
var sliderFocus = FocusNode();
|
||||
|
||||
Widget buildSlider() {
|
||||
return Slider(
|
||||
return CustomSlider(
|
||||
focusNode: sliderFocus,
|
||||
value: context.reader.page.toDouble(),
|
||||
min: 1,
|
||||
max:
|
||||
context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
|
||||
reversed: isReversed,
|
||||
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
|
||||
onChanged: (i) {
|
||||
context.reader.toPage(i.toInt());
|
||||
@@ -473,7 +469,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
ImageProvider image;
|
||||
var imageKey = images[index];
|
||||
if (imageKey.startsWith('file://')) {
|
||||
image = FileImage(File(imageKey.replaceFirst("file://", '')));
|
||||
image = FileImage(openFilePlatform(imageKey.replaceFirst("file://", '')));
|
||||
} else {
|
||||
image = ReaderImageProvider(
|
||||
imageKey,
|
||||
@@ -519,7 +515,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
}
|
||||
if (imageKey.startsWith("file://")) {
|
||||
return await File(imageKey.substring(7)).readAsBytes();
|
||||
return await openFilePlatform(imageKey.substring(7)).readAsBytes();
|
||||
} else {
|
||||
return (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
||||
|
@@ -305,13 +305,24 @@ class _SearchPageState extends State<SearchPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
title: Text(appdata.searchHistory[index - 2]),
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
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,
|
||||
),
|
||||
@@ -490,7 +501,7 @@ class SearchOptionWidget extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(option.label.ts(sourceKey)),
|
||||
),
|
||||
if(option.type == 'select')
|
||||
if (option.type == 'select')
|
||||
Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
@@ -504,7 +515,7 @@ class SearchOptionWidget extends StatelessWidget {
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if(option.type == 'multi-select')
|
||||
if (option.type == 'multi-select')
|
||||
Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
@@ -514,7 +525,7 @@ class SearchOptionWidget extends StatelessWidget {
|
||||
isSelected: (jsonDecode(value) as List).contains(e.key),
|
||||
onTap: () {
|
||||
var list = jsonDecode(value) as List;
|
||||
if(list.contains(e.key)) {
|
||||
if (list.contains(e.key)) {
|
||||
list.remove(e.key);
|
||||
} else {
|
||||
list.add(e.key);
|
||||
@@ -524,7 +535,7 @@ class SearchOptionWidget extends StatelessWidget {
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if(option.type == 'dropdown')
|
||||
if (option.type == 'dropdown')
|
||||
Select(
|
||||
current: option.options[value],
|
||||
values: option.options.values.toList(),
|
||||
|
@@ -86,29 +86,33 @@ Future<bool> checkUpdate() async {
|
||||
}
|
||||
|
||||
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
|
||||
var value = await checkUpdate();
|
||||
if (value) {
|
||||
showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "New version available".tl,
|
||||
content: Text(
|
||||
"A new version is available. Do you want to update now?".tl),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString(
|
||||
"https://github.com/venera-app/venera/releases");
|
||||
},
|
||||
child: Text("Update".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
} else if (showMessageIfNoUpdate) {
|
||||
App.rootContext.showMessage(message: "No new version available".tl);
|
||||
try {
|
||||
var value = await checkUpdate();
|
||||
if (value) {
|
||||
showDialog(
|
||||
context: App.rootContext,
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "New version available".tl,
|
||||
content: Text(
|
||||
"A new version is available. Do you want to update now?".tl),
|
||||
actions: [
|
||||
Button.text(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
launchUrlString(
|
||||
"https://github.com/venera-app/venera/releases");
|
||||
},
|
||||
child: Text("Update".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
} else if (showMessageIfNoUpdate) {
|
||||
App.rootContext.showMessage(message: "No new version available".tl);
|
||||
}
|
||||
} catch (e, s) {
|
||||
Log.error("Check Update", e.toString(), s);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -36,12 +36,12 @@ class _AppSettingsState extends State<AppSettings> {
|
||||
if (App.isAndroid) {
|
||||
var channel = const MethodChannel("venera/storage");
|
||||
var permission = await channel.invokeMethod('');
|
||||
if(permission != true) {
|
||||
if (permission != true) {
|
||||
context.showMessage(message: "Permission denied".tl);
|
||||
return;
|
||||
}
|
||||
var path = await selectDirectory();
|
||||
if(path != null) {
|
||||
if (path != null) {
|
||||
// check if the path is writable
|
||||
var testFile = File(FilePath.join(path, "test"));
|
||||
try {
|
||||
@@ -177,6 +177,29 @@ class _AppSettingsState extends State<AppSettings> {
|
||||
App.forceRebuild();
|
||||
},
|
||||
).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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@@ -38,6 +38,16 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
|
||||
for (var e in LocalFavoritesManager().folderNames) e: e
|
||||
},
|
||||
).toSliver(),
|
||||
_CallbackSetting(
|
||||
title: "Delete all unavailable local favorite items".tl,
|
||||
callback: () async {
|
||||
var controller = showLoadingDialog(context);
|
||||
var count = await LocalFavoritesManager().removeInvalid();
|
||||
controller.close();
|
||||
context.showMessage(message: "Deleted @a favorite items".tlParams({'a': count}));
|
||||
},
|
||||
actionTitle: 'Delete'.tl,
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@@ -38,14 +38,11 @@ class _ProxySettingView extends StatefulWidget {
|
||||
|
||||
class _ProxySettingViewState extends State<_ProxySettingView> {
|
||||
String type = '';
|
||||
|
||||
String host = '';
|
||||
|
||||
String port = '';
|
||||
|
||||
String username = '';
|
||||
|
||||
String password = '';
|
||||
bool ignoreCertificateErrors = false;
|
||||
|
||||
// USERNAME:PASSWORD@HOST:PORT
|
||||
String toProxyStr() {
|
||||
@@ -103,6 +100,7 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
|
||||
void initState() {
|
||||
var proxy = appdata.settings['proxy'];
|
||||
parseProxyString(proxy);
|
||||
ignoreCertificateErrors = appdata.settings['ignoreCertificateErrors'] ?? false;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@@ -148,6 +146,17 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
|
||||
},
|
||||
),
|
||||
if (type == 'manual') buildManualProxy(),
|
||||
SwitchListTile(
|
||||
title: Text("Ignore Certificate Errors".tl),
|
||||
value: ignoreCertificateErrors,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
ignoreCertificateErrors = v;
|
||||
});
|
||||
appdata.settings['ignoreCertificateErrors'] = ignoreCertificateErrors;
|
||||
appdata.saveData();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@@ -33,9 +33,10 @@ class _SwitchSettingState extends State<_SwitchSetting> {
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
appdata.settings[widget.settingKey] = value;
|
||||
appdata.saveData();
|
||||
});
|
||||
widget.onChanged?.call();
|
||||
appdata.saveData().then((_) {
|
||||
widget.onChanged?.call();
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -133,7 +134,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "Help".tl,
|
||||
content: Text(widget.help!).paddingHorizontal(16).fixWidth(double.infinity),
|
||||
content: Text(widget.help!)
|
||||
.paddingHorizontal(16)
|
||||
.fixWidth(double.infinity),
|
||||
actions: [
|
||||
Button.filled(
|
||||
onPressed: context.pop,
|
||||
@@ -158,8 +161,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
||||
var rect = offset & size;
|
||||
showMenu(
|
||||
elevation: 3,
|
||||
color: context.colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
color: context.brightness == Brightness.light
|
||||
? const Color(0xFFF6F6F6)
|
||||
: const Color(0xFF1E1E1E),
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(
|
||||
rect,
|
||||
@@ -229,7 +233,9 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "Help".tl,
|
||||
content: Text(widget.help!).paddingHorizontal(16).fixWidth(double.infinity),
|
||||
content: Text(widget.help!)
|
||||
.paddingHorizontal(16)
|
||||
.fixWidth(double.infinity),
|
||||
actions: [
|
||||
Button.filled(
|
||||
onPressed: context.pop,
|
||||
@@ -458,24 +464,31 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
}
|
||||
});
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SimpleDialog(
|
||||
title: const Text("Add"),
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "Add".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: canAdd.entries
|
||||
.map((e) => InkWell(
|
||||
child: ListTile(title: Text(e.value), key: Key(e.key)),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
setState(() {
|
||||
keys.add(e.key);
|
||||
});
|
||||
updateSetting();
|
||||
},
|
||||
))
|
||||
.map(
|
||||
(e) => ListTile(
|
||||
title: Text(e.value),
|
||||
key: Key(e.key),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
setState(() {
|
||||
keys.add(e.key);
|
||||
});
|
||||
updateSetting();
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void updateSetting() {
|
||||
|
@@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
|
@@ -70,6 +70,8 @@ class AppWebview extends StatefulWidget {
|
||||
|
||||
final bool singlePage;
|
||||
|
||||
static WebViewEnvironment? webViewEnvironment;
|
||||
|
||||
@override
|
||||
State<AppWebview> createState() => _AppWebviewState();
|
||||
}
|
||||
@@ -117,7 +119,50 @@ class _AppWebviewState extends State<AppWebview> {
|
||||
)
|
||||
];
|
||||
|
||||
Widget body = InAppWebView(
|
||||
Widget body = (App.isWindows && AppWebview.webViewEnvironment == null)
|
||||
? FutureBuilder(
|
||||
future: WebViewEnvironment.create(
|
||||
settings: WebViewEnvironmentSettings(
|
||||
userDataFolder: "${App.dataPath}\\webview",
|
||||
),
|
||||
),
|
||||
builder: (context, e) {
|
||||
if(e.error != null) {
|
||||
return Center(child: Text("Error: ${e.error}"));
|
||||
}
|
||||
if(e.data == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
AppWebview.webViewEnvironment = e.data;
|
||||
return createWebviewWithEnvironment(AppWebview.webViewEnvironment);
|
||||
},
|
||||
)
|
||||
: createWebviewWithEnvironment(AppWebview.webViewEnvironment);
|
||||
|
||||
body = Stack(
|
||||
children: [
|
||||
Positioned.fill(child: body),
|
||||
if (_progress < 1.0)
|
||||
const Positioned.fill(
|
||||
child: Center(child: CircularProgressIndicator()))
|
||||
],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
title: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: actions,
|
||||
),
|
||||
body: body);
|
||||
}
|
||||
|
||||
Widget createWebviewWithEnvironment(WebViewEnvironment? e) {
|
||||
return InAppWebView(
|
||||
webViewEnvironment: e,
|
||||
initialSettings: InAppWebViewSettings(
|
||||
isInspectable: true,
|
||||
),
|
||||
@@ -155,26 +200,6 @@ class _AppWebviewState extends State<AppWebview> {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
body = Stack(
|
||||
children: [
|
||||
Positioned.fill(child: body),
|
||||
if (_progress < 1.0)
|
||||
const Positioned.fill(
|
||||
child: Center(child: CircularProgressIndicator()))
|
||||
],
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
title: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: actions,
|
||||
),
|
||||
body: body);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -10,7 +10,7 @@ void handleLinks() {
|
||||
});
|
||||
}
|
||||
|
||||
void handleAppLink(Uri uri) async {
|
||||
Future<bool> handleAppLink(Uri uri) async {
|
||||
for(var source in ComicSource.all()) {
|
||||
if(source.linkHandler != null) {
|
||||
if(source.linkHandler!.domains.contains(uri.host)) {
|
||||
@@ -22,9 +22,11 @@ void handleAppLink(Uri uri) async {
|
||||
App.mainNavigatorKey!.currentContext?.to(() {
|
||||
return ComicPage(id: id, sourceKey: source.key);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
@@ -86,6 +86,9 @@ abstract class CBZ {
|
||||
var ext = e.path.split('.').last;
|
||||
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
|
||||
});
|
||||
if(files.isEmpty) {
|
||||
throw Exception('No images found in the archive');
|
||||
}
|
||||
files.sort((a, b) => a.path.compareTo(b.path));
|
||||
var coverFile = files.firstWhereOrNull(
|
||||
(element) =>
|
||||
@@ -108,7 +111,7 @@ abstract class CBZ {
|
||||
var src = files[i];
|
||||
var dst = File(
|
||||
FilePath.join(dest.path, '${i + 1}.${src.path.split('.').last}'));
|
||||
src.copy(dst.path);
|
||||
await src.copy(dst.path);
|
||||
}
|
||||
} else {
|
||||
dest.createSync();
|
||||
@@ -126,7 +129,7 @@ abstract class CBZ {
|
||||
var src = chapter.value[i];
|
||||
var dst = File(FilePath.join(
|
||||
chapterDir.path, '${i + 1}.${src.path.split('.').last}'));
|
||||
src.copy(dst.path);
|
||||
await src.copy(dst.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,7 +187,7 @@ abstract class CBZ {
|
||||
}
|
||||
int i = 1;
|
||||
for (var image in allImages) {
|
||||
var src = File(image.replaceFirst('file://', ''));
|
||||
var src = openFilePlatform(image);
|
||||
var width = allImages.length.toString().length;
|
||||
var dstName =
|
||||
'${i.toString().padLeft(width, '0')}.${image.split('.').last}';
|
||||
|
@@ -71,12 +71,14 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
|
||||
LocalFavoritesManager().init();
|
||||
}
|
||||
if (await appdataFile.exists()) {
|
||||
// proxy settings should be kept
|
||||
// proxy settings & authorization setting should be kept
|
||||
var proxySettings = appdata.settings["proxy"];
|
||||
var authSettings = appdata.settings["authorizationRequired"];
|
||||
File(FilePath.join(App.dataPath, "appdata.json")).deleteIfExistsSync();
|
||||
appdataFile.renameSync(FilePath.join(App.dataPath, "appdata.json"));
|
||||
await appdata.init();
|
||||
appdata.settings["proxy"] = proxySettings;
|
||||
appdata.settings["authorizationRequired"] = authSettings;
|
||||
appdata.saveData();
|
||||
}
|
||||
if (await cookieFile.exists()) {
|
||||
|
@@ -10,8 +10,14 @@ class FileType {
|
||||
if(ext.startsWith('.')) {
|
||||
ext = ext.substring(1);
|
||||
}
|
||||
var mime = lookupMimeType('no-file.$ext');
|
||||
return FileType(".$ext", mime ?? 'application/octet-stream');
|
||||
var mime = lookupMimeType('no-file.$ext') ?? 'application/octet-stream';
|
||||
// Android doesn't support some mime types
|
||||
mime = switch(mime) {
|
||||
'text/javascript' => 'application/javascript',
|
||||
'application/x-cbr' => 'application/octet-stream',
|
||||
_ => mime,
|
||||
};
|
||||
return FileType(".$ext", mime);
|
||||
}
|
||||
}
|
||||
|
||||
|
354
lib/utils/import_comic.dart
Normal file
354
lib/utils/import_comic.dart
Normal file
@@ -0,0 +1,354 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' as sql;
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
import 'cbz.dart';
|
||||
import 'io.dart';
|
||||
|
||||
class ImportComic {
|
||||
final String? selectedFolder;
|
||||
final bool copyToLocal;
|
||||
|
||||
const ImportComic({this.selectedFolder, this.copyToLocal = true});
|
||||
|
||||
Future<bool> cbz() async {
|
||||
var file = await selectFile(ext: ['cbz']);
|
||||
Map<String?, List<LocalComic>> imported = {};
|
||||
if(file == null) {
|
||||
return false;
|
||||
}
|
||||
var controller = showLoadingDialog(App.rootContext, allowCancel: false);
|
||||
try {
|
||||
var comic = await CBZ.import(File(file.path));
|
||||
imported[selectedFolder] = [comic];
|
||||
} catch (e, s) {
|
||||
Log.error("Import Comic", e.toString(), s);
|
||||
App.rootContext.showMessage(message: e.toString());
|
||||
}
|
||||
controller.close();
|
||||
return registerComics(imported, true);
|
||||
}
|
||||
|
||||
Future<bool> ehViewer() async {
|
||||
var dbFile = await selectFile(ext: ['db']);
|
||||
final picker = DirectoryPicker();
|
||||
final comicSrc = await picker.pickDirectory();
|
||||
Map<String?, List<LocalComic>> imported = {};
|
||||
if (dbFile == null || comicSrc == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cancelled = false;
|
||||
var controller = showLoadingDialog(App.rootContext, onCancel: () {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
try {
|
||||
var db = sql.sqlite3.open(dbFile.path);
|
||||
|
||||
Future<List<LocalComic>> validateComics(List<sql.Row> comics) async {
|
||||
List<LocalComic> imported = [];
|
||||
for (var comic in comics) {
|
||||
if (cancelled) {
|
||||
return imported;
|
||||
}
|
||||
var comicDir = openDirectoryPlatform(
|
||||
FilePath.join(comicSrc.path, comic['DIRNAME'] as String));
|
||||
String titleJP =
|
||||
comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String;
|
||||
String title = titleJP == "" ? comic['TITLE'] as String : titleJP;
|
||||
int timeStamp = comic['TIME'] as int;
|
||||
DateTime downloadTime = timeStamp != 0
|
||||
? DateTime.fromMillisecondsSinceEpoch(timeStamp)
|
||||
: DateTime.now();
|
||||
var comicObj = await _checkSingleComic(comicDir,
|
||||
title: title,
|
||||
tags: [
|
||||
//1 >> x
|
||||
[
|
||||
"MISC",
|
||||
"DOUJINSHI",
|
||||
"MANGA",
|
||||
"ARTISTCG",
|
||||
"GAMECG",
|
||||
"IMAGE SET",
|
||||
"COSPLAY",
|
||||
"ASIAN PORN",
|
||||
"NON-H",
|
||||
"WESTERN",
|
||||
][(log(comic['CATEGORY'] as int) / ln2).floor()]
|
||||
],
|
||||
createTime: downloadTime);
|
||||
if (comicObj == null) {
|
||||
continue;
|
||||
}
|
||||
imported.add(comicObj);
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
var tags = <String>[""];
|
||||
tags.addAll(db.select("""
|
||||
SELECT * FROM DOWNLOAD_LABELS LB
|
||||
ORDER BY LB.TIME DESC;
|
||||
""").map((r) => r['LABEL'] as String).toList());
|
||||
|
||||
for (var tag in tags) {
|
||||
if (cancelled) {
|
||||
break;
|
||||
}
|
||||
var folderName =
|
||||
tag == '' ? '(EhViewer)Default'.tl : '(EhViewer)$tag';
|
||||
var comicList = db.select("""
|
||||
SELECT *
|
||||
FROM DOWNLOAD_DIRNAME DN
|
||||
LEFT JOIN DOWNLOADS DL
|
||||
ON DL.GID = DN.GID
|
||||
WHERE DL.LABEL ${tag == '' ? 'IS NULL' : '= \'$tag\''} AND DL.STATE = 3
|
||||
ORDER BY DL.TIME DESC
|
||||
""").toList();
|
||||
|
||||
var validComics = await validateComics(comicList);
|
||||
imported[folderName] = validComics;
|
||||
if (validComics.isNotEmpty &&
|
||||
!LocalFavoritesManager().existsFolder(folderName)) {
|
||||
LocalFavoritesManager().createFolder(folderName);
|
||||
}
|
||||
}
|
||||
db.dispose();
|
||||
|
||||
//Android specific
|
||||
var cache = FilePath.join(App.cachePath, dbFile.name);
|
||||
await File(cache).deleteIgnoreError();
|
||||
} catch (e, s) {
|
||||
Log.error("Import Comic", e.toString(), s);
|
||||
App.rootContext.showMessage(message: e.toString());
|
||||
}
|
||||
controller.close();
|
||||
if(cancelled) return false;
|
||||
return registerComics(imported, copyToLocal);
|
||||
}
|
||||
|
||||
Future<bool> directory(bool single) async {
|
||||
final picker = DirectoryPicker();
|
||||
final path = await picker.pickDirectory();
|
||||
if (path == null) {
|
||||
return false;
|
||||
}
|
||||
Map<String?, List<LocalComic>> imported = {selectedFolder: []};
|
||||
try {
|
||||
if (single) {
|
||||
var result = await _checkSingleComic(path);
|
||||
if (result != null) {
|
||||
imported[selectedFolder]!.add(result);
|
||||
} else {
|
||||
App.rootContext.showMessage(message: "Invalid Comic".tl);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
await for (var entry in path.list()) {
|
||||
if (entry is Directory) {
|
||||
var result = await _checkSingleComic(entry);
|
||||
if (result != null) {
|
||||
imported[selectedFolder]!.add(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e, s) {
|
||||
Log.error("Import Comic", e.toString(), s);
|
||||
App.rootContext.showMessage(message: e.toString());
|
||||
}
|
||||
return registerComics(imported, copyToLocal);
|
||||
}
|
||||
|
||||
//Automatically search for cover image and chapters
|
||||
Future<LocalComic?> _checkSingleComic(Directory directory,
|
||||
{String? id,
|
||||
String? title,
|
||||
String? subtitle,
|
||||
List<String>? tags,
|
||||
DateTime? createTime})
|
||||
async {
|
||||
if (!(await directory.exists())) return null;
|
||||
var name = title ?? directory.name;
|
||||
if (LocalManager().findByName(name) != null) {
|
||||
Log.info("Import Comic", "Comic already exists: $name");
|
||||
return null;
|
||||
}
|
||||
bool hasChapters = false;
|
||||
var chapters = <String>[];
|
||||
var coverPath = ''; // relative path to the cover image
|
||||
var fileList = <String>[];
|
||||
await for (var entry in directory.list()) {
|
||||
if (entry is Directory) {
|
||||
hasChapters = true;
|
||||
chapters.add(entry.name);
|
||||
await for (var file in entry.list()) {
|
||||
if (file is Directory) {
|
||||
Log.info("Import Comic",
|
||||
"Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else if (entry is File) {
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'];
|
||||
if (imageExtensions.contains(entry.extension)) {
|
||||
fileList.add(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(fileList.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
fileList.sort();
|
||||
coverPath = fileList.firstWhereOrNull((l) => l.startsWith('cover')) ?? fileList.first;
|
||||
|
||||
chapters.sort();
|
||||
if (hasChapters && coverPath == '') {
|
||||
// use the first image in the first chapter as the cover
|
||||
var firstChapter = openDirectoryPlatform('${directory.path}/${chapters.first}');
|
||||
await for (var entry in firstChapter.list()) {
|
||||
if (entry is File) {
|
||||
coverPath = entry.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (coverPath == '') {
|
||||
Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found.");
|
||||
return null;
|
||||
}
|
||||
return LocalComic(
|
||||
id: id ?? '0',
|
||||
title: name,
|
||||
subtitle: subtitle ?? '',
|
||||
tags: tags ?? [],
|
||||
directory: directory.path,
|
||||
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
|
||||
cover: coverPath,
|
||||
comicType: ComicType.local,
|
||||
downloadedChapters: chapters,
|
||||
createdAt: createTime ?? DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<Map<String, String>> _copyDirectories(Map<String, dynamic> data) async {
|
||||
var toBeCopied = data['toBeCopied'] as List<String>;
|
||||
var destination = data['destination'] as String;
|
||||
Map<String, String> result = {};
|
||||
for (var dir in toBeCopied) {
|
||||
var source = openDirectoryPlatform(dir);
|
||||
var dest = openDirectoryPlatform("$destination/${source.name}");
|
||||
if (dest.existsSync()) {
|
||||
// The destination directory already exists, and it is not managed by the app.
|
||||
// Rename the old directory to avoid conflicts.
|
||||
Log.info("Import Comic",
|
||||
"Directory already exists: ${source.name}\nRenaming the old directory.");
|
||||
await dest.rename(
|
||||
findValidDirectoryName(dest.parent.path, "${dest.path}_old"));
|
||||
}
|
||||
dest.createSync();
|
||||
await copyDirectory(source, dest);
|
||||
result[source.path] = dest.path;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<String?, List<LocalComic>>> _copyComicsToLocalDir(
|
||||
Map<String?, List<LocalComic>> comics) async {
|
||||
var destPath = LocalManager().path;
|
||||
Map<String?, List<LocalComic>> result = {};
|
||||
for (var favoriteFolder in comics.keys) {
|
||||
result[favoriteFolder] = comics[favoriteFolder]!
|
||||
.where((c) => c.directory.startsWith(destPath))
|
||||
.toList();
|
||||
comics[favoriteFolder]!
|
||||
.removeWhere((c) => c.directory.startsWith(destPath));
|
||||
|
||||
if (comics[favoriteFolder]!.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// copy the comics to the local directory
|
||||
var pathMap = await compute<Map<String, dynamic>, Map<String, String>>(
|
||||
_copyDirectories, {
|
||||
'toBeCopied': comics[favoriteFolder]!.map((e) => e.directory).toList(),
|
||||
'destination': destPath,
|
||||
});
|
||||
//Construct a new object since LocalComic.directory is a final String
|
||||
for (var c in comics[favoriteFolder]!) {
|
||||
result[favoriteFolder]!.add(
|
||||
LocalComic(
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
subtitle: c.subtitle,
|
||||
tags: c.tags,
|
||||
directory: pathMap[c.directory]!,
|
||||
chapters: c.chapters,
|
||||
cover: c.cover,
|
||||
comicType: c.comicType,
|
||||
downloadedChapters: c.downloadedChapters,
|
||||
createdAt: c.createdAt
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
App.rootContext.showMessage(message: "Failed to copy comics".tl);
|
||||
Log.error("Import Comic", e.toString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<bool> registerComics(Map<String?, List<LocalComic>> importedComics, bool copy) async {
|
||||
try {
|
||||
if (copy) {
|
||||
importedComics = await _copyComicsToLocalDir(importedComics);
|
||||
}
|
||||
int importedCount = 0;
|
||||
for (var folder in importedComics.keys) {
|
||||
for (var comic in importedComics[folder]!) {
|
||||
var id = LocalManager().findValidId(ComicType.local);
|
||||
LocalManager().add(comic, id);
|
||||
importedCount++;
|
||||
if (folder != null) {
|
||||
LocalFavoritesManager().addComic(
|
||||
folder,
|
||||
FavoriteItem(
|
||||
id: id,
|
||||
name: comic.title,
|
||||
coverPath: comic.cover,
|
||||
author: comic.subtitle,
|
||||
type: comic.comicType,
|
||||
tags: comic.tags,
|
||||
favoriteTime: comic.createdAt
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
App.rootContext.showMessage(
|
||||
message: "Imported @a comics".tlParams({
|
||||
'a': importedCount,
|
||||
}));
|
||||
} catch(e) {
|
||||
App.rootContext.showMessage(message: "Failed to register comics".tl);
|
||||
Log.error("Import Comic", e.toString());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
@@ -4,6 +4,7 @@ import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_file_dialog/flutter_file_dialog.dart';
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -14,6 +15,16 @@ import 'package:venera/utils/file_type.dart';
|
||||
export 'dart:io';
|
||||
export 'dart:typed_data';
|
||||
|
||||
class IO {
|
||||
/// A global flag used to indicate whether the app is selecting files.
|
||||
///
|
||||
/// Select file and other similar file operations will launch external programs,
|
||||
/// causing the app to lose focus. AppLifecycleState will be set to paused.
|
||||
static bool get isSelectingFiles => _isSelectingFiles;
|
||||
|
||||
static bool _isSelectingFiles = false;
|
||||
}
|
||||
|
||||
class FilePath {
|
||||
const FilePath._();
|
||||
|
||||
@@ -70,7 +81,7 @@ extension DirectoryExtension on Directory {
|
||||
int total = 0;
|
||||
for (var f in listSync(recursive: true)) {
|
||||
if (FileSystemEntity.typeSync(f.path) == FileSystemEntityType.file) {
|
||||
total += await File(f.path).length();
|
||||
total += await openFilePlatform(f.path).length();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
@@ -82,7 +93,7 @@ extension DirectoryExtension on Directory {
|
||||
}
|
||||
|
||||
File joinFile(String name) {
|
||||
return File(FilePath.join(path, name));
|
||||
return openFilePlatform(FilePath.join(path, name));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +131,7 @@ Future<void> copyDirectory(Directory source, Directory destination) async {
|
||||
if (content is File) {
|
||||
content.copySync(newPath);
|
||||
} else if (content is Directory) {
|
||||
Directory newDirectory = Directory(newPath);
|
||||
Directory newDirectory = openDirectoryPlatform(newPath);
|
||||
newDirectory.createSync();
|
||||
copyDirectory(content.absolute, newDirectory.absolute);
|
||||
}
|
||||
@@ -136,47 +147,52 @@ Future<void> copyDirectoryIsolate(
|
||||
|
||||
String findValidDirectoryName(String path, String directory) {
|
||||
var name = sanitizeFileName(directory);
|
||||
var dir = Directory("$path/$name");
|
||||
var dir = openDirectoryPlatform("$path/$name");
|
||||
var i = 1;
|
||||
while (dir.existsSync() && dir.listSync().isNotEmpty) {
|
||||
name = sanitizeFileName("$directory($i)");
|
||||
dir = Directory("$path/$name");
|
||||
dir = openDirectoryPlatform("$path/$name");
|
||||
i++;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
class DirectoryPicker {
|
||||
String? _directory;
|
||||
/// Pick a directory.
|
||||
///
|
||||
/// The directory may not be usable after the instance is GCed.
|
||||
DirectoryPicker();
|
||||
|
||||
final _methodChannel = const MethodChannel("venera/method_channel");
|
||||
|
||||
Future<Directory?> pickDirectory() async {
|
||||
if (App.isWindows || App.isLinux) {
|
||||
var d = await file_selector.getDirectoryPath();
|
||||
_directory = d;
|
||||
return d == null ? null : Directory(d);
|
||||
} else if (App.isAndroid) {
|
||||
var d = await _methodChannel.invokeMethod<String?>("getDirectoryPath");
|
||||
_directory = d;
|
||||
return d == null ? null : Directory(d);
|
||||
} else {
|
||||
// ios, macos
|
||||
var d = await _methodChannel.invokeMethod<String?>("getDirectoryPath");
|
||||
_directory = d;
|
||||
return d == null ? null : Directory(d);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
if (_directory == null) {
|
||||
return;
|
||||
}
|
||||
if (App.isAndroid && _directory != null) {
|
||||
return Directory(_directory!).deleteIgnoreError(recursive: true);
|
||||
static final _finalizer = Finalizer<String>((path) {
|
||||
if (path.startsWith(App.cachePath)) {
|
||||
Directory(path).deleteIgnoreError();
|
||||
}
|
||||
if (App.isIOS || App.isMacOS) {
|
||||
await _methodChannel.invokeMethod("stopAccessingSecurityScopedResource");
|
||||
_methodChannel.invokeMethod("stopAccessingSecurityScopedResource");
|
||||
}
|
||||
});
|
||||
|
||||
static const _methodChannel = MethodChannel("venera/method_channel");
|
||||
|
||||
Future<Directory?> pickDirectory() async {
|
||||
IO._isSelectingFiles = true;
|
||||
try {
|
||||
String? directory;
|
||||
if (App.isWindows || App.isLinux) {
|
||||
directory = await file_selector.getDirectoryPath();
|
||||
} else if (App.isAndroid) {
|
||||
directory = (await AndroidDirectory.pickDirectory())?.path;
|
||||
} else {
|
||||
// ios, macos
|
||||
directory = await _methodChannel.invokeMethod<String?>("getDirectoryPath");
|
||||
}
|
||||
if (directory == null) return null;
|
||||
_finalizer.attach(this, directory);
|
||||
return openDirectoryPlatform(directory);
|
||||
} finally {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
IO._isSelectingFiles = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,54 +202,74 @@ class IOSDirectoryPicker {
|
||||
|
||||
// 调用 iOS 目录选择方法
|
||||
static Future<String?> selectDirectory() async {
|
||||
IO._isSelectingFiles = true;
|
||||
try {
|
||||
final String? path = await _channel.invokeMethod('selectDirectory');
|
||||
return path;
|
||||
} catch (e) {
|
||||
print("Error selecting directory: $e");
|
||||
// 返回报错信息
|
||||
return e.toString();
|
||||
} finally {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
IO._isSelectingFiles = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
label: 'files',
|
||||
extensions: extensions,
|
||||
);
|
||||
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],
|
||||
Future<FileSelectResult?> selectFile({required List<String> ext}) async {
|
||||
IO._isSelectingFiles = true;
|
||||
try {
|
||||
var extensions = App.isMacOS || App.isIOS ? null : ext;
|
||||
file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup(
|
||||
label: 'files',
|
||||
extensions: extensions,
|
||||
);
|
||||
if (file == null) return null;
|
||||
FileSelectResult? file;
|
||||
if (App.isAndroid) {
|
||||
const selectFileChannel = MethodChannel("venera/select_file");
|
||||
String mimeType = "*/*";
|
||||
if (ext.length == 1) {
|
||||
mimeType = FileType.fromExtension(ext[0]).mime;
|
||||
if (mimeType == "application/octet-stream") {
|
||||
mimeType = "*/*";
|
||||
}
|
||||
}
|
||||
var filePath = await selectFileChannel.invokeMethod(
|
||||
"selectFile",
|
||||
mimeType,
|
||||
);
|
||||
if (filePath == null) return null;
|
||||
file = FileSelectResult(filePath);
|
||||
} else {
|
||||
var xFile = await file_selector.openFile(
|
||||
acceptedTypeGroups: <file_selector.XTypeGroup>[typeGroup],
|
||||
);
|
||||
if (xFile == null) return null;
|
||||
file = FileSelectResult(xFile.path);
|
||||
}
|
||||
if (!ext.contains(file.path.split(".").last)) {
|
||||
App.rootContext.showMessage(message: "Invalid file type");
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
} finally {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
IO._isSelectingFiles = false;
|
||||
});
|
||||
}
|
||||
if (!ext.contains(file.path.split(".").last)) {
|
||||
App.rootContext.showMessage(message: "Invalid file type");
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<String?> selectDirectory() async {
|
||||
var path = await file_selector.getDirectoryPath();
|
||||
return path;
|
||||
IO._isSelectingFiles = true;
|
||||
try {
|
||||
var path = await file_selector.getDirectoryPath();
|
||||
return path;
|
||||
} finally {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
IO._isSelectingFiles = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// selectDirectoryIOS
|
||||
@@ -246,25 +282,59 @@ Future<void> saveFile(
|
||||
if (data == null && file == null) {
|
||||
throw Exception("data and file cannot be null at the same time");
|
||||
}
|
||||
if (data != null) {
|
||||
var cache = FilePath.join(App.cachePath, filename);
|
||||
if (File(cache).existsSync()) {
|
||||
File(cache).deleteSync();
|
||||
IO._isSelectingFiles = true;
|
||||
try {
|
||||
if (data != null) {
|
||||
var cache = FilePath.join(App.cachePath, filename);
|
||||
if (File(cache).existsSync()) {
|
||||
File(cache).deleteSync();
|
||||
}
|
||||
await File(cache).writeAsBytes(data);
|
||||
file = File(cache);
|
||||
}
|
||||
await File(cache).writeAsBytes(data);
|
||||
file = File(cache);
|
||||
if (App.isMobile) {
|
||||
final params = SaveFileDialogParams(sourceFilePath: file!.path);
|
||||
await FlutterFileDialog.saveFile(params: params);
|
||||
} else {
|
||||
final result = await file_selector.getSaveLocation(
|
||||
suggestedName: filename,
|
||||
);
|
||||
if (result != null) {
|
||||
var xFile = file_selector.XFile(file!.path);
|
||||
await xFile.saveTo(result.path);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
IO._isSelectingFiles = false;
|
||||
});
|
||||
}
|
||||
if (App.isMobile) {
|
||||
final params = SaveFileDialogParams(sourceFilePath: file!.path);
|
||||
await FlutterFileDialog.saveFile(params: params);
|
||||
} else {
|
||||
final result = await file_selector.getSaveLocation(
|
||||
suggestedName: filename,
|
||||
);
|
||||
if (result != null) {
|
||||
var xFile = file_selector.XFile(file!.path);
|
||||
await xFile.saveTo(result.path);
|
||||
}
|
||||
|
||||
Directory openDirectoryPlatform(String path) {
|
||||
if(App.isAndroid) {
|
||||
var dir = AndroidDirectory.fromPathSync(path);
|
||||
if(dir == null) {
|
||||
return Directory(path);
|
||||
}
|
||||
return dir;
|
||||
} else {
|
||||
return Directory(path);
|
||||
}
|
||||
}
|
||||
|
||||
File openFilePlatform(String path) {
|
||||
if(path.startsWith("file://")) {
|
||||
path = path.substring(7);
|
||||
}
|
||||
if(App.isAndroid) {
|
||||
var f = AndroidFile.fromPathSync(path);
|
||||
if(f == null) {
|
||||
return File(path);
|
||||
}
|
||||
return f;
|
||||
} else {
|
||||
return File(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,3 +373,27 @@ String bytesToReadableString(int bytes) {
|
||||
return "${(bytes / 1024 / 1024 / 1024).toStringAsFixed(2)} GB";
|
||||
}
|
||||
}
|
||||
|
||||
class FileSelectResult {
|
||||
final String path;
|
||||
|
||||
static final _finalizer = Finalizer<String>((path) {
|
||||
if (path.startsWith(App.cachePath)) {
|
||||
File(path).deleteIgnoreError();
|
||||
}
|
||||
});
|
||||
|
||||
FileSelectResult(this.path) {
|
||||
_finalizer.attach(this, path);
|
||||
}
|
||||
|
||||
Future<void> saveTo(String path) async {
|
||||
await File(this.path).copy(path);
|
||||
}
|
||||
|
||||
Future<Uint8List> readAsBytes() {
|
||||
return File(path).readAsBytes();
|
||||
}
|
||||
|
||||
String get name => File(path).name;
|
||||
}
|
71
pubspec.lock
71
pubspec.lock
@@ -356,6 +356,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -381,6 +389,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.1"
|
||||
flutter_saf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "829a566b738a26ea98e523807f49838e21308543"
|
||||
resolved-ref: "829a566b738a26ea98e523807f49838e21308543"
|
||||
url: "https://github.com/pkuislm/flutter_saf.git"
|
||||
source: git
|
||||
version: "0.0.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -512,6 +529,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -549,10 +606,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mime
|
||||
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
version: "2.0.0"
|
||||
mime_type:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -719,18 +776,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: "468c43f285207c84bcabf5737f33b914ceb8eb38398b91e5e3ad1698d1b72a52"
|
||||
sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.2"
|
||||
version: "10.1.2"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "6ababf341050edff57da8b6990f11f4e99eaba837865e2e6defe16d039619db5"
|
||||
sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
version: "5.0.1"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -988,4 +1045,4 @@ packages:
|
||||
version: "0.0.1"
|
||||
sdks:
|
||||
dart: ">=3.5.4 <4.0.0"
|
||||
flutter: ">=3.24.4"
|
||||
flutter: ">=3.24.5"
|
||||
|
11
pubspec.yaml
11
pubspec.yaml
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.5+105
|
||||
version: 1.0.7+107
|
||||
|
||||
environment:
|
||||
sdk: '>=3.5.0 <4.0.0'
|
||||
flutter: 3.24.4
|
||||
flutter: 3.24.5
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -32,7 +32,7 @@ dependencies:
|
||||
git:
|
||||
url: https://github.com/wgh136/photo_view
|
||||
ref: 94724a0b
|
||||
mime: ^1.0.5
|
||||
mime: ^2.0.0
|
||||
share_plus: ^10.0.2
|
||||
scrollable_positioned_list:
|
||||
git:
|
||||
@@ -64,6 +64,11 @@ dependencies:
|
||||
url: https://github.com/wgh136/webdav_client
|
||||
ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1
|
||||
battery_plus: ^6.2.0
|
||||
local_auth: ^2.3.0
|
||||
flutter_saf:
|
||||
git:
|
||||
url: https://github.com/pkuislm/flutter_saf.git
|
||||
ref: dd5242918da0ea9a0a50b0f87ade7a2def65453d
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
1
windows/.gitignore
vendored
1
windows/.gitignore
vendored
@@ -15,3 +15,4 @@ x86/
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
/ChineseSimplified.isl
|
||||
|
@@ -33,7 +33,7 @@ WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
|
||||
Name: "chinesesimplified"; MessagesFile: "{#RootPath}\windows\ChineseSimplified.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
@@ -54,6 +54,7 @@ Source: "{#RootPath}\build\windows\x64\runner\Release\url_launcher_windows_plugi
|
||||
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\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\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "{#RootPath}\build\windows\x64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import subprocess
|
||||
import os
|
||||
import httpx
|
||||
|
||||
file = open('pubspec.yaml', 'r')
|
||||
content = file.read()
|
||||
@@ -26,6 +27,13 @@ file = open('windows/build.iss', 'w')
|
||||
file.write(newContent)
|
||||
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)
|
||||
|
||||
with open('windows/build.iss', 'w') as file:
|
||||
|
Reference in New Issue
Block a user