56 Commits

Author SHA1 Message Date
nyne
ce48a89cc1 Merge pull request #71 from venera-app/dev
v1.0.7
2024-11-24 15:56:32 +08:00
f155bed694 update flutter_saf 2024-11-24 15:21:22 +08:00
1500d2a1d2 fix getImages 2024-11-24 13:12:06 +08:00
2408096a7c fix cbz export 2024-11-24 12:53:06 +08:00
bf1930cea2 show comment action button if comic.comments is empty 2024-11-24 12:48:37 +08:00
5d99b6ed99 fix download 2024-11-24 12:47:08 +08:00
e2aceb857d handle invalid local path 2024-11-24 12:03:12 +08:00
4b32165aae update version code 2024-11-24 11:08:21 +08:00
5bc3ddaf26 fix the issue of opening a local comic in history page 2024-11-24 10:43:53 +08:00
904e4f1186 fix the issue of hiding UI 2024-11-24 10:37:17 +08:00
511a9fdc09 hide "Copy to app local path" option on iOS and macOS 2024-11-23 18:45:10 +08:00
c2b8760d86 Add AppbarStyle.shadow;
Improve favorites page ui.
2024-11-23 12:12:52 +08:00
pkuislm
a1474ca9c3 更改安卓端的文件访问方式,优化导入逻辑 (#64)
* Refactor import function & Allow import local comics without copying them to local path.

* android: use file_picker instead, support directory access for android 10

* Improve import logic

* Fix sql query.

* Add ability to remove invalid favorite items.

* Perform sort before choosing cover

* Revert changes of "use file_picker instead".

* Try catch on "check update"

* Added module 'flutter_saf'

* gitignore

* remove unsupported arch in build.gradle

* Use flutter_saf to handle android's directory and files, improve import logic.

* revert changes of 'requestLegacyExternalStorage'

* fix cbz import

* openDirectoryPlatform

* Remove double check on source folder

* use openFilePlatform

* remove unused import

* improve local comic's path handling

* bump version

* fix pubspec format

* return null when comic folder is empty
2024-11-23 11:05:00 +08:00
boa
c3474b1dff change iOS default local path to Documents (#68) 2024-11-23 00:25:38 +08:00
Pacalini
2f290f0c86 universal: style improvements (#67) 2024-11-22 16:47:50 +08:00
AnxuNA
8b1f13cd33 Add Favorite multiple selections (#66) 2024-11-22 12:21:22 +08:00
f3aa0e9f27 fix comment 2024-11-22 10:24:31 +08:00
f4b9cb5abe limitImageWidth should only be enabled with ReaderMode.continuousTopToBottom 2024-11-21 21:38:41 +08:00
4d55e6a72f move checkUpdates to main_page 2024-11-21 21:36:08 +08:00
ad3f2fab45 add archive download 2024-11-21 21:29:45 +08:00
b1cdcc2a91 fix copyDirectories 2024-11-20 18:12:27 +08:00
7fcb63c0cb show comments in comic details page 2024-11-20 18:04:22 +08:00
454497fd65 fix mime 2024-11-20 13:25:56 +08:00
AnxuNA
c4aab2369f Fix _buildBriefMode display (#58) 2024-11-20 09:33:33 +08:00
ce175a2135 improve importing comic 2024-11-19 20:52:13 +08:00
6aeaeadb10 fix & improve importing comic 2024-11-19 18:44:52 +08:00
Pacalini
8402c1c9f3 authorize: auto-raise & skip on import (#56) 2024-11-19 16:01:35 +08:00
ed67bc80ea fix windows webview 2024-11-18 22:22:10 +08:00
AnxuNA
eb3a7f9d52 add Chapter && Page translate (#54)
add Chapter && Page translate
2024-11-18 21:27:47 +08:00
nyne
0d77803e8c Merge pull request #53 from venera-app/dev
v1.0.6
2024-11-18 18:20:39 +08:00
8db52c9db1 update version code 2024-11-18 17:57:35 +08:00
ce6f65f912 fix auto link 2024-11-18 17:56:27 +08:00
689700f52a improve tab bar 2024-11-18 17:42:20 +08:00
250f458029 improve word segmentation 2024-11-18 17:22:25 +08:00
1489e6c86d add appVersion to JsEngine 2024-11-18 17:02:07 +08:00
b4921c8e14 support rich text comment 2024-11-18 16:59:54 +08:00
800b67fb28 fix network issue 2024-11-18 10:56:19 +08:00
AnxuNA
036474a5d2 Optimization _buildBriefMode (#51)
更改_buildBriefMode样式
2024-11-17 22:55:04 +08:00
a1d1f504bd fix windows build 2024-11-17 21:22:55 +08:00
458bc261f3 update workflow 2024-11-17 20:57:39 +08:00
00af5f1989 update workflow 2024-11-17 20:50:32 +08:00
9988e76149 update workflow 2024-11-17 18:43:29 +08:00
213179b8c2 update workflow 2024-11-17 18:25:18 +08:00
708cf83a32 improve ui 2024-11-17 17:23:43 +08:00
0ee99a8760 fix android method channel 2024-11-16 19:13:19 +08:00
30a1c806cd Convert network folder to local 2024-11-16 16:51:56 +08:00
Pacalini
7bc0aeb4af tool bar: RtL slider & button swap (#50) 2024-11-16 16:07:39 +08:00
8513a739ec When AppLifecycleState is changed to resumed, check for data updates. 2024-11-15 22:14:53 +08:00
AnxuNA
d749e7421e Open in Browser Translation (#49)
Open in Browser Translation
2024-11-15 21:16:17 +08:00
165e5f2850 add authorization 2024-11-15 18:27:59 +08:00
edff9c7a0c fix config update issue 2024-11-15 17:03:41 +08:00
boa
65b41b2873 add option to ignore certificate errors (#46)
add option to ignore certificate errors
2024-11-14 20:40:28 +08:00
AnxuNA
f912e57bfd Change the style of _buildBriefMode. (#44)
Change the style of _buildBriefMode
2024-11-14 19:29:46 +08:00
nyne
2ef03ad7ae Merge pull request #42 from Pacalini/dev
reader: fix start/end flipping
2024-11-14 18:24:26 +08:00
Pacalini
47eb597d96 reader: fix start/end flipping 2024-11-14 18:10:47 +08:00
0ac9ee7061 fix #37 2024-11-14 15:28:57 +08:00
70 changed files with 4179 additions and 1244 deletions

View File

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

View File

@@ -1,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
View File

@@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
/app/.cxx/

View File

@@ -34,6 +34,8 @@ android {
splits{
abi {
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
enable true
universalApk true
}

View File

@@ -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}"

View File

@@ -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()
}
}

View File

@@ -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;
}
/**

View File

@@ -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": "沒有新版本可用"
}
}

View File

@@ -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>

View File

@@ -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),

View File

@@ -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),
),

View File

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

View File

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

View File

@@ -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);

View File

@@ -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});
}

View File

@@ -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),
),
],

View File

@@ -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

View File

@@ -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;

View File

@@ -119,6 +119,8 @@ class _Settings with ChangeNotifier {
'quickFavorite': null,
'enableTurnPageByVolumeKey': true,
'enableClockAndBatteryInfoInReader': true,
'ignoreCertificateErrors': false,
'authorizationRequired': false,
};
operator [](String key) {

View File

@@ -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);
}

View File

@@ -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"];
}

View File

@@ -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());
}
},
);
}
}

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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'],

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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));
}

View File

@@ -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';

View File

@@ -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(),
);
}
}

View 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
View 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();
}
}
}

View File

@@ -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),
),
),
);

View File

@@ -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),
],
),
);
}
}

View File

@@ -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 {

View File

@@ -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('&amp;', '&');
void writeBuffer() {
if (buffer.isEmpty) return;
var span = TextSpan(text: buffer.toString());
for (var tag in s) {
span = tag.merge(span, context);
}
textSpan.add(span);
buffer.clear();
}
while (i < text.length) {
if (text[i] == '<' && i != text.length - 1) {
if (text[i + 1] != '/') {
// start tag
var j = text.indexOf('>', i);
if (j != -1) {
var tagContent = text.substring(i + 1, j);
var splits = tagContent.split(' ');
splits.removeWhere((element) => element.isEmpty);
var tagName = splits[0];
var attributes = <String, String>{};
for (var k = 1; k < splits.length; k++) {
var attr = splits[k];
var attrSplits = attr.split('=');
if (attrSplits.length == 2) {
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', '');
}
}
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span'];
if (acceptedTags.contains(tagName)) {
writeBuffer();
if (tagName == 'img') {
var url = attributes['src'];
String? link;
for (var tag in s) {
if (tag.name == 'a') {
link = tag.attributes['href'];
break;
}
}
if (url != null) {
images.add(_CommentImage(url, link));
}
} else if (tagName == 'br') {
buffer.write('\n');
} else {
s.add(_Tag(tagName, attributes));
}
i = j + 1;
continue;
}
}
} else {
// end tag
var j = text.indexOf('>', i);
if (j != -1) {
var tagContent = text.substring(i + 2, j);
var splits = tagContent.split(' ');
splits.removeWhere((element) => element.isEmpty);
var tagName = splits[0];
if (s.isNotEmpty && s.last.name == tagName) {
writeBuffer();
s.removeLast();
i = j + 1;
continue;
}
if (tagName == 'br') {
i = j + 1;
buffer.write('\n');
continue;
}
}
}
} else if (text.length - i > 8 &&
text.substring(i, i + 4) == 'http' &&
!s.any((e) => e.name == 'a')) {
// auto link
int j = i;
for (; j < text.length; j++) {
if (!isValidUrlChar(text[j])) {
break;
}
}
var url = text.substring(i, j);
if (url.isURL) {
writeBuffer();
textSpan.add(TextSpan(
text: url,
style: ts.withColor(context.colorScheme.primary),
recognizer: TapGestureRecognizer()
..onTap = () {
_Tag.handleLink(url);
},
));
i = j;
continue;
}
}
buffer.write(text[i]);
i++;
}
writeBuffer();
}
@override
Widget build(BuildContext context) {
Widget content = SelectableText.rich(
TextSpan(
style: DefaultTextStyle.of(context).style,
children: textSpan,
),
);
if (images.isNotEmpty) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
content,
Wrap(
runSpacing: 4,
spacing: 4,
children: images.map((e) {
Widget image = Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.surfaceContainerLow,
),
width: 100,
height: 100,
child: Image(
width: 100,
height: 100,
image: CachedImageProvider(e.url),
),
);
if (e.link != null) {
image = InkWell(
onTap: () {
_Tag.handleLink(e.link!);
},
child: image,
);
}
return image;
}).toList(),
)
],
);
}
return content;
}
}

View File

@@ -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(

View File

@@ -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()

View File

@@ -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;
}
}
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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(),
);
}
}

View File

@@ -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 = "";

View File

@@ -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;

View File

@@ -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,

View File

@@ -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();

View File

@@ -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}"))!

View File

@@ -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(),

View File

@@ -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);
}
}

View File

@@ -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(),
],
);
}

View File

@@ -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(),
],
);
}

View File

@@ -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();
},
),
],
),
),

View File

@@ -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() {

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -10,7 +10,7 @@ void handleLinks() {
});
}
void handleAppLink(Uri uri) async {
Future<bool> handleAppLink(Uri uri) async {
for(var source in ComicSource.all()) {
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;
}

View File

@@ -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}';

View File

@@ -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()) {

View File

@@ -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
View 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;
}
}

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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
View File

@@ -15,3 +15,4 @@ x86/
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
/ChineseSimplified.isl

View File

@@ -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

View File

@@ -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: