Merge pull request #53 from venera-app/dev

v1.0.6
This commit is contained in:
nyne
2024-11-18 18:20:39 +08:00
committed by GitHub
45 changed files with 1723 additions and 426 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 name: Build ALL
run-name: Build IOS run-name: Build ALL
on: on:
workflow_dispatch: {} workflow_dispatch: {}
jobs: jobs:
@@ -63,3 +63,79 @@ jobs:
with: with:
name: app-ios.ipa name: app-ios.ipa
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa
Build_Android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- name: Decode and install certificate
env:
STORE_FILE: ${{ secrets.ANDROID_KEYSTORE }}
PROPERTY_FILE: ${{ secrets.ANDROID_KEY_PROPERTIES }}
run: |
echo "$STORE_FILE" | base64 --decode > android/keystore.jks
echo "$PROPERTY_FILE" > android/key.properties
- uses: actions/setup-java@v4
with:
distribution: 'oracle'
java-version: '17'
- run: flutter pub get
- run: flutter build apk --release
- uses: actions/upload-artifact@v4
with:
name: apks
path: build/app/outputs/apk/release
Build_Windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: install dependencies
run: |
choco install yq -y
pip install httpx
- uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version-file: pubspec.yaml
architecture: x64
- name: build
run: |
flutter pub get
python windows/build.py
- uses: actions/upload-artifact@v4
with:
name: windows_build
path: build/windows/Venera-*
Build_Linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version-file: pubspec.yaml
architecture: x64
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py
- run: dart run flutter_to_arch
- run: |
sudo rm -rf build/linux/arch/app.tar.gz
sudo rm -rf build/linux/arch/pkg
sudo rm -rf build/linux/arch/src
sudo rm -rf build/linux/arch/PKGBUILD
- uses: actions/upload-artifact@v4
with:
name: deb_build
path: build/linux/x64/release/debian
- uses: actions/upload-artifact@v4
with:
name: arch_build
path: build/linux/arch/

View File

@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_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.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<application <application
android:label="venera" android:label="venera"
android:name="${applicationName}" android:name="${applicationName}"

View File

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

View File

@@ -43,10 +43,11 @@
"Confirm": "确认", "Confirm": "确认",
"Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?", "Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?",
"Are you sure you want to delete @a selected comics?": "您确定要删除 @a 部漫画吗?", "Are you sure you want to delete @a selected comics?": "您确定要删除 @a 部漫画吗?",
"Add comic source": "添加漫画源", "Add comic source": "添加漫画源",
"Select file": "选择文件", "Select file": "选择文件",
"View list": "查看列表", "View list": "查看列表",
"Open help": "打开帮助", "Open help": "打开帮助",
"Open in Browser": "打开网页",
"Check updates": "检查更新", "Check updates": "检查更新",
"Edit": "编辑", "Edit": "编辑",
"Update": "更新", "Update": "更新",
@@ -101,8 +102,8 @@
"Auto page turning interval": "自动翻页间隔", "Auto page turning interval": "自动翻页间隔",
"Theme Mode": "主题模式", "Theme Mode": "主题模式",
"System": "系统", "System": "系统",
"Light": "明亮", "Light": "浅色",
"Dark": "黑暗", "Dark": "深色",
"Theme Color": "主题颜色", "Theme Color": "主题颜色",
"Red": "红色", "Red": "红色",
"Pink": "粉色", "Pink": "粉色",
@@ -209,7 +210,26 @@
"Update Comics Info": "更新漫画信息", "Update Comics Info": "更新漫画信息",
"Create Folder": "新建文件夹", "Create Folder": "新建文件夹",
"Select an image on screen": "选择屏幕上的图片", "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": "没有分类页面"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -257,10 +277,11 @@
"Confirm": "確認", "Confirm": "確認",
"Are you sure you want to delete this comic?": "您確定要刪除這部漫畫嗎?", "Are you sure you want to delete this comic?": "您確定要刪除這部漫畫嗎?",
"Are you sure you want to delete @a selected comics?": "您確定要刪除 @a 部漫畫嗎?", "Are you sure you want to delete @a selected comics?": "您確定要刪除 @a 部漫畫嗎?",
"Add comic source": "添加漫畫源", "Add comic source": "添加漫畫源",
"Select file": "選擇文件", "Select file": "選擇文件",
"View list": "查看列表", "View list": "查看列表",
"Open help": "打開幫助", "Open help": "打開幫助",
"Open in Browser": "打開網頁",
"Check updates": "檢查更新", "Check updates": "檢查更新",
"Edit": "編輯", "Edit": "編輯",
"Update": "更新", "Update": "更新",
@@ -313,8 +334,8 @@
"Auto page turning interval": "自動翻頁間隔", "Auto page turning interval": "自動翻頁間隔",
"Theme Mode": "主題模式", "Theme Mode": "主題模式",
"System": "系統", "System": "系統",
"Light": "明亮", "Light": "浅色",
"Dark": "黑暗", "Dark": "深色",
"Theme Color": "主題顏色", "Theme Color": "主題顏色",
"Red": "紅色", "Red": "紅色",
"Pink": "粉色", "Pink": "粉色",
@@ -421,6 +442,25 @@
"Update Comics Info": "更新漫畫信息", "Update Comics Info": "更新漫畫信息",
"Create Folder": "新建文件夾", "Create Folder": "新建文件夾",
"Select an image on screen": "選擇屏幕上的圖片", "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": "沒有分類頁面"
} }
} }

View File

@@ -51,5 +51,7 @@
<true/> <true/>
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
<true/> <true/>
<key>NSFaceIDUsageDescription</key>
<string>Ensure that the operation is being performed by the user themselves.</string>
</dict> </dict>
</plist> </plist>

View File

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

View File

@@ -227,16 +227,26 @@ class ComicTile extends StatelessWidget {
Widget _buildBriefMode(BuildContext context) { Widget _buildBriefMode(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Material( child: LayoutBuilder(
color: Colors.transparent, builder: (context, constraints) {
return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
elevation: 1, onTap: _onTap,
onLongPress:
enableLongPressed ? () => onLongPress(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Column(
children: [
Expanded(
child: SizedBox(
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
@@ -245,56 +255,97 @@ class ComicTile extends StatelessWidget {
), ),
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0,
right: 0, right: 0,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.3),
Colors.black.withOpacity(0.5),
]),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 4),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
bottomRight: Radius.circular(10.0),
bottomLeft: Radius.circular(10.0),
),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Padding(
padding:
const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.88,
),
child: Text( child: Text(
comic.title.replaceAll("\n", ""), comic.description.isEmpty
? comic.subtitle
?.replaceAll('\n', '') ??
''
: comic.description
.split('|')
.join('\n'),
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
fontSize: 14.0, fontSize: 12,
color: Colors.white, color: Colors.white,
), ),
textAlign: TextAlign.right,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
),
),
),
)), )),
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _onTap,
onLongPress:
enableLongPressed ? () => onLongPress(context) : null,
onSecondaryTapDown: (detail) =>
onSecondaryTap(detail, context),
borderRadius: BorderRadius.circular(8),
child: const SizedBox.expand(),
),
),
)
], ],
), ),
), ),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
child: Text(
comic.title.replaceAll('\n', ''),
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14.0,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
); );
},
));
}
List<String> _splitText(String text) {
// split text by space, comma. text in brackets will be kept together.
var words = <String>[];
var buffer = StringBuffer();
var inBracket = false;
for (var i = 0; i < text.length; i++) {
var c = text[i];
if (c == '[' || c == '(') {
inBracket = true;
} else if (c == ']' || c == ')') {
inBracket = false;
} else if (c == ' ' || c == ',') {
if (inBracket) {
buffer.write(c);
} else {
words.add(buffer.toString());
buffer.clear();
}
} else {
buffer.write(c);
}
}
if (buffer.isNotEmpty) {
words.add(buffer.toString());
}
return words;
} }
void block(BuildContext comicTileContext) { void block(BuildContext comicTileContext) {
@@ -303,7 +354,7 @@ class ComicTile extends StatelessWidget {
builder: (context) { builder: (context) {
var words = <String>[]; var words = <String>[];
var all = <String>[]; var all = <String>[];
all.addAll(comic.title.split(' ').where((element) => element != '')); all.addAll(_splitText(comic.title));
if (comic.subtitle != null && comic.subtitle != "") { if (comic.subtitle != null && comic.subtitle != "") {
all.add(comic.subtitle!); all.add(comic.subtitle!);
} }
@@ -691,6 +742,9 @@ class _SliverGridComics extends StatelessWidget {
menuOptions: menuBuilder?.call(comics[index]), menuOptions: menuBuilder?.call(comics[index]),
onTap: onTap != null ? () => onTap!(comics[index]) : null, onTap: onTap != null ? () => onTap!(comics[index]) : null,
); );
if(selection == null) {
return comic;
}
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -136,6 +136,8 @@ class ComicSource {
notifyListeners(); notifyListeners();
} }
static bool get isEmpty => _sources.isEmpty;
/// Name of this source. /// Name of this source.
final String name; final String name;

View File

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

View File

@@ -346,6 +346,32 @@ class LocalFavoritesManager with ChangeNotifier {
return name; return name;
} }
void linkFolderToNetwork(String folder, String source, String networkFolder) {
_db.execute("""
insert or replace into folder_sync (folder_name, source_key, source_folder)
values (?, ?, ?);
""", [folder, source, networkFolder]);
}
bool isLinkedToNetworkFolder(String folder, String source, String networkFolder) {
var res = _db.select("""
select * from folder_sync
where folder_name == ? and source_key == ? and source_folder == ?;
""", [folder, source, networkFolder]);
return res.isNotEmpty;
}
(String?, String?) findLinked(String folder) {
var res = _db.select("""
select * from folder_sync
where folder_name == ?;
""", [folder]);
if (res.isEmpty) {
return (null, null);
}
return (res.first["source_key"], res.first["source_folder"]);
}
bool comicExists(String folder, String id, ComicType type) { bool comicExists(String folder, String id, ComicType type) {
var res = _db.select(""" var res = _db.select("""
select * from "$folder" select * from "$folder"
@@ -365,20 +391,19 @@ class LocalFavoritesManager with ChangeNotifier {
return FavoriteItem.fromRow(res.first); return FavoriteItem.fromRow(res.first);
} }
/// add comic to a folder /// add comic to a folder.
/// /// return true if success, false if already exists
/// This method will download cover to local, to avoid problems like changing url bool addComic(String folder, FavoriteItem comic, [int? order]) {
void addComic(String folder, FavoriteItem comic, [int? order]) async {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
if (!existsFolder(folder)) { if (!existsFolder(folder)) {
throw Exception("Folder does not exists"); throw Exception("Folder does not exists");
} }
var res = _db.select(""" var res = _db.select("""
select * from "$folder" select * from "$folder"
where id == '${comic.id}'; where id == ? and type == ?;
"""); """, [comic.id, comic.type.value]);
if (res.isNotEmpty) { if (res.isNotEmpty) {
return; return false;
} }
final params = [ final params = [
comic.id, comic.id,
@@ -406,6 +431,7 @@ class LocalFavoritesManager with ChangeNotifier {
""", [...params, minValue(folder) - 1]); """, [...params, minValue(folder) - 1]);
} }
notifyListeners(); notifyListeners();
return true;
} }
/// delete a folder /// delete a folder
@@ -414,6 +440,10 @@ class LocalFavoritesManager with ChangeNotifier {
_db.execute(""" _db.execute("""
drop table "$name"; drop table "$name";
"""); """);
_db.execute("""
delete from folder_order
where folder_name == ?;
""", [name]);
notifyListeners(); notifyListeners();
} }
@@ -461,6 +491,16 @@ class LocalFavoritesManager with ChangeNotifier {
ALTER TABLE "$before" ALTER TABLE "$before"
RENAME TO "$after"; RENAME TO "$after";
"""); """);
_db.execute("""
update folder_order
set folder_name = ?
where folder_name == ?;
""", [after, before]);
_db.execute("""
update folder_sync
set folder_name = ?
where folder_name == ?;
""", [after, before]);
notifyListeners(); notifyListeners();
} }

View File

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

View File

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

View File

@@ -106,34 +106,24 @@ class MyLogInterceptor implements Interceptor {
class AppDio with DioMixin { class AppDio with DioMixin {
String? _proxy = proxy; String? _proxy = proxy;
static bool get ignoreCertificateErrors => appdata.settings['ignoreCertificateErrors'] == true;
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
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(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager()); interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor()); interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor()); interceptors.add(MyLogInterceptor());
} }
static HttpClient createHttpClient() {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5);
client.findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
client.idleTimeout = const Duration(seconds: 100);
client.badCertificateCallback =
(X509Certificate cert, String host, int port) {
if (host.contains("cdn")) return true;
final ipv4RegExp = RegExp(
r'^((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})$');
if (ipv4RegExp.hasMatch(host)) {
return true;
}
return false;
};
return client;
}
static String? proxy; static String? proxy;
static Future<String?> getProxy() async { static Future<String?> getProxy() async {
@@ -189,8 +179,8 @@ class AppDio with DioMixin {
ProgressCallback? onSendProgress, ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress, ProgressCallback? onReceiveProgress,
}) async { }) async {
if(options?.headers?['prevent-parallel'] == 'true') { if (options?.headers?['prevent-parallel'] == 'true') {
while(_requests.containsKey(path)) { while (_requests.containsKey(path)) {
await Future.delayed(const Duration(milliseconds: 20)); await Future.delayed(const Duration(milliseconds: 20));
} }
_requests[path] = true; _requests[path] = true;
@@ -204,6 +194,9 @@ class AppDio with DioMixin {
proxySettings: proxy == null proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy() ? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!), : rhttp.ProxySettings.proxy(proxy!),
tlsSettings: rhttp.TlsSettings(
verifyCertificates: !ignoreCertificateErrors,
),
)); ));
} }
try { try {
@@ -216,9 +209,8 @@ class AppDio with DioMixin {
onSendProgress: onSendProgress, onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress, onReceiveProgress: onReceiveProgress,
); );
} } finally {
finally { if (_requests.containsKey(path)) {
if(_requests.containsKey(path)) {
_requests.remove(path); _requests.remove(path);
} }
} }
@@ -237,6 +229,9 @@ class RHttpAdapter implements HttpClientAdapter {
keepAlivePing: Duration(seconds: 30), keepAlivePing: Duration(seconds: 30),
), ),
throwOnStatusCode: false, throwOnStatusCode: false,
tlsSettings: rhttp.TlsSettings(
verifyCertificates: !AppDio.ignoreCertificateErrors,
),
); );
} }
@@ -284,7 +279,7 @@ class RHttpAdapter implements HttpClientAdapter {
headers[key]!.add(entry.$2); headers[key]!.add(entry.$2);
} }
var data = res.body; var data = res.body;
if(headers['content-encoding']?.contains('gzip') ?? false) { if (headers['content-encoding']?.contains('gzip') ?? false) {
// rhttp does not support gzip decoding // rhttp does not support gzip decoding
data = gzip.decoder.bind(data).map((data) => Uint8List.fromList(data)); data = gzip.decoder.bind(data).map((data) => Uint8List.fromList(data));
} }

View File

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

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

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

View File

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

View File

@@ -327,7 +327,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
} }
Widget buildDescription() { Widget buildDescription() {
if (comic.description == null) { if (comic.description == null || comic.description!.trim().isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
return SliverToBoxAdapter( return SliverToBoxAdapter(
@@ -392,6 +392,27 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
child: InkWell( child: InkWell(
borderRadius: borderRadius, borderRadius: borderRadius,
onTap: onTap, onTap: onTap,
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
onSecondaryTapDown: (details) {
showMenuX(context, details.globalPosition, [
MenuEntry(
icon: Icons.remove_red_eye,
text: "View".tl,
onClick: onTap,
),
MenuEntry(
icon: Icons.copy,
text: "Copy".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
),
]);
},
child: Text(text).padding(padding), child: Text(text).padding(padding),
), ),
); );
@@ -406,6 +427,26 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
} }
} }
String formatTime(String time) {
if (int.tryParse(time) != null) {
var t = int.tryParse(time);
if (t! > 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(t)
.toString()
.substring(0, 19);
} else {
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
.toString()
.substring(0, 19);
}
}
if (time.contains('T') || time.contains('Z')) {
var t = DateTime.parse(time);
return t.toString().substring(0, 19);
}
return time;
}
Widget buildWrap({required List<Widget> children}) { Widget buildWrap({required List<Widget> children}) {
return Wrap( return Wrap(
runSpacing: 8, runSpacing: 8,
@@ -464,14 +505,14 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildWrap( buildWrap(
children: [ children: [
buildTag(text: 'Upload Time'.tl, isTitle: true), buildTag(text: 'Upload Time'.tl, isTitle: true),
buildTag(text: comic.uploadTime!), buildTag(text: formatTime(comic.uploadTime!)),
], ],
), ),
if (comic.updateTime != null) if (comic.updateTime != null)
buildWrap( buildWrap(
children: [ children: [
buildTag(text: 'Update Time'.tl, isTitle: true), buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: comic.updateTime!), buildTag(text: formatTime(comic.updateTime!)),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -575,7 +616,7 @@ abstract mixin class _ComicPageActions {
void quickFavorite() { void quickFavorite() {
var folder = appdata.settings['quickFavorite']; var folder = appdata.settings['quickFavorite'];
if(folder is! String) { if (folder is! String) {
return; return;
} }
LocalFavoritesManager().addComic( LocalFavoritesManager().addComic(
@@ -1037,6 +1078,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
if (!isInitialLoading && next == null) { if (!isInitialLoading && next == null) {
return; return;
} }
if (isLoading) return;
Future.microtask(() { Future.microtask(() {
setState(() { setState(() {
isLoading = true; isLoading = true;
@@ -1609,7 +1651,9 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: FilledButton( child: FilledButton(
onPressed: selected.isEmpty ? null : () { onPressed: selected.isEmpty
? null
: () {
widget.finishSelect(selected); widget.finishSelect(selected);
context.pop(); context.pop();
}, },

View File

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

View File

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

View File

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

View File

@@ -288,3 +288,178 @@ Future<void> sortFolders() async {
LocalFavoritesManager().updateOrder(folders); LocalFavoritesManager().updateOrder(folders);
} }
Future<void> importNetworkFolder(
String source,
String? folder,
String? folderID,
) async {
var comicSource = ComicSource.find(source);
if (comicSource == null) {
return;
}
if(folder != null && folder.isEmpty) {
folder = null;
}
var resultName = folder ?? comicSource.name;
var exists = LocalFavoritesManager().existsFolder(resultName);
if (exists) {
if (!LocalFavoritesManager()
.isLinkedToNetworkFolder(resultName, source, folderID ?? "")) {
App.rootContext.showMessage(message: "Folder already exists".tl);
return;
}
}
if(!exists) {
LocalFavoritesManager().createFolder(resultName);
LocalFavoritesManager().linkFolderToNetwork(
resultName,
source,
folderID ?? "",
);
}
var current = 0;
var isFinished = false;
String? next;
Future<void> fetchNext() async {
var retry = 3;
while (true) {
try {
if (comicSource.favoriteData?.loadComic != null) {
next ??= '1';
var page = int.parse(next!);
var res = await comicSource.favoriteData!.loadComic!(page, folderID);
var count = 0;
for (var c in res.data) {
var result = LocalFavoritesManager().addComic(
resultName,
FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
),
);
if (result) {
count++;
}
}
current += count;
if (res.data.isEmpty || res.subData == page) {
isFinished = true;
next = null;
} else {
next = (page + 1).toString();
}
} else if (comicSource.favoriteData?.loadNext != null) {
var res = await comicSource.favoriteData!.loadNext!(next, folderID);
var count = 0;
for (var c in res.data) {
var result = LocalFavoritesManager().addComic(
resultName,
FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
),
);
if (result) {
count++;
}
}
current += count;
if (res.data.isEmpty || res.subData == null) {
isFinished = true;
next = null;
} else {
next = res.subData;
}
} else {
throw "Unsupported source";
}
return;
} catch (e) {
retry--;
if (retry == 0) {
rethrow;
}
continue;
}
}
}
bool isCanceled = false;
String? errorMsg;
bool isErrored() => errorMsg != null;
void Function()? updateDialog;
showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
updateDialog = () => setState(() {});
return ContentDialog(
title: isFinished
? "Finished".tl
: isErrored()
? "Error".tl
: "Importing".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
LinearProgressIndicator(
value: isFinished ? 1 : null,
),
const SizedBox(height: 4),
Text("Imported @c comics".tlParams({
"c": current,
})),
const SizedBox(height: 4),
if (isErrored()) Text("Error: $errorMsg"),
],
).paddingHorizontal(16),
actions: [
Button.filled(
color: (isFinished || isErrored())
? null
: context.colorScheme.error,
onPressed: () {
isCanceled = true;
context.pop();
},
child: (isFinished || isErrored())
? Text("OK".tl)
: Text("Cancel".tl),
),
],
);
},
);
},
).then((_) {
isCanceled = true;
});
while (!isFinished && !isCanceled) {
try {
await fetchNext();
updateDialog?.call();
} catch (e) {
errorMsg = e.toString();
updateDialog?.call();
break;
}
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';

View File

@@ -14,6 +14,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
late List<FavoriteItem> comics; late List<FavoriteItem> comics;
String? networkSource;
String? networkFolder;
void updateComics() { void updateComics() {
setState(() { setState(() {
comics = LocalFavoritesManager().getAllComics(widget.folder); comics = LocalFavoritesManager().getAllComics(widget.folder);
@@ -24,6 +27,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
void initState() { void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
comics = LocalFavoritesManager().getAllComics(widget.folder); comics = LocalFavoritesManager().getAllComics(widget.folder);
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a;
networkFolder = b;
super.initState(); super.initState();
} }
@@ -49,6 +55,51 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
child: Text(favPage.folder ?? "Unselected".tl), child: Text(favPage.folder ?? "Unselected".tl),
), ),
actions: [ actions: [
if (networkSource != null)
Tooltip(
message: "Sync".tl,
child: Flyout(
flyoutBuilder: (context) {
var sourceName = ComicSource.find(networkSource!)?.name ??
networkSource!;
var text = "The folder is Linked to @source".tlParams({
"source": sourceName,
});
if(networkFolder != null && networkFolder!.isNotEmpty) {
text += "\n${"Source Folder".tl}: $networkFolder";
}
return FlyoutContent(
title: "Sync".tl,
content: Text(text),
actions: [
Button.filled(
child: Text("Update".tl),
onPressed: () {
context.pop();
importNetworkFolder(
networkSource!,
widget.folder,
networkFolder!,
).then(
(value) {
updateComics();
},
);
},
),
],
);
},
child: Builder(builder: (context) {
return IconButton(
icon: const Icon(Icons.sync),
onPressed: () {
Flyout.of(context).show();
},
);
}),
),
),
MenuButton( MenuButton(
entries: [ entries: [
MenuEntry( MenuEntry(
@@ -136,7 +187,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}); });
}), }),
MenuEntry( MenuEntry(
icon: Icons.update, icon: Icons.download,
text: "Download All".tl, text: "Download All".tl,
onClick: () async { onClick: () async {
int count = 0; int count = 0;

View File

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

View File

@@ -89,11 +89,13 @@ class _SyncDataWidget extends StatefulWidget {
State<_SyncDataWidget> createState() => _SyncDataWidgetState(); State<_SyncDataWidget> createState() => _SyncDataWidgetState();
} }
class _SyncDataWidgetState extends State<_SyncDataWidget> { class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
DataSync().addListener(update); DataSync().addListener(update);
WidgetsBinding.instance.addObserver(this);
lastCheck = DateTime.now();
} }
void update() { void update() {
@@ -106,6 +108,20 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> {
void dispose() { void dispose() {
super.dispose(); super.dispose();
DataSync().removeListener(update); 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 @override

View File

@@ -12,6 +12,7 @@ import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart'; import 'package:photo_view/photo_view_gallery.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/components/custom_slider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
@@ -244,8 +245,10 @@ abstract mixin class _ReaderLocation {
bool toPage(int page) { bool toPage(int page) {
if (_validatePage(page)) { if (_validatePage(page)) {
if (page == this.page) { if (page == this.page) {
if(!(chapter == 1 && page == 1) && !(chapter == maxChapter && page == maxPage)) {
return false; return false;
} }
}
this.page = page; this.page = page;
update(); update();
if (enablePageAnimation) { if (enablePageAnimation) {

View File

@@ -18,6 +18,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
bool get isOpen => _isOpen; bool get isOpen => _isOpen;
bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft;
int showFloatingButtonValue = 0; int showFloatingButtonValue = 0;
var lastValue = 0; var lastValue = 0;
@@ -217,34 +220,26 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
children: [ children: [
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton.filledTonal( IconButton.filledTonal(
onPressed: () { onPressed: () => !isReversed
if (!context.reader.toPrevChapter()) { ? context.reader.chapter > 1
context.reader.toPage(1); ? context.reader.toPrevChapter()
} else { : context.reader.toPage(1)
if (showFloatingButtonValue != 0) { : context.reader.chapter < context.reader.maxChapter
setState(() { ? context.reader.toNextChapter()
showFloatingButtonValue = 0; : context.reader.toPage(context.reader.maxPage),
});
}
}
},
icon: const Icon(Icons.first_page), icon: const Icon(Icons.first_page),
), ),
Expanded( Expanded(
child: buildSlider(), child: buildSlider(),
), ),
IconButton.filledTonal( IconButton.filledTonal(
onPressed: () { onPressed: () => !isReversed
if (!context.reader.toNextChapter()) { ? context.reader.chapter < context.reader.maxChapter
context.reader.toPage(context.reader.maxPage); ? context.reader.toNextChapter()
} else { : context.reader.toPage(context.reader.maxPage)
if (showFloatingButtonValue != 0) { : context.reader.chapter > 1
setState(() { ? context.reader.toPrevChapter()
showFloatingButtonValue = 0; : context.reader.toPage(1),
});
}
}
},
icon: const Icon(Icons.last_page)), icon: const Icon(Icons.last_page)),
const SizedBox( const SizedBox(
width: 8, width: 8,
@@ -379,12 +374,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
var sliderFocus = FocusNode(); var sliderFocus = FocusNode();
Widget buildSlider() { Widget buildSlider() {
return Slider( return CustomSlider(
focusNode: sliderFocus, focusNode: sliderFocus,
value: context.reader.page.toDouble(), value: context.reader.page.toDouble(),
min: 1, min: 1,
max: max:
context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(), context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
reversed: isReversed,
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16), divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
onChanged: (i) { onChanged: (i) {
context.reader.toPage(i.toInt()); context.reader.toPage(i.toInt());

View File

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

View File

@@ -36,12 +36,12 @@ class _AppSettingsState extends State<AppSettings> {
if (App.isAndroid) { if (App.isAndroid) {
var channel = const MethodChannel("venera/storage"); var channel = const MethodChannel("venera/storage");
var permission = await channel.invokeMethod(''); var permission = await channel.invokeMethod('');
if(permission != true) { if (permission != true) {
context.showMessage(message: "Permission denied".tl); context.showMessage(message: "Permission denied".tl);
return; return;
} }
var path = await selectDirectory(); var path = await selectDirectory();
if(path != null) { if (path != null) {
// check if the path is writable // check if the path is writable
var testFile = File(FilePath.join(path, "test")); var testFile = File(FilePath.join(path, "test"));
try { try {
@@ -177,6 +177,29 @@ class _AppSettingsState extends State<AppSettings> {
App.forceRebuild(); App.forceRebuild();
}, },
).toSliver(), ).toSliver(),
if (!App.isLinux)
_SwitchSetting(
title: "Authorization Required".tl,
settingKey: "authorizationRequired",
onChanged: () async {
var current = appdata.settings['authorizationRequired'];
if (current) {
final auth = LocalAuthentication();
final bool canAuthenticateWithBiometrics =
await auth.canCheckBiometrics;
final bool canAuthenticate = canAuthenticateWithBiometrics ||
await auth.isDeviceSupported();
if (!canAuthenticate) {
context.showMessage(message: "Biometrics not supported".tl);
setState(() {
appdata.settings['authorizationRequired'] = false;
});
appdata.saveData();
return;
}
}
},
).toSliver(),
], ],
); );
} }

View File

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

View File

@@ -33,9 +33,10 @@ class _SwitchSettingState extends State<_SwitchSetting> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
appdata.settings[widget.settingKey] = value; appdata.settings[widget.settingKey] = value;
appdata.saveData();
}); });
appdata.saveData().then((_) {
widget.onChanged?.call(); widget.onChanged?.call();
});
}, },
), ),
); );
@@ -133,7 +134,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
builder: (context) { builder: (context) {
return ContentDialog( return ContentDialog(
title: "Help".tl, title: "Help".tl,
content: Text(widget.help!).paddingHorizontal(16).fixWidth(double.infinity), content: Text(widget.help!)
.paddingHorizontal(16)
.fixWidth(double.infinity),
actions: [ actions: [
Button.filled( Button.filled(
onPressed: context.pop, onPressed: context.pop,
@@ -158,8 +161,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
var rect = offset & size; var rect = offset & size;
showMenu( showMenu(
elevation: 3, elevation: 3,
color: context.colorScheme.surface, color: context.brightness == Brightness.light
surfaceTintColor: Colors.transparent, ? const Color(0xFFF6F6F6)
: const Color(0xFF1E1E1E),
context: context, context: context,
position: RelativeRect.fromRect( position: RelativeRect.fromRect(
rect, rect,
@@ -229,7 +233,9 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
builder: (context) { builder: (context) {
return ContentDialog( return ContentDialog(
title: "Help".tl, title: "Help".tl,
content: Text(widget.help!).paddingHorizontal(16).fixWidth(double.infinity), content: Text(widget.help!)
.paddingHorizontal(16)
.fixWidth(double.infinity),
actions: [ actions: [
Button.filled( Button.filled(
onPressed: context.pop, onPressed: context.pop,
@@ -460,11 +466,15 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return SimpleDialog( return ContentDialog(
title: const Text("Add"), title: "Add".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: canAdd.entries children: canAdd.entries
.map((e) => InkWell( .map(
child: ListTile(title: Text(e.value), key: Key(e.key)), (e) => ListTile(
title: Text(e.value),
key: Key(e.key),
onTap: () { onTap: () {
context.pop(); context.pop();
setState(() { setState(() {
@@ -472,10 +482,13 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
}); });
updateSetting(); updateSetting();
}, },
)) ),
)
.toList(), .toList(),
),
);
},
); );
});
} }
void updateSetting() { void updateSetting() {

View File

@@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:local_auth/local_auth.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';

View File

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

View File

@@ -190,7 +190,6 @@ class IOSDirectoryPicker {
final String? path = await _channel.invokeMethod('selectDirectory'); final String? path = await _channel.invokeMethod('selectDirectory');
return path; return path;
} catch (e) { } catch (e) {
print("Error selecting directory: $e");
// 返回报错信息 // 返回报错信息
return e.toString(); return e.toString();
} }

View File

@@ -356,6 +356,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398"
url: "https://pub.dev"
source: hosted
version: "2.0.23"
flutter_qjs: flutter_qjs:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -512,6 +520,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: "6763aaf8965f21822624cb2fd3c03d2a8b3791037b5efb0fe4b13e110f5afc92"
url: "https://pub.dev"
source: hosted
version: "1.0.46"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "6d2950da311d26d492a89aeb247c72b4653ddc93601ea36a84924a396806d49c"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a"
url: "https://pub.dev"
source: hosted
version: "1.0.10"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
lodepng_flutter: lodepng_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -988,4 +1036,4 @@ packages:
version: "0.0.1" version: "0.0.1"
sdks: sdks:
dart: ">=3.5.4 <4.0.0" dart: ">=3.5.4 <4.0.0"
flutter: ">=3.24.4" flutter: ">=3.24.5"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.0.5+105 version: 1.0.6+106
environment: environment:
sdk: '>=3.5.0 <4.0.0' sdk: '>=3.5.0 <4.0.0'
flutter: 3.24.4 flutter: 3.24.5
dependencies: dependencies:
flutter: flutter:
@@ -64,6 +64,7 @@ dependencies:
url: https://github.com/wgh136/webdav_client url: https://github.com/wgh136/webdav_client
ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1 ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1
battery_plus: ^6.2.0 battery_plus: ^6.2.0
local_auth: ^2.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -33,7 +33,7 @@ WizardStyle=modern
[Languages] [Languages]
Name: "english"; MessagesFile: "compiler:Default.isl" Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" Name: "chinesesimplified"; MessagesFile: "{#RootPath}\windows\ChineseSimplified.isl"
[Tasks] [Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
@@ -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\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\screen_retriever_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\local_auth_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\zip_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\zip_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\x64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RootPath}\build\windows\x64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion

View File

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