136 Commits

Author SHA1 Message Date
ccb03343f4 Fix the issue where the toolbar can not be open when chapter data loading failed. Close #415 2025-07-13 20:22:56 +08:00
角砂糖
b9817ec030 Fix page calculation logic && trigger recalculation on orientation change (#428) 2025-06-26 19:55:21 +08:00
角砂糖
5ebb554e54 Add an option to filter logs by level (#427) 2025-06-26 19:55:07 +08:00
23ee79fe9d Set high refresh rate on Android. 2025-06-23 19:39:47 +08:00
nyne
85baac657a Merge pull request #421 from lings03/local
Allow user to keep favorite and history when delete local comic. Close #420
2025-06-23 19:06:34 +08:00
nyne
cceca6b96f Merge branch 'master' into local 2025-06-23 19:04:20 +08:00
角砂糖
b5b0dc85e3 Show group in last read and history when group existing. (#419) 2025-06-23 19:03:24 +08:00
nyne
50044c4372 Merge pull request #418 from lings03/reverse
Add a option to reverse the default chapter order. Close #414
2025-06-23 19:02:52 +08:00
nyne
5fd7f1b880 Merge branch 'master' into reverse 2025-06-23 19:00:48 +08:00
角砂糖
058fde3f5a Add a button to show system status bar (#417) 2025-06-23 19:00:04 +08:00
角砂糖
a2d46123dd Add missing translation
debug时发现之前少了这一句翻译,也不至于提个pr,顺便加在这里吧
2025-06-22 20:07:15 +08:00
角砂糖
01acc4f9de Allow user to keep favorite and history when delete local comic. Close #420 2025-06-22 19:50:38 +08:00
角砂糖
856aae0769 Add a option to reverse the default chapter order. Close #414 2025-06-22 00:29:49 +08:00
nyne
8eda8adcc8 Merge pull request #410 from venera-app/v1.4.5-dev
V1.4.5
2025-06-18 16:52:59 +08:00
defd4b8624 Update version code. 2025-06-18 16:39:02 +08:00
b2a164e066 Remove the config file repository url from app. 2025-06-18 16:34:49 +08:00
a46ceebf19 Fixed the issue where the update dialog was not showed on startup. 2025-06-18 16:07:36 +08:00
cc08445f13 Set initial chapter to first downloaded chapter if there is no history when starting to read a local comic. Close #405 2025-06-17 17:36:13 +08:00
93f7f72d07 Fixed some issues when using custom download path on Android. Close #400 2025-06-17 17:15:35 +08:00
20f7ab4866 Clear folder value if it does not exist in local favorites. Close #389 2025-06-15 15:02:45 +08:00
54363919cd Fixed RangeError when translating tags. Close #356 2025-06-15 14:58:15 +08:00
182a821fc5 Fixed the issue where the download task would stop after exiting the reader. Close #387 2025-06-15 14:58:15 +08:00
8868c6edb3 Update Flutter SDK version to 3.32.4 2025-06-15 14:58:15 +08:00
角砂糖
fffbb4ed23 Only add closeListener when app is desktop (#397) 2025-06-04 12:11:45 +08:00
角砂糖
b057be0311 Fix abnormal history recording when not flipping pages. Close #392 (#395) 2025-06-03 17:36:20 +08:00
角砂糖
fc5fed1707 Fix history of page when show single image on first page (#393) 2025-06-03 17:35:45 +08:00
角砂糖
8525f5318f Fix page calculate when showSingleImageOnFirstPage is enabled (#391) 2025-06-03 17:35:17 +08:00
角砂糖
d58cafc4a0 Fix abnormal single image height when imagesPerPage > 1. Close #379 2025-05-31 10:50:17 +08:00
23afafd1d6 Update rhttp 2025-05-26 19:05:15 +08:00
nyne
3b6e0adbbb Merge pull request #377 from venera-app/v1.4.4
V1.4.4
2025-05-26 18:18:43 +08:00
20a57c7a36 Update version code 2025-05-26 18:10:07 +08:00
665f50ed2a Fixed an issue where comic counts would become invalid after renaming a favorite folder. Close #357 2025-05-26 16:42:05 +08:00
55733ef505 Update selectAll method to handle search mode for selecting comics. Close #359 2025-05-26 16:09:23 +08:00
0c46214619 Reduce maximum length for comic directory names to improve consistency. Close #362 2025-05-26 15:35:24 +08:00
749a1a47fb Fix dialog content overflow. Close #363 2025-05-25 20:33:31 +08:00
76e9ef87d4 Add functionality to delete specific comic chapters. Close #368 2025-05-25 20:26:35 +08:00
dcd6466547 Improve performance of deleting favorites, coping favorites, moving favorites and deleting downloads. Close #365 2025-05-24 16:24:53 +08:00
ed70fdba93 Improve reordering local comics. Close #374 2025-05-22 20:51:47 +08:00
ded0068ea6 Improve performance for clearing history. 2025-05-22 20:37:25 +08:00
nyne
7dc6be622a fix clearing history. 2025-05-22 20:01:07 +08:00
nyne
88f093f7e5 Add clear unfavorited history functionality. Close #372 2025-05-22 19:59:42 +08:00
8f357b3e6c Merge branch 'master' into v1.4.4 2025-05-20 15:51:28 +08:00
9ee82975e8 Handle invalid appdata file. 2025-05-20 15:40:30 +08:00
nyne
9f048685e4 fix decryptAesCbc 2025-05-05 18:29:46 +08:00
nyne
bc1f5e11b5 Update version code 2025-05-05 18:26:01 +08:00
1f2147ef72 Add support for gbk. Close #354 2025-05-05 12:51:36 +08:00
fba365fd93 Fix crash caused by cache manager. Close #351 2025-05-04 23:03:37 +08:00
a5e3fbaee5 Improve image loading 2025-05-04 22:24:39 +08:00
190e645a12 Update translation 2025-04-29 11:35:54 +08:00
nyne
8a83ff5367 Merge pull request #349 from venera-app/v1.4.2-dev
V1.4.2
2025-04-29 11:32:40 +08:00
6e14942dab Add application category type to Info.plist 2025-04-29 11:29:30 +08:00
146fc70143 Update version code 2025-04-29 11:19:59 +08:00
b37ea01aca Add an option to disable double tap to zoom. 2025-04-29 11:18:59 +08:00
bf7b90313a Fix invalid total page count. Close #348 2025-04-28 20:18:29 +08:00
929c1a9d91 Show comics count of a folder on sidebar. 2025-04-28 19:46:29 +08:00
9ff68d0701 Improve local favorites performance. 2025-04-28 19:40:12 +08:00
dfd15ed34a Fix an issue where folders were not fully displayed on the favorites page. 2025-04-26 10:23:18 +08:00
nyne
dfe2a0db6a Merge pull request #345 from venera-app/v1.4.1-dev
V1.4.1
2025-04-25 09:22:51 +08:00
c6714f79b6 Revert "Add windows arm64"
This reverts commit 6877aa120f.
2025-04-25 09:18:45 +08:00
552a42fb27 Fix the issue where app crashes after exit app. 2025-04-24 20:11:09 +08:00
af456c52f1 Improve the UI of comic source list. 2025-04-24 17:20:16 +08:00
f38129133a Terminate the application when the UI thread is dead. Close #343 2025-04-24 16:44:51 +08:00
17e2696ca4 flutter 3.29.3 2025-04-23 17:50:04 +08:00
9d6999af33 Improve UI 2025-04-23 16:58:38 +08:00
ae5548918c Fix saving, sharing, and collecting images when there are multiple images on the screen. Close #289 2025-04-23 16:51:51 +08:00
92d22c977c Add a Save Image option to the Reader context menu. 2025-04-23 15:51:58 +08:00
8cc3702e1a Add an option to display single image on the first reader page. Close #244 2025-04-23 15:38:10 +08:00
3131ce52a7 Fix file name sanitising to remove trailing dots. Close #322 2025-04-22 20:29:18 +08:00
62e4056f4a Add an 'All' folder to the local favorites page. Close #335 2025-04-22 20:19:22 +08:00
a29a7cbaf3 Adjust the scroll distance when turning pages using the arrow keys. Close #329 2025-04-21 20:12:08 +08:00
7bdab7ade7 Add ComicInfo.xml to cbz file. Close #333 2025-04-21 20:04:06 +08:00
ea99e87afb Fixed issue where http client settings were not synchronised with appdata. Close #337 2025-04-21 19:44:23 +08:00
0d3fde9457 Adjust key repeat timer duration based on page animation setting. 2025-04-21 19:16:43 +08:00
aa9f4dae82 Reset state of photo view controllers on page change. Close #331 2025-04-19 10:54:25 +08:00
6877aa120f Add windows arm64 2025-04-15 17:08:28 +08:00
d25d72a5f7 Improve image cache. Close #326 2025-04-10 17:14:05 +08:00
nyne
97768b4945 Merge pull request #317 from venera-app/v1.4.0-dev
V1.4.0
2025-04-05 22:06:21 +08:00
2481780ab3 fix issues reported by analyzer. 2025-04-05 22:03:54 +08:00
nyne
49481bfa6a Fix windows arm64 build script 2025-04-05 21:32:31 +08:00
211850d73e Improve comic source importing UI 2025-04-05 21:22:00 +08:00
fcf0334d55 Fix the issue that the downloaded chapters was not saved when download a comic without select chapters. Close #305 2025-04-05 20:58:06 +08:00
aa8eec5792 Improve UI. 2025-04-05 20:48:04 +08:00
6eb0060dd6 Add debug page. 2025-04-05 20:29:30 +08:00
c096f5a2d8 Add dynamic category part. 2025-04-05 20:11:05 +08:00
554b9f2a77 Fix search sources in search results page. 2025-04-05 19:31:41 +08:00
f87afbe397 Fix issues with empty chapter list. 2025-04-05 18:00:55 +08:00
6ff30f8ac3 Improve chapter display. 2025-04-05 17:48:49 +08:00
118941f239 Fix the mouse scrolling issue when multiple scroll lists are nested. 2025-04-05 17:45:29 +08:00
d91bca6913 [Comic Source] Improve data conversion 2025-04-05 17:18:53 +08:00
463ad5b5bc [Comic Source] New model PageJumpTarget. All page jump operations now use PageJumpTarget. 2025-04-04 22:47:43 +08:00
971fc1da92 Update version code. 2025-04-03 13:04:25 +08:00
37af7e266a Allow changing chapter by volume key. Close #250 2025-04-03 13:03:39 +08:00
276e23354d Smooth scroll for comments page. 2025-04-03 11:53:43 +08:00
3da00595b7 Add a setting for long press position. Close #287 2025-04-02 16:23:51 +08:00
nyne
d3c115ee0c fix log 2025-04-02 09:41:10 +08:00
dcc94c5b3d Fix crash on Android. 2025-04-01 21:07:29 +08:00
a116b5b615 Update AGP to 8.9.0 2025-04-01 20:36:24 +08:00
05fcb23a4d Limit download directory length. Close #311 2025-04-01 15:49:22 +08:00
daa6e8ce18 Show comic pages in details page. 2025-04-01 15:13:09 +08:00
8665994572 Write logs to file. 2025-04-01 14:57:11 +08:00
90441af989 Fix the issue where local comics page can not been opened when there is a comic with empty chapter list. Close #309 2025-03-31 16:10:14 +08:00
7631fab86b Prevent window from closing while uploading data 2025-03-31 15:46:41 +08:00
cd9b07bb3e Fix restoring window placement on linux 2025-03-31 12:26:32 +08:00
6c179ceb95 Add UA to WebDav requests. Close #308 2025-03-30 18:27:52 +08:00
ec48dbef57 Update linux icon 2025-03-30 18:23:43 +08:00
cd1cc1229e Remove native linux window decoration. 2025-03-30 15:42:43 +08:00
nyne
bda299e1f8 Merge pull request #304 from venera-app/v1.3.4-dev
V1.3.4
2025-03-28 19:23:50 +08:00
nyne
78ea129564 fix analyze error 2025-03-28 19:22:02 +08:00
nyne
f3b4598bb6 Fix the issue of not being able to read local comics. 2025-03-28 18:54:32 +08:00
nyne
7bc4c69a32 Add windows arm64 build script 2025-03-28 18:29:56 +08:00
nyne
a8e55e0151 Improve the long press to zoom feature. 2025-03-28 18:03:44 +08:00
nyne
fddd959545 fix windows arm64 build. 2025-03-28 18:02:36 +08:00
nyne
ebf6846bf1 fix windows arm64 build. 2025-03-28 16:18:23 +08:00
0f2d0bb9f9 Update version code. 2025-03-28 10:59:48 +08:00
48338e4ef7 Fix implicit data writing. Close #280 2025-03-28 10:58:47 +08:00
8d8e345d82 Fix invalid space when using Galley mode with multiple images on screen. Close #277 2025-03-27 23:00:06 +08:00
nyne
fcbf6a6277 Update issue checker 2025-03-27 21:28:07 +08:00
d83d679eb9 Implement writeImageToClipboard on macOS. 2025-03-27 19:40:51 +08:00
d6087e5f59 Implement writeImageToClipboard on Linux. 2025-03-27 14:52:05 +08:00
37371bee6c Merge remote-tracking branch 'origin/linux-window' into v1.3.4-dev
# Conflicts:
#	assets/translation.json
2025-03-27 13:13:18 +08:00
45fe5f503a Improve blur effect. 2025-03-27 13:11:20 +08:00
d440ed6424 Improve the long press to zoom feature.
Close #287
2025-03-27 13:04:19 +08:00
d812332613 Add image copy functionality.
Currently only supports Windows.
Close #260
2025-03-26 22:50:00 +08:00
dee8d17b1e Increase the range of comic tile size. Close #275 2025-03-26 19:55:56 +08:00
nyne
c0d461ebd9 Update prompt. 2025-03-26 18:41:27 +08:00
nyne
45e2a1142a Update model 2025-03-26 18:25:36 +08:00
nyne
533c2b2507 Update issue_check.yml 2025-03-26 18:15:52 +08:00
nyne
29b7e0d646 Add a workflow to check issues. 2025-03-26 17:47:59 +08:00
b1870b65d6 Translations for page selector. Close #286 2025-03-25 16:49:44 +08:00
1103076009 Improved page switching via keyboard. Close #293 2025-03-25 16:36:08 +08:00
51739355c8 Add clipboard methods to js engine. 2025-03-25 16:24:05 +08:00
1b4f67b314 The line starts with 'class' is considered as first line. 2025-03-25 16:18:43 +08:00
ba8831caa6 Add option to show page number in reader settings 2025-03-24 18:54:48 +08:00
2b1684b0fc Added a 'Back to Top' button. Close #276 2025-03-23 17:11:23 +08:00
cd3f09efae Make sure the app quits when the window is closed. 2025-03-23 16:48:07 +08:00
03628f2afa Improve gesture for continuous mode. 2025-03-22 11:11:20 +08:00
79 changed files with 4009 additions and 1645 deletions

29
.github/workflows/issue_check.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Check Issue
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
check:
name: Check Issue
runs-on: ubuntu-latest
steps:
- name: Checkout
id: checkout
uses: actions/checkout@v4
- name: Check Issue
id: check
uses: wgh136/gpt_issue_checker@v1.0.2
with:
api-url: ${{ secrets.API_URL }}
api-key: ${{ secrets.API_KEY }}
github-token: ${{ secrets.GITHUB_TOKEN }}
prompt: "You are a repository issue checker. The project is a comic app that supports view local or network comics using config files. To view a comic source, user must add a config file. User should not report any issue related to config file to the project repository because there is another repository for managing config files. You are given an issue content and you need to decide whether to close the issue. If you decide to close the issue, you should also provide a comment explaining why you are closing the issue. If you decide not to close the issue, you should provide a comment which is a summary of the issue. You should response with a JSON object with the following keys: should_close, should_comment, comment."
model: "gpt-4o"

View File

@@ -67,7 +67,6 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.github.wgh136.venera" applicationId = "com.github.wgh136.venera"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration.
@@ -125,6 +124,6 @@ flutter {
} }
dependencies { dependencies {
implementation "androidx.activity:activity-ktx:1.9.2" implementation "androidx.activity:activity-ktx:1.10.1"
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'
} }

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.3.2' apply false id "com.android.application" version '8.9.0' apply false
id "org.jetbrains.kotlin.android" version "1.8.10" apply false id "org.jetbrains.kotlin.android" version "1.8.10" apply false
} }

View File

@@ -39,6 +39,32 @@ let Convert = {
}); });
}, },
/**
* @param str {string}
* @returns {ArrayBuffer}
*/
encodeGbk: (str) => {
return sendMessage({
method: "convert",
type: "gbk",
value: str,
isEncode: true
});
},
/**
* @param value {ArrayBuffer}
* @returns {string}
*/
decodeGbk: (value) => {
return sendMessage({
method: "convert",
type: "gbk",
value: value,
isEncode: false
});
},
/** /**
* @param {ArrayBuffer} value * @param {ArrayBuffer} value
* @returns {string} * @returns {string}
@@ -176,7 +202,7 @@ let Convert = {
decryptAesCbc: (value, key, iv) => { decryptAesCbc: (value, key, iv) => {
return sendMessage({ return sendMessage({
method: "convert", method: "convert",
type: "aes-ecb", type: "aes-cbc",
value: value, value: value,
key: key, key: key,
iv: iv, iv: iv,
@@ -1357,4 +1383,30 @@ let APP = {
method: 'getPlatform' method: 'getPlatform'
}) })
} }
}
/**
* Set clipboard text
* @param text {string}
* @returns {Promise<void>}
*
* @since 1.3.4
*/
function setClipboard(text) {
return sendMessage({
method: 'setClipboard',
text: text
})
}
/**
* Get clipboard text
* @returns {Promise<string>}
*
* @since 1.3.4
*/
function getClipboard() {
return sendMessage({
method: 'getClipboard'
})
} }

View File

@@ -140,18 +140,18 @@
"Block": "屏蔽", "Block": "屏蔽",
"Add new favorite to": "添加新收藏到", "Add new favorite to": "添加新收藏到",
"Move favorite after reading": "阅读后移动收藏", "Move favorite after reading": "阅读后移动收藏",
"Delete folder?" : "删除文件夹?", "Delete folder?": "删除文件夹?",
"Delete folder '@f' ?" : "删除文件夹 '@f' ?", "Delete folder '@f' ?": "删除文件夹 '@f' ?",
"Import from file": "从文件导入", "Import from file": "从文件导入",
"Failed to import": "导入失败", "Failed to import": "导入失败",
"Cache Limit": "缓存限制", "Cache Limit": "缓存限制",
"Set Cache Limit": "设置缓存限制", "Set Cache Limit": "设置缓存限制",
"Size in MB": "大小MB", "Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "选择一个包含漫画文件夹的目录", "Select a directory which contains the comic directories.": "选择一个包含漫画文件夹的目录",
"Help": "帮助", "Help": "帮助",
"Export as cbz": "导出为cbz", "Export as cbz": "导出为cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "选择一个归档文件 (cbz, zip, 7z, cb7)", "Select an archive file (cbz, zip, 7z, cb7)": "选择一个归档文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一个归档文件", "An archive file": "一个归档文件",
"Fullscreen": "全屏", "Fullscreen": "全屏",
"Exit": "退出", "Exit": "退出",
"View more": "查看更多", "View more": "查看更多",
@@ -198,9 +198,9 @@
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹", "Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
"Added": "已添加", "Added": "已添加",
"Turn page by volume keys": "使用音量键翻页", "Turn page by volume keys": "使用音量键翻页",
"Display time & battery info in reader":"在阅读器中显示时间和电量信息", "Display time & battery info in reader": "在阅读器中显示时间和电量信息",
"EhViewer downloads":"EhViewer下载", "EhViewer downloads": "EhViewer下载",
"Select an EhViewer database and a download folder.":"选择EhViewer的下载数据导出的db文件与存放下载内容的目录", "Select an EhViewer database and a download folder.": "选择EhViewer的下载数据导出的db文件与存放下载内容的目录",
"(EhViewer)Default": "(EhViewer)默认", "(EhViewer)Default": "(EhViewer)默认",
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若通过EhViewer数据库导入漫画程序将会按其中的下载标签自动创建收藏文件夹。", "If you import an EhViewer's database, program will automatically create folders according to the download label in that database.": "若通过EhViewer数据库导入漫画程序将会按其中的下载标签自动创建收藏文件夹。",
"Multi-Select": "进入多选模式", "Multi-Select": "进入多选模式",
@@ -234,14 +234,16 @@
"Please add some sources": "请添加一些源", "Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置", "Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面", "No Category Pages": "没有分类页面",
"Group @group": "第 @group 组",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 页", "Page @page": "第 @page 页",
"Remove local favorite and history": "删除本地收藏和历史记录",
"Also remove files on disk": "同时删除磁盘上的文件", "Also remove files on disk": "同时删除磁盘上的文件",
"Copy to app local path": "将漫画复制到本地存储目录中", "Copy to app local path": "将漫画复制到本地存储目录中",
"Delete all unavailable local favorite items": "删除所有无效的本地收藏", "Delete all unavailable local favorite items": "删除所有无效的本地收藏",
"Deleted @a favorite items.": "已删除 @a 条无效收藏", "Deleted @a favorite items.": "已删除 @a 条无效收藏",
"New version available": "有新版本可用", "New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", "A new version is available. Do you want to update now?": "有新版本可用。您要现在更新吗?",
"No new version available": "没有新版本可用", "No new version available": "没有新版本可用",
"Export as pdf": "导出为pdf", "Export as pdf": "导出为pdf",
"Export as epub": "导出为epub", "Export as epub": "导出为epub",
@@ -288,15 +290,15 @@
"Copy the title successfully": "复制标题成功", "Copy the title successfully": "复制标题成功",
"The comic is invalid, please long press to delete, you can double click the title to copy": "该漫画已失效, 请长按删除, 可以双击标题进行复制", "The comic is invalid, please long press to delete, you can double click the title to copy": "该漫画已失效, 请长按删除, 可以双击标题进行复制",
"No search results found": "未找到搜索结果", "No search results found": "未找到搜索结果",
"Added @c comics to download queue." : "已添加 @c 本漫画到下载队列", "Added @c comics to download queue.": "已添加 @c 本漫画到下载队列",
"Download started": "下载已开始", "Download started": "下载已开始",
"Click favorite": "点击收藏", "Click favorite": "点击收藏",
"End": "末尾", "End": "末尾",
"None": "无", "None": "无",
"View Detail": "查看详情", "View Detail": "查看详情",
"Select a directory which contains multiple archive files." : "选择一个包含多个归档文件的目录", "Select a directory which contains multiple archive files.": "选择一个包含多个归档文件的目录",
"Multiple archive files" : "多个归档文件", "Multiple archive files": "多个归档文件",
"No valid comics found" : "未找到有效的漫画", "No valid comics found": "未找到有效的漫画",
"Enable DNS Overrides": "启用DNS覆写", "Enable DNS Overrides": "启用DNS覆写",
"DNS Overrides": "DNS覆写", "DNS Overrides": "DNS覆写",
"Custom Image Processing": "自定义图片处理", "Custom Image Processing": "自定义图片处理",
@@ -342,12 +344,12 @@
"Replies": "回复", "Replies": "回复",
"Follow Updates": "追更", "Follow Updates": "追更",
"Not Configured": "未配置", "Not Configured": "未配置",
"Choose a folder to follow updates." : "选择一个文件夹以追更", "Choose a folder to follow updates.": "选择一个文件夹以追更",
"Choose Folder": "选择文件夹", "Choose Folder": "选择文件夹",
"No folders available": "没有可用的文件夹", "No folders available": "没有可用的文件夹",
"Updating comics...": "更新漫画中...", "Updating comics...": "更新漫画中...",
"Automatic update checking enabled." : "已启用自动更新检查", "Automatic update checking enabled.": "已启用自动更新检查",
"The app will check for updates at most once a day." : "APP将每天最多检查一次更新", "The app will check for updates at most once a day.": "APP将每天最多检查一次更新",
"Change Folder": "更改文件夹", "Change Folder": "更改文件夹",
"Check Now": "立即检查", "Check Now": "立即检查",
"Updates": "更新", "Updates": "更新",
@@ -360,7 +362,7 @@
"Disabled": "已禁用", "Disabled": "已禁用",
"Auto Sync Data": "自动同步数据", "Auto Sync Data": "自动同步数据",
"Mark all as read": "全部标记为已读", "Mark all as read": "全部标记为已读",
"Do you want to mark all as read?" : "您要全部标记为已读吗?", "Do you want to mark all as read?": "您要全部标记为已读吗?",
"Swipe down for previous chapter": "向下滑动查看上一章", "Swipe down for previous chapter": "向下滑动查看上一章",
"Swipe up for next chapter": "向上滑动查看下一章", "Swipe up for next chapter": "向上滑动查看下一章",
"Initial Page": "初始页面", "Initial Page": "初始页面",
@@ -373,7 +375,36 @@
"Paging": "分页", "Paging": "分页",
"Continuous": "连续", "Continuous": "连续",
"Display mode of comic list": "漫画列表的显示模式", "Display mode of comic list": "漫画列表的显示模式",
"A valid WebDav directory URL": "有效的WebDav目录URL" "Show Page Number": "显示页码",
"Jump to page": "跳转到页面",
"Page": "页面",
"Jump": "跳转",
"Copy Image": "复制图片",
"A valid WebDav directory URL": "有效的WebDav目录URL",
"Shut Down": "关闭",
"Uploading data...": "正在上传数据...",
"Pages": "页数",
"Long press zoom position": "长按缩放位置",
"Press position": "按压位置",
"Screen center": "屏幕中心",
"Suggestions": "建议",
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
"Show single image on first page": "在首页显示单张图片",
"Show system status bar": "显示系统状态栏",
"Click to select an image": "点击选择一张图片",
"Repo URL": "仓库地址",
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
"Double tap to zoom": "双击缩放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反转",
"Delete Chapters": "删除章节",
"Path copied to clipboard": "路径已复制到剪贴板",
"Reverse default chapter order": "反转默认章节顺序",
"Reload Configs": "重新加载配置文件",
"Reload": "重载",
"Disable Length Limitation": "禁用长度限制",
"Only valid for this run": "仅对本次运行有效",
"Logs": "日志"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -515,18 +546,18 @@
"Block": "封鎖", "Block": "封鎖",
"Add new favorite to": "添加新收藏到", "Add new favorite to": "添加新收藏到",
"Move favorite after reading": "閱讀後移動收藏", "Move favorite after reading": "閱讀後移動收藏",
"Delete folder?" : "刪除資料夾?", "Delete folder?": "刪除資料夾?",
"Delete folder '@f' ?" : "刪除資料夾 '@f' ", "Delete folder '@f' ?": "刪除資料夾 '@f' ",
"Import from file": "從文件匯入", "Import from file": "從文件匯入",
"Failed to import": "匯入失敗", "Failed to import": "匯入失敗",
"Cache Limit": "快取限制", "Cache Limit": "快取限制",
"Set Cache Limit": "設定快取限制", "Set Cache Limit": "設定快取限制",
"Size in MB": "大小MB", "Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫資料夾的目錄", "Select a directory which contains the comic directories.": "選擇一個包含漫畫資料夾的目錄",
"Help": "幫助", "Help": "幫助",
"Export as cbz": "匯出為cbz", "Export as cbz": "匯出為cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)", "Select an archive file (cbz, zip, 7z, cb7)": "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
"An archive file" : "一個歸檔文件", "An archive file": "一個歸檔文件",
"Fullscreen": "全螢幕", "Fullscreen": "全螢幕",
"Exit": "退出", "Exit": "退出",
"View more": "查看更多", "View more": "查看更多",
@@ -610,20 +641,22 @@
"Please add some sources": "請添加一些源", "Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定", "Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面", "No Category Pages": "沒有分類頁面",
"Group @group": "第 @group 組",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁", "Page @page": "第 @page 頁",
"Remove local favorite and history": "刪除本機收藏和歷史記錄",
"Also remove files on disk": "同時刪除磁碟上的文件", "Also remove files on disk": "同時刪除磁碟上的文件",
"Copy to app local path": "將漫畫複製到本機儲存目錄中", "Copy to app local path": "將漫畫複製到本機儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏", "Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
"Deleted @a favorite items.": "已刪除 @a 條無效收藏", "Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用", "New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", "A new version is available. Do you want to update now?": "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用", "No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf", "Export as pdf": "匯出為pdf",
"Export as epub": "匯出為epub", "Export as epub": "匯出為epub",
"Aggregated Search": "聚合搜尋", "Aggregated Search": "聚合搜尋",
"No search results found": "未找到搜尋結果", "No search results found": "未找到搜尋結果",
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載佇列", "Added @c comics to download queue.": "已添加 @c 本漫畫到下載佇列",
"Download started": "下載已開始", "Download started": "下載已開始",
"Click favorite": "點擊收藏", "Click favorite": "點擊收藏",
"Local comic collection is not supported at present": "本機收藏暫不支援", "Local comic collection is not supported at present": "本機收藏暫不支援",
@@ -670,9 +703,9 @@
"End": "末尾", "End": "末尾",
"None": "無", "None": "無",
"View Detail": "查看詳情", "View Detail": "查看詳情",
"Select a directory which contains multiple archive files." : "選擇一個包含多個歸檔文件的目錄", "Select a directory which contains multiple archive files.": "選擇一個包含多個歸檔文件的目錄",
"Multiple archive files" : "多個歸檔文件", "Multiple archive files": "多個歸檔文件",
"No valid comics found" : "未找到有效的漫畫", "No valid comics found": "未找到有效的漫畫",
"Enable DNS Overrides": "啟用DNS覆寫", "Enable DNS Overrides": "啟用DNS覆寫",
"DNS Overrides": "DNS覆寫", "DNS Overrides": "DNS覆寫",
"Custom Image Processing": "自訂圖片處理", "Custom Image Processing": "自訂圖片處理",
@@ -718,12 +751,12 @@
"Replies": "回覆", "Replies": "回覆",
"Follow Updates": "追更", "Follow Updates": "追更",
"Not Configured": "未配置", "Not Configured": "未配置",
"Choose a folder to follow updates." : "選擇一個資料夾以追更", "Choose a folder to follow updates.": "選擇一個資料夾以追更",
"Choose Folder": "選擇資料夾", "Choose Folder": "選擇資料夾",
"No folders available": "沒有可用的資料夾", "No folders available": "沒有可用的資料夾",
"Updating comics...": "更新漫畫中...", "Updating comics...": "更新漫畫中...",
"Automatic update checking enabled." : "已啟用自動更新檢查", "Automatic update checking enabled.": "已啟用自動更新檢查",
"The app will check for updates at most once a day." : "APP將每天最多檢查一次更新", "The app will check for updates at most once a day.": "APP將每天最多檢查一次更新",
"Change Folder": "更改資料夾", "Change Folder": "更改資料夾",
"Check Now": "立即檢查", "Check Now": "立即檢查",
"Updates": "更新", "Updates": "更新",
@@ -736,7 +769,7 @@
"Disabled": "已停用", "Disabled": "已停用",
"Auto Sync Data": "自動同步資料", "Auto Sync Data": "自動同步資料",
"Mark all as read": "全部標記為已讀", "Mark all as read": "全部標記為已讀",
"Do you want to mark all as read?" : "您要全部標記為已讀嗎?", "Do you want to mark all as read?": "您要全部標記為已讀嗎?",
"Swipe down for previous chapter": "向下滑動查看上一章", "Swipe down for previous chapter": "向下滑動查看上一章",
"Swipe up for next chapter": "向上滑動查看下一章", "Swipe up for next chapter": "向上滑動查看下一章",
"Initial Page": "初始頁面", "Initial Page": "初始頁面",
@@ -749,6 +782,35 @@
"Paging": "分頁", "Paging": "分頁",
"Continuous": "連續", "Continuous": "連續",
"Display mode of comic list": "漫畫列表的顯示模式", "Display mode of comic list": "漫畫列表的顯示模式",
"A valid WebDav directory URL": "有效的WebDav目錄URL" "Show Page Number": "顯示頁碼",
"Jump to page": "跳轉到頁面",
"Page": "頁面",
"Jump": "跳轉",
"Copy Image": "複製圖片",
"A valid WebDav directory URL": "有效的WebDav目錄URL",
"Shut Down": "關閉",
"Uploading data...": "正在上傳數據...",
"Pages": "頁數",
"Long press zoom position": "長按縮放位置",
"Press position": "按壓位置",
"Screen center": "螢幕中心",
"Suggestions": "建議",
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
"Show single image on first page": "在首頁顯示單張圖片",
"Show system status bar": "顯示系統狀態欄",
"Click to select an image": "點擊選擇一張圖片",
"Repo URL": "倉庫地址",
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
"Double tap to zoom": "雙擊縮放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反轉",
"Delete Chapters": "刪除章節",
"Path copied to clipboard": "路徑已複製到剪貼簿",
"Reverse default chapter order": "反轉預設章節順序",
"Reload Configs": "重新載入設定檔",
"Reload": "重載",
"Disable Length Limitation": "禁用長度限制",
"Only valid for this run": "僅對本次運行有效",
"Logs": "日誌"
} }
} }

BIN
debian/gui/venera.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -9,13 +9,45 @@ Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine wh
This document will describe how to write a comic source for Venera. This document will describe how to write a comic source for Venera.
## Preparation ## Comic Source List
Venera can display a list of comic sources in the app.
You should provide a repository url to let the app load the comic source list.
The url should point to a JSON file that contains the list of comic sources.
The JSON file should have the following format:
```json
[
{
"name": "Source Name",
"url": "https://example.com/source.js",
"filename": "Relative path to the source file",
"version": "1.0.0",
"description": "A brief description of the source"
}
]
```
Only one of `url` and `filename` should be provided.
The description field is optional.
Currently, you can use the following repo url:
```
https://cdn.jsdelivr.net/gh/venera-app/venera-configs@main/index.json
```
The repo is maintained by the Venera team, and you can submit a pull request to add your comic source.
## Create a Comic Source
### Preparation
- Install Venera. Using flutter to run the project is recommended since it's easier to debug. - Install Venera. Using flutter to run the project is recommended since it's easier to debug.
- An editor that supports javascript. - An editor that supports javascript.
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs). - Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
## Start Writing ### Start Writing
The template contains detailed comments and examples. You can refer to it when writing your own comic source. The template contains detailed comments and examples. You can refer to it when writing your own comic source.
@@ -23,7 +55,7 @@ Here is a brief introduction to the template:
> Note: Javascript api document is [here](js_api.md). > Note: Javascript api document is [here](js_api.md).
### Write basic information #### Write basic information
```javascript ```javascript
class NewComicSource extends ComicSource { class NewComicSource extends ComicSource {
@@ -49,7 +81,7 @@ In this part, you need to do the following:
- Change the class name to your source name. - Change the class name to your source name.
- Fill in the name, key, version, minAppVersion, and url fields. - Fill in the name, key, version, minAppVersion, and url fields.
### init function #### init function
```javascript ```javascript
/** /**
@@ -64,7 +96,7 @@ The function will be called when the source is initialized. You can do some init
Remove this function if not used. Remove this function if not used.
### Account #### Account
```javascript ```javascript
// [Optional] account related // [Optional] account related
@@ -140,7 +172,7 @@ In this part, you can implement login, logout, and register functions.
Remove this part if not used. Remove this part if not used.
### Explore page #### Explore page
```javascript ```javascript
// explore page list // explore page list
@@ -185,7 +217,7 @@ There are three types of explore pages:
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page. - multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button. - mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
### Category Page #### Category Page
```javascript ```javascript
// categories // categories
@@ -227,7 +259,7 @@ Category page is a static page that contains multiple parts, each part contains
A comic source can only have one category page. A comic source can only have one category page.
### Category Comics Page #### Category Comics Page
```javascript ```javascript
/// category comic loading related /// category comic loading related
@@ -280,7 +312,7 @@ When user clicks on a category, the category comics page will be displayed.
This part is used to load comics of a category. This part is used to load comics of a category.
### Search #### Search
```javascript ```javascript
/// search related /// search related
@@ -339,7 +371,7 @@ This part is used to load search results.
`load` and `loadNext` functions are used to load search results. `load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored. If `load` function is implemented, `loadNext` function will be ignored.
### Favorites #### Favorites
```javascript ```javascript
// favorite related // favorite related
@@ -411,7 +443,7 @@ This part is used to manage network favorites of the source.
`load` and `loadNext` functions are used to load search results. `load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored. If `load` function is implemented, `loadNext` function will be ignored.
### Comic Details #### Comic Details
```javascript ```javascript
/// single comic related /// single comic related
@@ -576,7 +608,7 @@ If `load` function is implemented, `loadNext` function will be ignored.
This part is used to load comic details. This part is used to load comic details.
### Settings #### Settings
```javascript ```javascript
/* /*
@@ -635,7 +667,7 @@ This part is used to load comic details.
This part is used to provide settings for the source. This part is used to provide settings for the source.
### Translations #### Translations
```javascript ```javascript
// [Optional] translations for the strings in this config // [Optional] translations for the strings in this config

View File

@@ -46,12 +46,14 @@
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Choose images</string> <string>Choose images</string>
<key>UIFileSharingEnabled</key> <key>UIFileSharingEnabled</key>
<true/> <true/>
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
<true/> <true/>
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
<string>Ensure that the operation is being performed by the user themselves.</string> <string>Ensure that the operation is being performed by the user themselves.</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.books</string>
</dict> </dict>
</plist> </plist>

View File

@@ -80,7 +80,7 @@ class _AppbarState extends State<Appbar> {
var content = Container( var content = Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.backgroundColor ?? color: widget.backgroundColor ??
context.colorScheme.surface.toOpacity(0.72), context.colorScheme.surface.toOpacity(0.86),
), ),
height: _kAppBarHeight + context.padding.top, height: _kAppBarHeight + context.padding.top,
child: Row( child: Row(
@@ -231,7 +231,7 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
child: BlurEffect( child: BlurEffect(
blur: 15, blur: 15,
child: Material( child: Material(
color: context.colorScheme.surface.toOpacity(0.72), color: context.colorScheme.surface.toOpacity(0.86),
elevation: 0, elevation: 0,
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
child: body, child: body,

View File

@@ -334,7 +334,12 @@ class ComicTile extends StatelessWidget {
} }
var children = <Widget>[]; var children = <Widget>[];
for (var line in text.split('\n')) { var lines = text.split('\n');
lines.removeWhere((e) => e.trim().isEmpty);
if (lines.length > 3) {
lines = lines.sublist(0, 3);
}
for (var line in lines) {
children.add(Container( children.add(Container(
margin: const EdgeInsets.fromLTRB(2, 0, 2, 2), margin: const EdgeInsets.fromLTRB(2, 0, 2, 2),
padding: constraints.maxWidth < 80 padding: constraints.maxWidth < 80

View File

@@ -163,3 +163,29 @@ class SliverLazyToBoxAdapter extends StatelessWidget {
]); ]);
} }
} }
class SliverAnimatedVisibility extends StatelessWidget {
const SliverAnimatedVisibility({
super.key,
required this.visible,
required this.child,
});
final bool visible;
final Widget child;
@override
Widget build(BuildContext context) {
var child = visible ? this.child : const SizedBox.shrink();
return SliverToBoxAdapter(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: child,
),
);
}
}

View File

@@ -61,7 +61,7 @@ class _MenuRoute<T> extends PopupRoute<T> {
child: BlurEffect( child: BlurEffect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: Material( child: Material(
color: context.colorScheme.surface.toOpacity(0.78), color: context.colorScheme.surface.toOpacity(0.92),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: Container( child: Container(
width: width, width: width,

View File

@@ -290,28 +290,30 @@ class ContentDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var content = Column( var content = SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
title != null children: [
? Appbar( title != null
leading: IconButton( ? Appbar(
icon: const Icon(Icons.close), leading: IconButton(
onPressed: dismissible ? context.pop : null, icon: const Icon(Icons.close),
), onPressed: dismissible ? context.pop : null,
title: Text(title!), ),
backgroundColor: Colors.transparent, title: Text(title!),
) backgroundColor: Colors.transparent,
: const SizedBox.shrink(), )
this.content, : const SizedBox.shrink(),
const SizedBox(height: 16), this.content,
Row( const SizedBox(height: 16),
mainAxisAlignment: MainAxisAlignment.end, Row(
children: actions, mainAxisAlignment: MainAxisAlignment.end,
).paddingRight(12), children: actions,
const SizedBox(height: 16), ).paddingRight(12),
], const SizedBox(height: 16),
],
),
); );
return Dialog( return Dialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(

View File

@@ -51,10 +51,32 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
static bool _isMouseScroll = App.isDesktop; static bool _isMouseScroll = App.isDesktop;
late int id;
static int _id = 0;
var activeChildren = <int>{};
ScrollState? parent;
@override @override
void initState() { void initState() {
_controller = widget.controller ?? ScrollController(); _controller = widget.controller ?? ScrollController();
super.initState(); super.initState();
id = _id;
_id++;
}
@override
void didChangeDependencies() {
parent = ScrollState.maybeOf(context);
super.didChangeDependencies();
}
@override
void dispose() {
parent?.onChildInactive(id);
super.dispose();
} }
@override @override
@@ -66,8 +88,7 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
const BouncingScrollPhysics(), const BouncingScrollPhysics(),
); );
} }
return Listener( var child = Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) { onPointerDown: (event) {
_futurePosition = null; _futurePosition = null;
if (_isMouseScroll) { if (_isMouseScroll) {
@@ -77,6 +98,9 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
} }
}, },
onPointerSignal: (pointerSignal) { onPointerSignal: (pointerSignal) {
if (activeChildren.isNotEmpty) {
return;
}
if (pointerSignal is PointerScrollEvent) { if (pointerSignal is PointerScrollEvent) {
if (HardwareKeyboard.instance.isShiftPressed) { if (HardwareKeyboard.instance.isShiftPressed) {
return; return;
@@ -113,8 +137,14 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
}); });
} }
}, },
child: ScrollControllerProvider._( child: ScrollState._(
controller: _controller, controller: _controller,
onChildActive: (id) {
activeChildren.add(id);
},
onChildInactive: (id) {
activeChildren.remove(id);
},
child: widget.builder( child: widget.builder(
context, context,
_controller, _controller,
@@ -124,25 +154,49 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
), ),
), ),
); );
if (parent != null) {
return MouseRegion(
onEnter: (_) {
parent!.onChildActive(id);
},
onExit: (_) {
parent!.onChildInactive(id);
},
child: child,
);
}
return child;
} }
} }
class ScrollControllerProvider extends InheritedWidget { class ScrollState extends InheritedWidget {
const ScrollControllerProvider._({ const ScrollState._({
required this.controller, required this.controller,
required super.child, required super.child,
required this.onChildActive,
required this.onChildInactive,
}); });
final ScrollController controller; final ScrollController controller;
static ScrollController of(BuildContext context) { final void Function(int id) onChildActive;
final ScrollControllerProvider? provider =
context.dependOnInheritedWidgetOfExactType<ScrollControllerProvider>(); final void Function(int id) onChildInactive;
return provider!.controller;
static ScrollState of(BuildContext context) {
final ScrollState? provider =
context.dependOnInheritedWidgetOfExactType<ScrollState>();
return provider!;
}
static ScrollState? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ScrollState>();
} }
@override @override
bool updateShouldNotify(ScrollControllerProvider oldWidget) { bool updateShouldNotify(ScrollState oldWidget) {
return oldWidget.controller != controller; return oldWidget.controller != controller;
} }
} }

View File

@@ -82,7 +82,7 @@ class _WindowFrameState extends State<WindowFrame> {
return; return;
} }
} }
windowManager.close(); exit(0);
} }
@override @override
@@ -561,20 +561,19 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
Widget _buildVirtualWindowFrame(BuildContext context) { Widget _buildVirtualWindowFrame(BuildContext context) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_isMaximized ? 0 : 8), borderRadius: BorderRadius.circular(_isMaximized ? 0 : 8),
color: Colors.transparent, color: Colors.transparent,
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
BoxShadow( BoxShadow(
color: Colors.black.toOpacity(_isFocused ? 0.4 : 0.2), color: Colors.black.toOpacity(_isFocused ? 0.4 : 0.2),
offset: Offset(0.0, 2), blurRadius: 4,
blurRadius: 4, )
) ],
], ),
), clipBehavior: Clip.antiAlias,
clipBehavior: Clip.antiAlias, child: widget.child,
child: widget.child, );
);
} }
@override @override

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.3.3"; final version = "1.4.5";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;
@@ -47,6 +47,7 @@ class _App {
late String dataPath; late String dataPath;
late String cachePath; late String cachePath;
String? externalStoragePath;
final rootNavigatorKey = GlobalKey<NavigatorState>(); final rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -77,6 +78,9 @@ class _App {
Future<void> init() async { Future<void> init() async {
cachePath = (await getApplicationCacheDirectory()).path; cachePath = (await getApplicationCacheDirectory()).path;
dataPath = (await getApplicationSupportDirectory()).path; dataPath = (await getApplicationSupportDirectory()).path;
if (isAndroid) {
externalStoragePath = (await getExternalStorageDirectory())!.path;
}
} }
Future<void> initComponents() async { Future<void> initComponents() async {

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/init.dart'; import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -17,17 +18,18 @@ class Appdata with Init {
bool _isSavingData = false; bool _isSavingData = false;
Future<void> saveData([bool sync = true]) async { Future<void> saveData([bool sync = true]) async {
if (_isSavingData) { while (_isSavingData) {
await Future.doWhile(() async { await Future.delayed(const Duration(milliseconds: 20));
await Future.delayed(const Duration(milliseconds: 20));
return _isSavingData;
});
} }
_isSavingData = true; _isSavingData = true;
var data = jsonEncode(toJson()); try {
var file = File(FilePath.join(App.dataPath, 'appdata.json')); var data = jsonEncode(toJson());
await file.writeAsString(data); var file = File(FilePath.join(App.dataPath, 'appdata.json'));
_isSavingData = false; await file.writeAsString(data);
}
finally {
_isSavingData = false;
}
if (sync) { if (sync) {
DataSync().uploadData(); DataSync().uploadData();
} }
@@ -85,9 +87,18 @@ class Appdata with Init {
var implicitData = <String, dynamic>{}; var implicitData = <String, dynamic>{};
void writeImplicitData() { void writeImplicitData() async {
var file = File(FilePath.join(App.dataPath, 'implicitData.json')); while (_isSavingData) {
file.writeAsString(jsonEncode(implicitData)); await Future.delayed(const Duration(milliseconds: 20));
}
_isSavingData = true;
try {
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
await file.writeAsString(jsonEncode(implicitData));
}
finally {
_isSavingData = false;
}
} }
@override @override
@@ -100,16 +111,31 @@ class Appdata with Init {
if (!await file.exists()) { if (!await file.exists()) {
return; return;
} }
var json = jsonDecode(await file.readAsString()); try {
for (var key in (json['settings'] as Map<String, dynamic>).keys) { var json = jsonDecode(await file.readAsString());
if (json['settings'][key] != null) { for (var key in (json['settings'] as Map<String, dynamic>).keys) {
settings[key] = json['settings'][key]; if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
}
}
searchHistory = List.from(json['searchHistory']);
}
catch(e) {
Log.error("Appdata", "Failed to load appdata", e);
Log.info("Appdata", "Resetting appdata");
file.deleteIgnoreError();
}
try {
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
} }
} }
searchHistory = List.from(json['searchHistory']); catch (e) {
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json')); Log.error("Appdata", "Failed to load implicit data", e);
if (await implicitDataFile.exists()) { Log.info("Appdata", "Resetting implicit data");
implicitData = jsonDecode(await implicitDataFile.readAsString()); var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
implicitDataFile.deleteIgnoreError();
} }
} }
} }
@@ -146,6 +172,7 @@ class Settings with ChangeNotifier {
'cacheSize': 2048, // in MB 'cacheSize': 2048, // in MB
'downloadThreads': 5, 'downloadThreads': 5,
'enableLongPressToZoom': true, 'enableLongPressToZoom': true,
'longPressZoomPosition': "press", // press, center
'checkUpdateOnStart': false, 'checkUpdateOnStart': false,
'limitImageWidth': true, 'limitImageWidth': true,
'webdav': [], // empty means not configured 'webdav': [], // empty means not configured
@@ -162,12 +189,16 @@ class Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing, 'customImageProcessing': defaultCustomImageProcessing,
'sni': true, 'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': 'comicSourceListUrl': '',
"https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'preloadImageCount': 4, 'preloadImageCount': 4,
'followUpdatesFolder': null, 'followUpdatesFolder': null,
'initialPage': '0', 'initialPage': '0',
'comicListDisplayMode': 'paging', // paging, continuous 'comicListDisplayMode': 'paging', // paging, continuous
'showPageNumberInReader': true,
'showSingleImageOnFirstPage': false,
'enableDoubleTapToZoom': true,
'reverseChapterOrder': false,
'showSystemStatusBar': false,
}; };
operator [](String key) { operator [](String key) {
@@ -176,7 +207,9 @@ class Settings with ChangeNotifier {
operator []=(String key, dynamic value) { operator []=(String key, dynamic value) {
_data[key] = value; _data[key] = value;
notifyListeners(); if (key != "dataVersion") {
notifyListeners();
}
} }
@override @override

View File

@@ -1,5 +1,7 @@
import 'dart:ffi';
import 'dart:isolate';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -21,7 +23,52 @@ class CacheManager {
int _limitSize = 2 * 1024 * 1024 * 1024; int _limitSize = 2 * 1024 * 1024 * 1024;
CacheManager._create(){ static Future<int> _scanDir(Pointer<void> dbP, String dir) async {
var res = await Isolate.run(() async {
int totalSize = 0;
List<String> unmanagedFiles = [];
var db = sqlite3.fromPointer(dbP);
await for (var file in Directory(dir).list(recursive: true)) {
if (file is File) {
var size = await file.length();
var segments = file.uri.pathSegments;
var name = segments.last;
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
var res = db.select('''
SELECT * FROM cache
WHERE dir = ? AND name = ?
''', [dir, name]);
if (res.isEmpty) {
unmanagedFiles.add(file.path);
} else {
totalSize += size;
}
}
}
return {
'totalSize': totalSize,
'unmanagedFiles': unmanagedFiles,
};
});
// delete unmanaged files
// Only modify the database in the main isolate to avoid deadlock
for (var filePath in res['unmanagedFiles'] as List<String>) {
var file = File(filePath);
if (await file.exists()) {
await file.delete();
}
var segments = file.uri.pathSegments;
var name = segments.last;
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
CacheManager()._db.execute('''
DELETE FROM cache
WHERE dir = ? AND name = ?
''', [dir, name]);
}
return res['totalSize'] as int;
}
CacheManager._create() {
Directory(cachePath).createSync(recursive: true); Directory(cachePath).createSync(recursive: true);
_db = sqlite3.open('${App.dataPath}/cache.db'); _db = sqlite3.open('${App.dataPath}/cache.db');
_db.execute(''' _db.execute('''
@@ -33,100 +80,103 @@ class CacheManager {
type TEXT type TEXT
) )
'''); ''');
compute((path) => Directory(path).size, cachePath) _scanDir(_db.handle, cachePath).then((value) {
.then((value) => _currentSize = value); _currentSize = value;
checkCache();
});
} }
/// Get the singleton instance of CacheManager.
factory CacheManager() => instance ??= CacheManager._create(); factory CacheManager() => instance ??= CacheManager._create();
/// set cache size limit in MB /// set cache size limit in MB
void setLimitSize(int size){ void setLimitSize(int size) {
_limitSize = size * 1024 * 1024; _limitSize = size * 1024 * 1024;
} }
void setType(String key, String? type){ /// Write cache to disk.
_db.execute(''' Future<void> writeCache(String key, List<int> data,
UPDATE cache [int duration = 7 * 24 * 60 * 60 * 1000]) async {
SET type = ? await delete(key);
WHERE key = ?
''', [type, key]);
}
String? getType(String key){
var res = _db.select('''
SELECT type FROM cache
WHERE key = ?
''', [key]);
if(res.isEmpty){
return null;
}
return res.first[0];
}
Future<void> writeCache(String key, List<int> data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
this.dir++; this.dir++;
this.dir %= 100; this.dir %= 100;
var dir = this.dir; var dir = this.dir;
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString(); var name = md5.convert(key.codeUnits).toString();
var file = File('$cachePath/$dir/$name'); var file = File('$cachePath/$dir/$name');
while(await file.exists()){
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
file = File('$cachePath/$dir/$name');
}
await file.create(recursive: true); await file.create(recursive: true);
await file.writeAsBytes(data); await file.writeAsBytes(data);
var expires = DateTime.now().millisecondsSinceEpoch + duration; var expires = DateTime.now().millisecondsSinceEpoch + duration;
_db.execute(''' _db.execute('''
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?) INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
''', [key, dir.toString(), name, expires]); ''', [key, dir.toString(), name, expires]);
if(_currentSize != null) { if (_currentSize != null) {
_currentSize = _currentSize! + data.length; _currentSize = _currentSize! + data.length;
} }
checkCacheIfRequired(); checkCacheIfRequired();
} }
Future<CachingFile> openWrite(String key) async{ /// Find cache by key.
this.dir++; /// If cache is expired, it will be deleted and return null.
this.dir %= 100; /// If cache is not found, it will return null.
var dir = this.dir; /// If cache is found, it will return the file, and update the expires time.
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString(); Future<File?> findCache(String key) async {
var file = File('$cachePath/$dir/$name');
while(await file.exists()){
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
file = File('$cachePath/$dir/$name');
}
await file.create(recursive: true);
return CachingFile._(key, dir.toString(), name, file);
}
Future<File?> findCache(String key) async{
var res = _db.select(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
if(res.isEmpty){ if (res.isEmpty) {
return null; return null;
} }
var row = res.first; var row = res.first;
var dir = row[1] as String; var dir = row[1] as String;
var name = row[2] as String; var name = row[2] as String;
var expires = row[3] as int;
var file = File('$cachePath/$dir/$name'); var file = File('$cachePath/$dir/$name');
if(await file.exists()){ var now = DateTime.now().millisecondsSinceEpoch;
if (expires < now) {
// expired
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
if (await file.exists()) {
await file.delete();
}
return null;
}
if (await file.exists()) {
// update time
var expires = now + 7 * 24 * 60 * 60 * 1000;
_db.execute('''
UPDATE cache
SET expires = ?
WHERE key = ?
''', [expires, key]);
return file; return file;
} else {
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
} }
return null; return null;
} }
bool _isChecking = false; bool _isChecking = false;
/// Check cache size and delete expired cache.
/// Only check cache if current size is greater than limit size.
void checkCacheIfRequired() { void checkCacheIfRequired() {
if(_currentSize != null && _currentSize! > _limitSize){ if (_currentSize != null && _currentSize! > _limitSize) {
checkCache(); checkCache();
} }
} }
Future<void> checkCache() async{ /// Check cache size and delete expired cache.
if(_isChecking){ /// If current size is greater than limit size,
/// delete cache until current size is less than limit size.
Future<void> checkCache() async {
if (_isChecking) {
return; return;
} }
_isChecking = true; _isChecking = true;
@@ -134,39 +184,42 @@ class CacheManager {
SELECT * FROM cache SELECT * FROM cache
WHERE expires < ? WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]); ''', [DateTime.now().millisecondsSinceEpoch]);
for(var row in res){ for (var row in res) {
var dir = row[1] as String; var dir = row[1] as String;
var name = row[2] as String; var name = row[2] as String;
var file = File('$cachePath/$dir/$name'); var file = File('$cachePath/$dir/$name');
if(await file.exists()){ if (await file.exists()) {
var size = await file.length();
_currentSize = _currentSize! - size;
await file.delete(); await file.delete();
} }
} }
_db.execute(''' if (res.isNotEmpty) {
_db.execute('''
DELETE FROM cache DELETE FROM cache
WHERE expires < ? WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]); ''', [DateTime.now().millisecondsSinceEpoch]);
int count = 0;
var res2 = _db.select('''
SELECT COUNT(*) FROM cache
''');
if(res2.isNotEmpty){
count = res2.first[0] as int;
} }
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){ while (_currentSize != null && _currentSize! > _limitSize) {
var res = _db.select(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
ORDER BY expires ASC ORDER BY expires ASC
limit 10 limit 10
'''); ''');
for(var row in res){ if (res.isEmpty) {
// There are many files unmanaged by the cache manager.
// Clear all cache.
await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true);
break;
}
for (var row in res) {
var key = row[0] as String; var key = row[0] as String;
var dir = row[1] as String; var dir = row[1] as String;
var name = row[2] as String; var name = row[2] as String;
var file = File('$cachePath/$dir/$name'); var file = File('$cachePath/$dir/$name');
if(await file.exists()){ if (await file.exists()) {
var size = await file.length(); var size = await file.length();
await file.delete(); await file.delete();
_db.execute(''' _db.execute('''
@@ -174,7 +227,7 @@ class CacheManager {
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
_currentSize = _currentSize! - size; _currentSize = _currentSize! - size;
if(_currentSize! <= _limitSize){ if (_currentSize! <= _limitSize) {
break; break;
} }
} else { } else {
@@ -183,18 +236,18 @@ class CacheManager {
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
} }
count--;
} }
} }
_isChecking = false; _isChecking = false;
} }
Future<void> delete(String key) async{ /// Delete cache by key.
Future<void> delete(String key) async {
var res = _db.select(''' var res = _db.select('''
SELECT * FROM cache SELECT * FROM cache
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
if(res.isEmpty){ if (res.isEmpty) {
return; return;
} }
var row = res.first; var row = res.first;
@@ -202,7 +255,7 @@ class CacheManager {
var name = row[2] as String; var name = row[2] as String;
var file = File('$cachePath/$dir/$name'); var file = File('$cachePath/$dir/$name');
var fileSize = 0; var fileSize = 0;
if(await file.exists()){ if (await file.exists()) {
fileSize = await file.length(); fileSize = await file.length();
await file.delete(); await file.delete();
} }
@@ -210,11 +263,12 @@ class CacheManager {
DELETE FROM cache DELETE FROM cache
WHERE key = ? WHERE key = ?
''', [key]); ''', [key]);
if(_currentSize != null) { if (_currentSize != null) {
_currentSize = _currentSize! - fileSize; _currentSize = _currentSize! - fileSize;
} }
} }
/// Delete all cache.
Future<void> clear() async { Future<void> clear() async {
await Directory(cachePath).delete(recursive: true); await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true); Directory(cachePath).createSync(recursive: true);
@@ -223,75 +277,4 @@ class CacheManager {
'''); ''');
_currentSize = 0; _currentSize = 0;
} }
Future<void> deleteKeyword(String keyword) async{
var res = _db.select('''
SELECT * FROM cache
WHERE key LIKE ?
''', ['%$keyword%']);
for(var row in res){
var key = row[0] as String;
var dir = row[1] as String;
var name = row[2] as String;
var file = File('$cachePath/$dir/$name');
var fileSize = 0;
if(await file.exists()){
fileSize = await file.length();
try {
await file.delete();
}
finally {}
}
_db.execute('''
DELETE FROM cache
WHERE key = ?
''', [key]);
if(_currentSize != null) {
_currentSize = _currentSize! - fileSize;
}
}
}
} }
class CachingFile{
CachingFile._(this.key, this.dir, this.name, this.file);
final String key;
final String dir;
final String name;
final File file;
final List<int> _buffer = [];
Future<void> writeBytes(List<int> data) async{
_buffer.addAll(data);
if(_buffer.length > 1024 * 1024){
await file.writeAsBytes(_buffer, mode: FileMode.append);
_buffer.clear();
}
}
Future<void> close() async{
if(_buffer.isNotEmpty){
await file.writeAsBytes(_buffer, mode: FileMode.append);
}
CacheManager()._db.execute('''
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
CacheManager().checkCacheIfRequired();
}
Future<void> cancel() async{
await file.deleteIgnoreError();
}
void reset() {
_buffer.clear();
if(file.existsSync()) {
file.deleteSync();
}
}
}

View File

@@ -34,24 +34,28 @@ class CategoryButtonData {
}); });
} }
class CategoryItem {
final String label;
final PageJumpTarget target;
const CategoryItem(this.label, this.target);
}
abstract class BaseCategoryPart { abstract class BaseCategoryPart {
String get title; String get title;
List<String> get categories; List<CategoryItem> get categories;
List<String>? get categoryParams => null;
bool get enableRandom; bool get enableRandom;
String get categoryType;
/// Data class for building a part of category page. /// Data class for building a part of category page.
const BaseCategoryPart(); const BaseCategoryPart();
} }
class FixedCategoryPart extends BaseCategoryPart { class FixedCategoryPart extends BaseCategoryPart {
@override @override
final List<String> categories; final List<CategoryItem> categories;
@override @override
bool get enableRandom => false; bool get enableRandom => false;
@@ -59,19 +63,12 @@ class FixedCategoryPart extends BaseCategoryPart {
@override @override
final String title; final String title;
@override
final String categoryType;
@override
final List<String>? categoryParams;
/// A [BaseCategoryPart] that show fixed tags on category page. /// A [BaseCategoryPart] that show fixed tags on category page.
const FixedCategoryPart(this.title, this.categories, this.categoryType, const FixedCategoryPart(this.title, this.categories);
[this.categoryParams]);
} }
class RandomCategoryPart extends BaseCategoryPart { class RandomCategoryPart extends BaseCategoryPart {
final List<String> tags; final List<CategoryItem> all;
final int randomNumber; final int randomNumber;
@@ -81,67 +78,59 @@ class RandomCategoryPart extends BaseCategoryPart {
@override @override
bool get enableRandom => true; bool get enableRandom => true;
@override List<CategoryItem> _categories() {
final String categoryType; if (randomNumber >= all.length) {
return all;
List<String> _categories() {
if (randomNumber >= tags.length) {
return tags;
} }
var start = math.Random().nextInt(tags.length - randomNumber); var start = math.Random().nextInt(all.length - randomNumber);
return tags.sublist(start, start + randomNumber); return all.sublist(start, start + randomNumber);
} }
@override @override
List<String> get categories => _categories(); List<CategoryItem> get categories => _categories();
/// A [BaseCategoryPart] that show random tags on category page. /// A [BaseCategoryPart] that show a part of random tags on category page.
const RandomCategoryPart( const RandomCategoryPart(
this.title, this.tags, this.randomNumber, this.categoryType); this.title,
this.all,
this.randomNumber,
);
} }
class RandomCategoryPartWithRuntimeData extends BaseCategoryPart { class DynamicCategoryPart extends BaseCategoryPart {
final Iterable<String> Function() loadTags; final JSAutoFreeFunction loader;
final int randomNumber; final String sourceKey;
@override @override
final String title; List<CategoryItem> get categories {
var data = loader([]);
@override if (data is! List) {
bool get enableRandom => true; throw "DynamicCategoryPart loader must return a List";
@override
final String categoryType;
static final random = math.Random();
List<String> _categories() {
var tags = loadTags();
if (randomNumber >= tags.length) {
return tags.toList();
} }
final start = random.nextInt(tags.length - randomNumber); var res = <CategoryItem>[];
var res = List.filled(randomNumber, ''); for (var item in data) {
int index = -1; if (item is! Map) {
for (var s in tags) { throw "DynamicCategoryPart loader must return a List of Map";
index++;
if (start > index) {
continue;
} else if (index == start + randomNumber) {
break;
} }
res[index - start] = s; var label = item['label'];
var target = PageJumpTarget.parse(sourceKey, item['target']);
if (label is! String) {
throw "Category label must be a String";
}
res.add(CategoryItem(label, target));
} }
return res; return res;
} }
@override @override
List<String> get categories => _categories(); bool get enableRandom => false;
/// A [BaseCategoryPart] that show random tags on category page. @override
RandomCategoryPartWithRuntimeData( final String title;
this.title, this.loadTags, this.randomNumber, this.categoryType);
/// A [BaseCategoryPart] that show dynamic tags on category page.
const DynamicCategoryPart(this.title, this.loader, this.sourceKey);
} }
CategoryData getCategoryDataWithKey(String key) { CategoryData getCategoryDataWithKey(String key) {

View File

@@ -11,6 +11,8 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/pages/category_comics_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/init.dart'; import 'package:venera/utils/init.dart';
@@ -349,7 +351,7 @@ class ExplorePagePart {
/// - category:categoryName /// - category:categoryName
/// ///
/// End with `@`+`param` if the category has a parameter. /// End with `@`+`param` if the category has a parameter.
final String? viewMore; final PageJumpTarget? viewMore;
const ExplorePagePart(this.title, this.comics, this.viewMore); const ExplorePagePart(this.title, this.comics, this.viewMore);
} }

View File

@@ -116,6 +116,26 @@ class Comic {
toString() => "$sourceKey@$id"; toString() => "$sourceKey@$id";
} }
class ComicID {
final ComicType type;
final String id;
const ComicID(this.type, this.id);
@override
bool operator ==(Object other) {
if (other is! ComicID) return false;
return other.type == type && other.id == id;
}
@override
int get hashCode => type.hashCode ^ id.hashCode;
@override
String toString() => "$type@$id";
}
class ComicDetails with HistoryMixin { class ComicDetails with HistoryMixin {
@override @override
final String title; final String title;
@@ -169,7 +189,9 @@ class ComicDetails with HistoryMixin {
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) { static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
var res = <String, List<String>>{}; var res = <String, List<String>>{};
map.forEach((key, value) { map.forEach((key, value) {
res[key] = List<String>.from(value); if (value is List) {
res[key] = List<String>.from(value);
}
}); });
return res; return res;
} }
@@ -342,7 +364,8 @@ class ComicChapters {
} else if (groupedChapters.isNotEmpty) { } else if (groupedChapters.isNotEmpty) {
return ComicChapters.grouped(groupedChapters); return ComicChapters.grouped(groupedChapters);
} else { } else {
throw ArgumentError("Empty chapter list"); // return a empty list.
return ComicChapters(chapters);
} }
} }
@@ -429,3 +452,110 @@ class ComicChapters {
} }
} }
} }
class PageJumpTarget {
final String sourceKey;
final String page;
final Map<String, dynamic>? attributes;
const PageJumpTarget(this.sourceKey, this.page, this.attributes);
static PageJumpTarget parse(String sourceKey, dynamic value) {
if (value is Map) {
if (value['page'] != null) {
return PageJumpTarget(
sourceKey,
value["page"] ?? "search",
value["attributes"],
);
} else if (value["action"] != null) {
// old version `onClickTag`
var page = value["action"];
if (page == "search") {
return PageJumpTarget(
sourceKey,
"search",
{
"text": value["keyword"],
},
);
} else if (page == "category") {
return PageJumpTarget(
sourceKey,
"category",
{
"category": value["keyword"],
"param": value["param"],
},
);
} else {
return PageJumpTarget(sourceKey, page, null);
}
}
} else if (value is String) {
// old version string encoding. search: `search:keyword`, category: `category:keyword` or `category:keyword@param`
var segments = value.split(":");
var page = segments[0];
if (page == "search") {
return PageJumpTarget(
sourceKey,
"search",
{
"text": segments[1],
},
);
} else if (page == "category") {
var c = segments[1];
if (c.contains('@')) {
var parts = c.split('@');
return PageJumpTarget(
sourceKey,
"category",
{
"category": parts[0],
"param": parts[1],
},
);
} else {
return PageJumpTarget(
sourceKey,
"category",
{
"category": c,
},
);
}
} else {
return PageJumpTarget(sourceKey, page, null);
}
}
return PageJumpTarget(sourceKey, "Invalid Data", null);
}
void jump(BuildContext context) {
if (page == "search") {
context.to(
() => SearchResultPage(
text: attributes?["text"] ?? attributes?["keyword"] ?? "",
sourceKey: sourceKey,
options: List.from(attributes?["options"] ?? []),
),
);
} else if (page == "category") {
var key = ComicSource.find(sourceKey)!.categoryData!.key;
context.to(
() => CategoryComicsPage(
categoryKey: key,
category: attributes?["category"] ??
(throw ArgumentError("Category name is required")),
options: List.from(attributes?["options"] ?? []),
param: attributes?["param"],
),
);
} else {
Log.error("Page Jump", "Unknown page: $page");
}
}
}

View File

@@ -80,9 +80,8 @@ class ComicSourceParser {
Future<ComicSource> parse(String js, String filePath) async { Future<ComicSource> parse(String js, String filePath) async {
js = js.replaceAll("\r\n", "\n"); js = js.replaceAll("\r\n", "\n");
var line1 = js var line1 =
.split('\n') js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class "));
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
if (line1 == null || if (line1 == null ||
!line1.startsWith("class ") || !line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) { !line1.contains("extends ComicSource")) {
@@ -336,7 +335,7 @@ class ComicSourceParser {
(e['comics'] as List).map((e) { (e['comics'] as List).map((e) {
return Comic.fromJson(e, _key!); return Comic.fromJson(e, _key!);
}).toList(), }).toList(),
e['viewMore'], PageJumpTarget.parse(_key!, e['viewMore']),
); );
}), }),
), ),
@@ -404,21 +403,91 @@ class ComicSourceParser {
var categoryParts = <BaseCategoryPart>[]; var categoryParts = <BaseCategoryPart>[];
for (var c in doc["parts"]) { for (var c in doc["parts"]) {
final String name = c["name"]; if (c["categories"] != null && c["categories"] is! List) {
final String type = c["type"]; continue;
final List<String> tags = List.from(c["categories"]);
final String itemType = c["itemType"];
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
final String? groupParam = c["groupParam"];
if (groupParam != null) {
categoryParams = List.filled(tags.length, groupParam);
} }
if (type == "fixed") { List? categories = c["categories"];
categoryParts if (categories == null || categories[0] is Map) {
.add(FixedCategoryPart(name, tags, itemType, categoryParams)); // new format
} else if (type == "random") { final String name = c["name"];
categoryParts.add( final String type = c["type"];
RandomCategoryPart(name, tags, c["randomNumber"] ?? 1, itemType)); final cs = categories
?.map(
(e) => CategoryItem(
e['label'],
PageJumpTarget.parse(_key!, e['target']),
),
)
.toList();
if (type != "dynamic" && (cs == null || cs.isEmpty)) {
continue;
}
if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs!));
} else if (type == "random") {
categoryParts
.add(RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1));
} else if (type == "dynamic" && categories == null) {
var loader = c["loader"];
if (loader is! JSInvokable) {
throw "DynamicCategoryPart loader must be a function";
}
categoryParts.add(DynamicCategoryPart(
name,
JSAutoFreeFunction(loader),
_key!,
));
}
} else {
// old format
final String name = c["name"];
final String type = c["type"];
final List<String> tags = List.from(c["categories"]);
final String itemType = c["itemType"];
List<String>? categoryParams = ListOrNull.from(c["categoryParams"]);
final String? groupParam = c["groupParam"];
if (groupParam != null) {
categoryParams = List.filled(tags.length, groupParam);
}
var cs = <CategoryItem>[];
for (int i = 0; i < tags.length; i++) {
PageJumpTarget target;
if (itemType == 'category') {
target = PageJumpTarget(
_key!,
'category',
{
"category": tags[i],
"param": categoryParams?.elementAtOrNull(i),
},
);
} else if (itemType == 'search') {
target = PageJumpTarget(
_key!,
'search',
{
"keyword": tags[i],
},
);
} else if (itemType == 'search_with_namespace') {
target = PageJumpTarget(
_key!,
'search',
{
"keyword": "$name:$tags[i]",
},
);
} else {
target = PageJumpTarget(_key!, itemType, null);
}
cs.add(CategoryItem(tags[i], target));
}
if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs));
} else if (type == "random") {
categoryParts
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1));
}
} }
} }
@@ -620,7 +689,8 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder"); final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort"); final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic = _getValue("favorites.singleFolderForSingleComic"); final bool? singleFolderForSingleComic =
_getValue("favorites.singleFolderForSingleComic");
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async { Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) { if (!ComicSource.find(_key!)!.isLogged) {
@@ -978,9 +1048,12 @@ class ComicSourceParser {
var res = JsEngine().runCode(""" var res = JsEngine().runCode("""
ComicSource.sources.$_key.comic.onClickTag(${jsonEncode(namespace)}, ${jsonEncode(tag)}) ComicSource.sources.$_key.comic.onClickTag(${jsonEncode(namespace)}, ${jsonEncode(tag)})
"""); """);
var r = Map<String, String?>.from(res); if (res is! Map) {
return null;
}
var r = Map<String, dynamic>.from(res);
r.removeWhere((key, value) => value == null); r.removeWhere((key, value) => value == null);
return Map.from(r); return PageJumpTarget.parse(_key!, r);
}; };
} }

View File

@@ -41,7 +41,7 @@ typedef LikeCommentFunc = Future<Res<int?>> Function(
typedef VoteCommentFunc = Future<Res<int?>> Function( typedef VoteCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel); String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef HandleClickTagEvent = Map<String, String> Function( typedef HandleClickTagEvent = PageJumpTarget? Function(
String namespace, String tag); String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star. /// [rating] is the rating value, 0-10. 1 represents 0.5 star.

View File

@@ -1,4 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
@@ -209,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
late Database _db; late Database _db;
late Map<String, int> counts;
int get totalComics {
int total = 0;
for (var t in counts.values) {
total += t;
}
return total;
}
int folderComics(String folder) {
return counts[folder] ?? 0;
}
Future<void> init() async { Future<void> init() async {
counts = {};
_db = sqlite3.open("${App.dataPath}/local_favorite.db"); _db = sqlite3.open("${App.dataPath}/local_favorite.db");
_db.execute(""" _db.execute("""
create table if not exists folder_order ( create table if not exists folder_order (
@@ -234,7 +251,7 @@ class LocalFavoritesManager with ChangeNotifier {
alter table "$folder" alter table "$folder"
add column translated_tags TEXT; add column translated_tags TEXT;
"""); """);
var comics = getAllComics(folder); var comics = getFolderComics(folder);
for (var comic in comics) { for (var comic in comics) {
var translatedTags = _translateTags(comic.tags); var translatedTags = _translateTags(comic.tags);
_db.execute(""" _db.execute("""
@@ -256,6 +273,13 @@ class LocalFavoritesManager with ChangeNotifier {
} else { } else {
appdata.settings['followUpdatesFolder'] = null; appdata.settings['followUpdatesFolder'] = null;
} }
initCounts();
}
void initCounts() {
for (var folder in folderNames) {
counts[folder] = count(folder);
}
} }
List<String> find(String id, ComicType type) { List<String> find(String id, ComicType type) {
@@ -349,7 +373,7 @@ class LocalFavoritesManager with ChangeNotifier {
""").firstOrNull?["min_value"] ?? 0; """).firstOrNull?["min_value"] ?? 0;
} }
List<FavoriteItem> getAllComics(String folder) { List<FavoriteItem> getFolderComics(String folder) {
var rows = _db.select(""" var rows = _db.select("""
select * from "$folder" select * from "$folder"
ORDER BY display_order; ORDER BY display_order;
@@ -357,6 +381,54 @@ class LocalFavoritesManager with ChangeNotifier {
return rows.map((element) => FavoriteItem.fromRow(element)).toList(); return rows.map((element) => FavoriteItem.fromRow(element)).toList();
} }
static Future<List<FavoriteItem>> _getFolderComicsAsync(
String folder, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var rows = db.select("""
select * from "$folder"
ORDER BY display_order;
""");
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
});
}
/// Start a new isolate to get the comics in the folder
Future<List<FavoriteItem>> getFolderComicsAsync(String folder) {
return _getFolderComicsAsync(folder, _db.handle);
}
List<FavoriteItem> getAllComics() {
var res = <FavoriteItem>{};
for (final folder in folderNames) {
var comics = _db.select("""
select * from "$folder";
""");
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
}
return res.toList();
}
static Future<List<FavoriteItem>> _getAllComicsAsync(
List<String> folders, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var res = <FavoriteItem>{};
for (final folder in folders) {
var comics = db.select("""
select * from "$folder";
""");
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
}
return res.toList();
});
}
/// Start a new isolate to get all the comics
Future<List<FavoriteItem>> getAllComicsAsync() {
return _getAllComicsAsync(folderNames, _db.handle);
}
void addTagTo(String folder, String id, String tag) { void addTagTo(String folder, String id, String tag) {
_db.execute(""" _db.execute("""
update "$folder" update "$folder"
@@ -422,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
); );
"""); """);
notifyListeners(); notifyListeners();
counts[name] = 0;
return name; return name;
} }
@@ -536,6 +609,11 @@ class LocalFavoritesManager with ChangeNotifier {
""", [updateTime, comic.id, comic.type.value]); """, [updateTime, comic.id, comic.type.value]);
} }
} }
if (counts[folder] == null) {
counts[folder] = count(folder);
} else {
counts[folder] = counts[folder]! + 1;
}
notifyListeners(); notifyListeners();
return true; return true;
} }
@@ -575,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void batchMoveFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
_db.execute("BEGIN TRANSACTION");
var displayOrder = maxValue(targetFolder) + 1;
try {
for (var item in items) {
_db.execute("""
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
select id, name, author, type, tags, cover_path, time, ?
from "$sourceFolder"
where id == ? and type == ?;
""", [displayOrder, item.id, item.type.value]);
_db.execute("""
delete from "$sourceFolder"
where id == ? and type == ?;
""", [item.id, item.type.value]);
displayOrder++;
}
notifyListeners();
} catch (e) {
Log.error("Batch Move Favorites", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
// Update counts
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
if (counts[sourceFolder] != null) {
counts[sourceFolder] = counts[sourceFolder]! - items.length;
} else {
counts[sourceFolder] = count(sourceFolder);
}
notifyListeners();
}
void batchCopyFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
_db.execute("BEGIN TRANSACTION");
var displayOrder = maxValue(targetFolder) + 1;
try {
for (var item in items) {
_db.execute("""
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
select id, name, author, type, tags, cover_path, time, ?
from "$sourceFolder"
where id == ? and type == ?;
""", [displayOrder, item.id, item.type.value]);
displayOrder++;
}
notifyListeners();
} catch (e) {
Log.error("Batch Copy Favorites", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
// Update counts
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
notifyListeners();
}
/// delete a folder /// delete a folder
void deleteFolder(String name) { void deleteFolder(String name) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
@@ -585,14 +759,10 @@ class LocalFavoritesManager with ChangeNotifier {
delete from folder_order delete from folder_order
where folder_name == ?; where folder_name == ?;
""", [name]); """, [name]);
counts.remove(name);
notifyListeners(); notifyListeners();
} }
void deleteComic(String folder, FavoriteItem comic) {
_modifiedAfterLastCache = true;
deleteComicWithId(folder, comic.id, comic.type);
}
void deleteComicWithId(String folder, String id, ComicType type) { void deleteComicWithId(String folder, String id, ComicType type) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
LocalFavoriteImageProvider.delete(id, type.value); LocalFavoriteImageProvider.delete(id, type.value);
@@ -600,6 +770,60 @@ class LocalFavoritesManager with ChangeNotifier {
delete from "$folder" delete from "$folder"
where id == ? and type == ?; where id == ? and type == ?;
""", [id, type.value]); """, [id, type.value]);
if (counts[folder] != null) {
counts[folder] = counts[folder]! - 1;
} else {
counts[folder] = count(folder);
}
notifyListeners();
}
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION");
try {
for (var comic in comics) {
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
}
if (counts[folder] != null) {
counts[folder] = counts[folder]! - comics.length;
} else {
counts[folder] = count(folder);
}
} catch (e) {
Log.error("Batch Delete Comics", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
notifyListeners();
}
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION");
var folderNames = _getFolderNamesWithDB();
try {
for (var comic in comics) {
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
for (var folder in folderNames) {
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
}
}
} catch (e) {
Log.error("Batch Delete Comics in All Folders", e.toString());
_db.execute("ROLLBACK");
return;
}
initCounts();
_db.execute("COMMIT");
notifyListeners(); notifyListeners();
} }
@@ -630,11 +854,26 @@ class LocalFavoritesManager with ChangeNotifier {
if (!existsFolder(folder)) { if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found"); throw Exception("Failed to reorder: folder not found");
} }
deleteFolder(folder); _db.execute("BEGIN TRANSACTION");
createFolder(folder); try {
for (int i = 0; i < newFolder.length; i++) { for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], i); _db.execute("""
update "$folder"
set display_order = ?
where id == ? and type == ?;
""", [
i,
newFolder[i].id,
newFolder[i].type.value
]);
}
} }
catch (e) {
Log.error("Reorder", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
notifyListeners(); notifyListeners();
} }
@@ -659,6 +898,8 @@ class LocalFavoritesManager with ChangeNotifier {
set folder_name = ? set folder_name = ?
where folder_name == ?; where folder_name == ?;
""", [after, before]); """, [after, before]);
counts[after] = counts[before] ?? 0;
counts.remove(before);
notifyListeners(); notifyListeners();
} }
@@ -736,10 +977,10 @@ class LocalFavoritesManager with ChangeNotifier {
return comics; return comics;
} }
List<FavoriteItemWithFolderInfo> search(String keyword) { List<FavoriteItem> search(String keyword) {
var keywordList = keyword.split(" "); var keywordList = keyword.split(" ");
keyword = keywordList.first; keyword = keywordList.first;
var comics = <FavoriteItemWithFolderInfo>[]; var comics = <FavoriteItem>{};
for (var table in folderNames) { for (var table in folderNames) {
keyword = "%$keyword%"; keyword = "%$keyword%";
var res = _db.select(""" var res = _db.select("""
@@ -747,15 +988,18 @@ class LocalFavoritesManager with ChangeNotifier {
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?; WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
""", [keyword, keyword, keyword, keyword]); """, [keyword, keyword, keyword, keyword]);
for (var comic in res) { for (var comic in res) {
comics.add( comics.add(FavoriteItem.fromRow(comic));
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
} }
if (comics.length > 200) { if (comics.length > 200) {
break; break;
} }
} }
bool test(FavoriteItemWithFolderInfo comic, String keyword) { bool test(FavoriteItem comic, String keyword) {
keyword = keyword.trim();
if (keyword.isEmpty) {
return true;
}
if (comic.name.contains(keyword)) { if (comic.name.contains(keyword)) {
return true; return true;
} else if (comic.author.contains(keyword)) { } else if (comic.author.contains(keyword)) {
@@ -766,12 +1010,14 @@ class LocalFavoritesManager with ChangeNotifier {
return false; return false;
} }
for (var i = 1; i < keywordList.length; i++) { return comics.where((element) {
comics = for (var i = 1; i < keywordList.length; i++) {
comics.where((element) => test(element, keywordList[i])).toList(); if (!test(element, keywordList[i])) {
} return false;
}
return comics; }
return true;
}).toList();
} }
void editTags(String id, String folder, List<String> tags) { void editTags(String id, String folder, List<String> tags) {

View File

@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart'; import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -132,6 +133,11 @@ class History implements Comic {
@override @override
String get description { String get description {
var res = ""; var res = "";
if (group != null){
res += "${"Group @group".tlParams({
"group": group!,
})} - ";
}
if (ep >= 1) { if (ep >= 1) {
res += "Chapter @ep".tlParams({ res += "Chapter @ep".tlParams({
"ep": ep, "ep": ep,
@@ -305,6 +311,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void clearUnfavoritedHistory() {
_db.execute('BEGIN TRANSACTION;');
try {
final idAndTypes = _db.select("""
select id, type from history;
""");
for (var element in idAndTypes) {
final id = element["id"] as String;
final type = ComicType(element["type"] as int);
if (!LocalFavoritesManager().isExist(id, type)) {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [id, type.value]);
}
}
_db.execute('COMMIT;');
} catch (e) {
_db.execute('ROLLBACK;');
rethrow;
}
updateCache();
notifyListeners();
}
void remove(String id, ComicType type) async { void remove(String id, ComicType type) async {
_db.execute(""" _db.execute("""
delete from history delete from history
@@ -380,4 +411,23 @@ class HistoryManager with ChangeNotifier {
isInitialized = false; isInitialized = false;
_db.dispose(); _db.dispose();
} }
void batchDeleteHistories(List<ComicID> histories) {
if (histories.isEmpty) return;
_db.execute('BEGIN TRANSACTION;');
try {
for (var history in histories) {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [history.id, history.type.value]);
}
_db.execute('COMMIT;');
} catch (e) {
_db.execute('ROLLBACK;');
rethrow;
}
updateCache();
notifyListeners();
}
} }

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:enough_convert/enough_convert.dart';
import 'package:flutter/foundation.dart' show protected; import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html; import 'package:html/parser.dart' as html;
@@ -25,6 +26,7 @@ import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/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';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/init.dart'; import 'package:venera/utils/init.dart';
import 'comic_source/comic_source.dart'; import 'comic_source/comic_source.dart';
@@ -163,6 +165,13 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
return "${App.locale.languageCode}_${App.locale.countryCode}"; return "${App.locale.languageCode}_${App.locale.countryCode}";
case "getPlatform": case "getPlatform":
return Platform.operatingSystem; return Platform.operatingSystem;
case "setClipboard":
return Clipboard.setData(ClipboardData(text: message["text"]));
case "getClipboard":
return Future.sync(() async {
var res = await Clipboard.getData(Clipboard.kTextPlain);
return res?.text;
});
} }
} }
return null; return null;
@@ -187,7 +196,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain, responseType: ResponseType.plain,
validateStatus: (status) => true, validateStatus: (status) => true,
)); ));
var proxy = await AppDio.getProxy(); var proxy = await getProxy();
dio.httpClientAdapter = IOHttpClientAdapter( dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () { createHttpClient: () {
return HttpClient() return HttpClient()
@@ -364,6 +373,11 @@ mixin class _JSEngineApi {
switch (type) { switch (type) {
case "utf8": case "utf8":
return isEncode ? utf8.encode(value) : utf8.decode(value); return isEncode ? utf8.encode(value) : utf8.decode(value);
case "gbk":
final codec = const GbkCodec();
return isEncode
? Uint8List.fromList(codec.encode(value))
: codec.decode(value);
case "base64": case "base64":
return isEncode ? base64Encode(value) : base64Decode(value); return isEncode ? base64Encode(value) : base64Decode(value);
case "md5": case "md5":

View File

@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:flutter_saf/flutter_saf.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -107,15 +109,42 @@ class LocalComic with HistoryMixin implements Comic {
void read() { void read() {
var history = HistoryManager().find(id, comicType); var history = HistoryManager().find(id, comicType);
int? firstDownloadedChapter;
int? firstDownloadedChapterGroup;
if (downloadedChapters.isNotEmpty && chapters != null) {
final chapters = this.chapters!;
if (chapters.isGrouped) {
for (int i=0; i<chapters.groupCount; i++) {
var group = chapters.getGroupByIndex(i);
var keys = group.keys.toList();
for (int j=0; j<keys.length; j++) {
var chapterId = keys[j];
if (downloadedChapters.contains(chapterId)) {
firstDownloadedChapter = j + 1;
firstDownloadedChapterGroup = i + 1;
break;
}
}
}
} else {
var keys = chapters.allChapters.keys;
for (int i = 0; i < keys.length; i++) {
if (downloadedChapters.contains(keys.elementAt(i))) {
firstDownloadedChapter = i + 1;
break;
}
}
}
}
App.rootContext.to( App.rootContext.to(
() => Reader( () => Reader(
type: comicType, type: comicType,
cid: id, cid: id,
name: title, name: title,
chapters: chapters, chapters: chapters,
initialChapter: history?.ep, initialChapter: history?.ep ?? firstDownloadedChapter,
initialPage: history?.page, initialPage: history?.page,
initialChapterGroup: history?.group, initialChapterGroup: history?.group ?? firstDownloadedChapterGroup,
history: history ?? history: history ??
History.fromModel( History.fromModel(
model: this, model: this,
@@ -461,6 +490,10 @@ class LocalManager with ChangeNotifier {
if (comic != null) { if (comic != null) {
return Directory(FilePath.join(path, comic.directory)); return Directory(FilePath.join(path, comic.directory));
} }
const comicDirectoryMaxLength = 80;
if (name.length > comicDirectoryMaxLength) {
name = name.substring(0, comicDirectoryMaxLength);
}
var dir = findValidDirectoryName(path, name); var dir = findValidDirectoryName(path, name);
return Directory(FilePath.join(path, dir)).create().then((value) => value); return Directory(FilePath.join(path, dir)).create().then((value) => value);
} }
@@ -542,6 +575,99 @@ class LocalManager with ChangeNotifier {
remove(c.id, c.comicType); remove(c.id, c.comicType);
notifyListeners(); notifyListeners();
} }
void deleteComicChapters(LocalComic c, List<String> chapters) {
if (chapters.isEmpty) {
return;
}
var newDownloadedChapters = c.downloadedChapters
.where((e) => !chapters.contains(e))
.toList();
if (newDownloadedChapters.isNotEmpty) {
_db.execute(
'UPDATE comics SET downloadedChapters = ? WHERE id = ? AND comic_type = ?;',
[
jsonEncode(newDownloadedChapters),
c.id,
c.comicType.value,
],
);
} else {
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[c.id, c.comicType.value],
);
}
var shouldRemovedDirs = <Directory>[];
for (var chapter in chapters) {
var dir = Directory(FilePath.join(c.baseDir, chapter));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
}
if (shouldRemovedDirs.isNotEmpty) {
_deleteDirectories(shouldRemovedDirs);
}
notifyListeners();
}
void batchDeleteComics(List<LocalComic> comics, [bool removeFileOnDisk = true, bool removeFavoriteAndHistory = true]) {
if (comics.isEmpty) {
return;
}
var shouldRemovedDirs = <Directory>[];
_db.execute('BEGIN TRANSACTION;');
try {
for (var c in comics) {
if (removeFileOnDisk) {
var dir = Directory(FilePath.join(path, c.directory));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
}
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[c.id, c.comicType.value],
);
}
}
catch(e, s) {
Log.error("LocalManager", "Failed to batch delete comics: $e", s);
_db.execute('ROLLBACK;');
return;
}
_db.execute('COMMIT;');
var comicIDs = comics.map((e) => ComicID(e.comicType, e.id)).toList();
if (removeFavoriteAndHistory) {
LocalFavoritesManager().batchDeleteComicsInAllFolders(comicIDs);
HistoryManager().batchDeleteHistories(comicIDs);
}
notifyListeners();
if (removeFileOnDisk) {
_deleteDirectories(shouldRemovedDirs);
}
}
/// Deletes the directories in a separate isolate to avoid blocking the UI thread.
static void _deleteDirectories(List<Directory> directories) {
Isolate.run(() async {
await SAFTaskWorker().init();
for (var dir in directories) {
try {
if (dir.existsSync()) {
await dir.delete(recursive: true);
}
} catch (e) {
continue;
}
}
});
}
} }
enum LocalSortType { enum LocalSortType {

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
class LogItem { class LogItem {
final LogLevel level; final LogLevel level;
@@ -28,9 +28,6 @@ class Log {
static bool ignoreLimitation = false; static bool ignoreLimitation = false;
/// only for debug
static const String? logFile = null;
static void printWarning(String text) { static void printWarning(String text) {
debugPrint('\x1B[33m$text\x1B[0m'); debugPrint('\x1B[33m$text\x1B[0m');
} }
@@ -39,7 +36,20 @@ class Log {
debugPrint('\x1B[31m$text\x1B[0m'); debugPrint('\x1B[31m$text\x1B[0m');
} }
static IOSink? _file;
static void addLog(LogLevel level, String title, String content) { static void addLog(LogLevel level, String title, String content) {
if (_file == null) {
Directory dir;
if (App.isAndroid) {
dir = Directory(App.externalStoragePath!);
} else {
dir = Directory(App.dataPath);
}
var file = dir.joinFile("logs.txt");
_file = file.openWrite();
}
if (!ignoreLimitation && content.length > maxLogLength) { if (!ignoreLimitation && content.length > maxLogLength) {
content = "${content.substring(0, maxLogLength)}..."; content = "${content.substring(0, maxLogLength)}...";
} }
@@ -62,8 +72,8 @@ class Log {
} }
_logs.add(newLog); _logs.add(newLog);
if(logFile != null) { if(_file != null) {
File(logFile!).writeAsString(newLog.toString(), mode: FileMode.append); _file!.write(newLog.toString());
} }
if (_logs.length > maxLogNumber) { if (_logs.length > maxLogNumber) {
var res = _logs.remove( var res = _logs.remove(

View File

@@ -1,4 +1,8 @@
import 'dart:async';
import 'package:display_mode/display_mode.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_saf/flutter_saf.dart'; import 'package:flutter_saf/flutter_saf.dart';
import 'package:rhttp/rhttp.dart'; import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -47,10 +51,23 @@ Future<void> init() async {
if (App.isAndroid) { if (App.isAndroid) {
handleLinks(); handleLinks();
handleTextShare(); handleTextShare();
try {
await FlutterDisplayMode.setHighRefreshRate();
} catch(e) {
Log.error("Display Mode", "Failed to set high refresh rate: $e");
}
} }
FlutterError.onError = (details) { FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}"); Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
}; };
if (App.isWindows) {
// Report to the monitor thread that the app is running
// https://github.com/venera-app/venera/issues/343
Timer.periodic(const Duration(seconds: 1), (_) {
const methodChannel = MethodChannel('venera/method_channel');
methodChannel.invokeMethod("heartBeat");
});
}
} }
void _checkOldConfigs() { void _checkOldConfigs() {
@@ -84,8 +101,7 @@ Future<void> _checkAppUpdates() async {
appdata.writeImplicitData(); appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate(); ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) { if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300)); await checkUpdateUi(false, true);
await checkUpdateUi(false);
} }
} }

View File

@@ -35,8 +35,14 @@ void main(List<String> args) {
} }
await windowManager.setMinimumSize(const Size(500, 600)); await windowManager.setMinimumSize(const Size(500, 600));
var placement = await WindowPlacement.loadFromFile(); var placement = await WindowPlacement.loadFromFile();
await placement.applyToWindow(); if (App.isLinux) {
await windowManager.show(); await windowManager.show();
await placement.applyToWindow();
} else {
await placement.applyToWindow();
await windowManager.show();
}
WindowPlacement.loop(); WindowPlacement.loop();
}); });
} }

View File

@@ -7,7 +7,7 @@ import 'package:rhttp/rhttp.dart' as rhttp;
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/cache.dart'; import 'package:venera/network/cache.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/network/proxy.dart';
import '../foundation/app.dart'; import '../foundation/app.dart';
import 'cloudflare.dart'; import 'cloudflare.dart';
@@ -96,9 +96,11 @@ class MyLogInterceptor implements Interceptor {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
Log.info("Network", "${options.method} ${options.uri}\n" Log.info(
"headers:\n${options.headers}\n" "Network",
"data:\n${options.data}"); "${options.method} ${options.uri}\n"
"headers:\n${options.headers}\n"
"data:\n${options.data}");
options.connectTimeout = const Duration(seconds: 15); options.connectTimeout = const Duration(seconds: 15);
options.receiveTimeout = const Duration(seconds: 15); options.receiveTimeout = const Duration(seconds: 15);
options.sendTimeout = const Duration(seconds: 15); options.sendTimeout = const Duration(seconds: 15);
@@ -107,64 +109,15 @@ class MyLogInterceptor implements Interceptor {
} }
class AppDio with DioMixin { class AppDio with DioMixin {
String? _proxy = proxy;
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings( httpClientAdapter = RHttpAdapter();
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
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 String? proxy;
static Future<String?> getProxy() async {
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
return null;
}
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
String res;
if (!App.isLinux) {
const channel = MethodChannel("venera/method_channel");
try {
res = await channel.invokeMethod("getProxy");
} catch (e) {
return null;
}
} else {
res = "No Proxy";
}
if (res == "No Proxy") return null;
if (res.contains(";")) {
var proxies = res.split(";");
for (String proxy in proxies) {
proxy = proxy.removeAllBlank;
if (proxy.startsWith('https=')) {
return proxy.substring(6);
}
}
}
final RegExp regex = RegExp(
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
caseSensitive: false,
multiLine: false,
);
if (!regex.hasMatch(res)) {
return null;
}
return res;
}
static final Map<String, bool> _requests = {}; static final Map<String, bool> _requests = {};
@override @override
@@ -184,16 +137,6 @@ class AppDio with DioMixin {
_requests[path] = true; _requests[path] = true;
options!.headers!.remove('prevent-parallel'); options!.headers!.remove('prevent-parallel');
} }
proxy = await getProxy();
if (_proxy != proxy) {
Log.info("Network", "Proxy changed to $proxy");
_proxy = proxy;
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy!),
));
}
try { try {
return super.request<T>( return super.request<T>(
path, path,
@@ -213,7 +156,26 @@ class AppDio with DioMixin {
} }
class RHttpAdapter implements HttpClientAdapter { class RHttpAdapter implements HttpClientAdapter {
rhttp.ClientSettings settings; Future<rhttp.ClientSettings> get settings async {
var proxy = await getProxy();
return rhttp.ClientSettings(
proxySettings: proxy == null
? const rhttp.ProxySettings.noProxy()
: rhttp.ProxySettings.proxy(proxy),
redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings(
connectTimeout: Duration(seconds: 15),
keepAliveTimeout: Duration(seconds: 60),
keepAlivePing: Duration(seconds: 30),
),
throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
),
);
}
static Map<String, List<String>> _getOverrides() { static Map<String, List<String>> _getOverrides() {
if (!appdata.settings['enableDnsOverrides'] == true) { if (!appdata.settings['enableDnsOverrides'] == true) {
@@ -231,22 +193,6 @@ class RHttpAdapter implements HttpClientAdapter {
return result; return result;
} }
RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
settings = settings.copyWith(
redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings(
connectTimeout: Duration(seconds: 15),
keepAliveTimeout: Duration(seconds: 60),
keepAlivePing: Duration(seconds: 30),
),
throwOnStatusCode: false,
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
tlsSettings: rhttp.TlsSettings(
sni: appdata.settings['sni'] != false,
),
);
}
@override @override
void close({bool force = false}) {} void close({bool force = false}) {}
@@ -256,10 +202,15 @@ class RHttpAdapter implements HttpClientAdapter {
Stream<Uint8List>? requestStream, Stream<Uint8List>? requestStream,
Future<void>? cancelFuture, Future<void>? cancelFuture,
) async { ) async {
if (options.headers['User-Agent'] == null &&
options.headers['user-agent'] == null) {
options.headers['User-Agent'] = "venera/v${App.version}";
}
var res = await rhttp.Rhttp.request( var res = await rhttp.Rhttp.request(
method: rhttp.HttpMethod(options.method), method: rhttp.HttpMethod(options.method),
url: options.uri.toString(), url: options.uri.toString(),
settings: settings, settings: await settings,
expectBody: rhttp.HttpExpectBody.stream, expectBody: rhttp.HttpExpectBody.stream,
body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream), body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream),
headers: rhttp.HttpHeaders.rawMap( headers: rhttp.HttpHeaders.rawMap(
@@ -289,7 +240,7 @@ class RHttpAdapter implements HttpClientAdapter {
} }
static String _getStatusMessage(int statusCode) { static String _getStatusMessage(int statusCode) {
return switch(statusCode) { return switch (statusCode) {
200 => "OK", 200 => "OK",
201 => "Created", 201 => "Created",
202 => "Accepted", 202 => "Accepted",
@@ -299,9 +250,11 @@ class RHttpAdapter implements HttpClientAdapter {
302 => "Found", 302 => "Found",
400 => "Invalid Status Code 400: The Request is invalid.", 400 => "Invalid Status Code 400: The Request is invalid.",
401 => "Invalid Status Code 401: The Request is unauthorized.", 401 => "Invalid Status Code 401: The Request is unauthorized.",
403 => "Invalid Status Code 403: No permission to access the resource. Check your account or network.", 403 =>
"Invalid Status Code 403: No permission to access the resource. Check your account or network.",
404 => "Invalid Status Code 404: Not found.", 404 => "Invalid Status Code 404: Not found.",
429 => "Invalid Status Code 429: Too many requests. Please try again later.", 429 =>
"Invalid Status Code 429: Too many requests. Please try again later.",
_ => "Invalid Status Code $statusCode", _ => "Invalid Status Code $statusCode",
}; };
} }

View File

@@ -482,7 +482,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
chapters: comic!.chapters, chapters: comic!.chapters,
cover: File(_cover!.split("file://").last).name, cover: File(_cover!.split("file://").last).name,
comicType: ComicType(source.key.hashCode), comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [], downloadedChapters: chapters ?? comic?.chapters?.ids.toList() ?? [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
} }
@@ -552,7 +552,7 @@ class _ImageDownloadWrapper {
void start() async { void start() async {
int lastBytes = 0; int lastBytes = 0;
try { try {
await for (var p in ImageDownloader.loadComicImage( await for (var p in ImageDownloader.loadComicImageUnwrapped(
image, task.source.key, task.comicId, chapter)) { image, task.source.key, task.comicId, chapter)) {
if (isCancelled) { if (isCancelled) {
return; return;

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/proxy.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
class FileDownloader { class FileDownloader {
@@ -105,7 +106,7 @@ class FileDownloader {
void _download(StreamController<DownloadingStatus> resultStream) async { void _download(StreamController<DownloadingStatus> resultStream) async {
try { try {
var proxy = await AppDio.getProxy(); var proxy = await getProxy();
_dio.httpClientAdapter = IOHttpClientAdapter( _dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () { createHttpClient: () {
return HttpClient() return HttpClient()

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_qjs/flutter_qjs.dart'; import 'package:flutter_qjs/flutter_qjs.dart';
@@ -8,7 +9,7 @@ import 'package:venera/utils/image.dart';
import 'app_dio.dart'; import 'app_dio.dart';
class ImageDownloader { abstract class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail( static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey, String url, String? sourceKey,
[String? cid]) async* { [String? cid]) async* {
@@ -82,7 +83,40 @@ class ImageDownloader {
); );
} }
static final _loadingImages = <String, _StreamWrapper<ImageDownloadProgress>>{};
/// Cancel all loading images.
static void cancelAllLoadingImages() {
for (var wrapper in _loadingImages.values) {
wrapper.cancel();
}
_loadingImages.clear();
}
/// Load a comic image from the network or cache.
/// The function will prevent multiple requests for the same image.
static Stream<ImageDownloadProgress> loadComicImage( static Stream<ImageDownloadProgress> loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
if (_loadingImages.containsKey(cacheKey)) {
return _loadingImages[cacheKey]!.stream;
}
final stream = _StreamWrapper<ImageDownloadProgress>(
_loadComicImage(imageKey, sourceKey, cid, eid),
(wrapper) {
_loadingImages.remove(cacheKey);
},
);
_loadingImages[cacheKey] = stream;
return stream.stream;
}
static Stream<ImageDownloadProgress> loadComicImageUnwrapped(
String imageKey, String? sourceKey, String cid, String eid) {
return _loadComicImage(imageKey, sourceKey, cid, eid);
}
static Stream<ImageDownloadProgress> _loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) async* { String imageKey, String? sourceKey, String cid, String eid) async* {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
final cache = await CacheManager().findCache(cacheKey); final cache = await CacheManager().findCache(cacheKey);
@@ -189,6 +223,74 @@ class ImageDownloader {
} }
} }
/// A wrapper class for a stream that
/// allows multiple listeners to listen to the same stream.
class _StreamWrapper<T> {
final Stream<T> _stream;
final List<StreamController> controllers = [];
final void Function(_StreamWrapper<T> wrapper) onClosed;
bool isClosed = false;
_StreamWrapper(this._stream, this.onClosed) {
_listen();
}
void _listen() async {
try {
await for (var data in _stream) {
if (isClosed) {
break;
}
for (var controller in controllers) {
if (!controller.isClosed) {
controller.add(data);
}
}
}
}
catch (e) {
for (var controller in controllers) {
if (!controller.isClosed) {
controller.addError(e);
}
}
}
finally {
for (var controller in controllers) {
if (!controller.isClosed) {
controller.close();
}
}
}
controllers.clear();
isClosed = true;
onClosed(this);
}
Stream<T> get stream {
if (isClosed) {
throw Exception('Stream is closed');
}
var controller = StreamController<T>();
controllers.add(controller);
controller.onCancel = () {
controllers.remove(controller);
};
return controller.stream;
}
void cancel() {
for (var controller in controllers) {
controller.close();
}
controllers.clear();
isClosed = true;
}
}
class ImageDownloadProgress { class ImageDownloadProgress {
final int currentBytes; final int currentBytes;

60
lib/network/proxy.dart Normal file
View File

@@ -0,0 +1,60 @@
import 'package:flutter/services.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/utils/ext.dart';
String? _cachedProxy;
DateTime? _cachedProxyTime;
Future<String?> getProxy() async {
if (_cachedProxyTime != null &&
DateTime.now().difference(_cachedProxyTime!).inSeconds < 1) {
return _cachedProxy;
}
String? proxy = await _getProxy();
_cachedProxy = proxy;
_cachedProxyTime = DateTime.now();
return proxy;
}
Future<String?> _getProxy() async {
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
return null;
}
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
String res;
if (!App.isLinux) {
const channel = MethodChannel("venera/method_channel");
try {
res = await channel.invokeMethod("getProxy");
} catch (e) {
return null;
}
} else {
res = "No Proxy";
}
if (res == "No Proxy") return null;
if (res.contains(";")) {
var proxies = res.split(";");
for (String proxy in proxies) {
proxy = proxy.removeAllBlank;
if (proxy.startsWith('https=')) {
return proxy.substring(6);
}
}
}
final RegExp regex = RegExp(
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
caseSensitive: false,
multiLine: false,
);
if (!regex.hasMatch(res)) {
return null;
}
return res;
}

View File

@@ -4,12 +4,10 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/pages/ranking_page.dart'; import 'package:venera/pages/ranking_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'category_comics_page.dart';
import 'comic_source_page.dart'; import 'comic_source_page.dart';
class CategoriesPage extends StatefulWidget { class CategoriesPage extends StatefulWidget {
@@ -147,43 +145,6 @@ class _CategoryPage extends StatelessWidget {
return ""; return "";
} }
void handleClick(
String tag,
String? param,
String type,
String namespace,
String categoryKey,
) {
if (type == 'search') {
App.mainNavigatorKey?.currentContext?.to(
() => SearchResultPage(
text: tag,
options: const [],
sourceKey: findComicSourceKey(),
),
);
} else if (type == "search_with_namespace") {
if (tag.contains(" ")) {
tag = '"$tag"';
}
App.mainNavigatorKey?.currentContext?.to(
() => SearchResultPage(
text: "$namespace:$tag",
options: const [],
sourceKey: findComicSourceKey(),
),
);
} else if (type == "category") {
App.mainNavigatorKey!.currentContext!.to(
() => CategoryComicsPage(
category: tag,
categoryKey: categoryKey,
param: param,
),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var children = <Widget>[]; var children = <Widget>[];
@@ -194,11 +155,11 @@ class _CategoryPage extends StatelessWidget {
child: Wrap( child: Wrap(
children: [ children: [
if (data.enableRankingPage) if (data.enableRankingPage)
buildTag("Ranking".tl, (p0, p1) { buildTag("Ranking".tl, () {
context.to(() => RankingPage(categoryKey: data.key)); context.to(() => RankingPage(categoryKey: data.key));
}), }),
for (var buttonData in data.buttons) for (var buttonData in data.buttons)
buildTag(buttonData.label.tl, (p0, p1) => buttonData.onTap()) buildTag(buttonData.label.tl, buttonData.onTap)
], ],
), ),
)); ));
@@ -212,36 +173,14 @@ class _CategoryPage extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
buildTitleWithRefresh(part.title, () => updater(() {})), buildTitleWithRefresh(part.title, () => updater(() {})),
buildTagsWithParams( buildTags(part.categories)
part.categories,
part.categoryParams,
part.title,
(key, param) => handleClick(
key,
param,
part.categoryType,
part.title,
category,
),
)
], ],
); );
})); }));
} else { } else {
children.add(buildTitle(part.title)); children.add(buildTitle(part.title));
children.add( children.add(
buildTagsWithParams( buildTags(part.categories),
part.categories,
part.categoryParams,
part.title,
(tag, param) => handleClick(
tag,
param,
part.categoryType,
part.title,
data.key,
),
),
); );
} }
} }
@@ -280,30 +219,28 @@ class _CategoryPage extends StatelessWidget {
); );
} }
Widget buildTagsWithParams( Widget buildTags(
List<String> tags, List<CategoryItem> categories,
List<String>? params,
String? namespace,
ClickTagCallback onClick,
) { ) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16), padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
child: Wrap( child: Wrap(
children: List<Widget>.generate( children: List<Widget>.generate(
tags.length, categories.length,
(index) => buildTag( (index) => buildCategory(categories[index]),
tags[index],
onClick,
namespace,
params?.elementAtOrNull(index),
),
), ),
), ),
); );
} }
Widget buildTag(String tag, ClickTagCallback onClick, Widget buildCategory(CategoryItem c) {
[String? namespace, String? param]) { return buildTag(c.label, () {
var context = App.mainNavigatorKey!.currentContext!;
c.target.jump(context);
});
}
Widget buildTag(String label, VoidCallback onClick) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Builder( child: Builder(
@@ -313,10 +250,10 @@ class _CategoryPage extends StatelessWidget {
color: context.colorScheme.primaryContainer.toOpacity(0.72), color: context.colorScheme.primaryContainer.toOpacity(0.72),
child: InkWell( child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)), borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () => onClick(tag, param), onTap: onClick,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(tag), child: Text(label),
), ),
), ),
); );

View File

@@ -9,6 +9,7 @@ class CategoryComicsPage extends StatefulWidget {
required this.category, required this.category,
this.param, this.param,
required this.categoryKey, required this.categoryKey,
this.options,
super.key, super.key,
}); });
@@ -18,6 +19,8 @@ class CategoryComicsPage extends StatefulWidget {
final String categoryKey; final String categoryKey;
final List<String>? options;
@override @override
State<CategoryComicsPage> createState() => _CategoryComicsPageState(); State<CategoryComicsPage> createState() => _CategoryComicsPageState();
} }
@@ -31,6 +34,9 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
void findData() { void findData() {
for (final source in ComicSource.all()) { for (final source in ComicSource.all()) {
if (source.categoryData?.key == widget.categoryKey) { if (source.categoryData?.key == widget.categoryKey) {
if (source.categoryComicsData == null) {
throw "The comic source ${source.name} does not support category comics";
}
data = source.categoryComicsData!; data = source.categoryComicsData!;
options = data.options.where((element) { options = data.options.where((element) {
if (element.notShowWhen.contains(widget.category)) { if (element.notShowWhen.contains(widget.category)) {
@@ -40,7 +46,16 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
} }
return true; return true;
}).toList(); }).toList();
optionsValue = options.map((e) => e.options.keys.first).toList(); var defaultOptionsValue =
options.map((e) => e.options.keys.first).toList();
if (optionsValue.length != options.length) {
var newOptionsValue = List<String>.filled(options.length, "");
for (var i = 0; i < options.length; i++) {
newOptionsValue[i] =
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
}
optionsValue = newOptionsValue;
}
sourceKey = source.key; sourceKey = source.key;
return; return;
} }
@@ -50,6 +65,11 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
@override @override
void initState() { void initState() {
if (widget.options != null) {
optionsValue = widget.options!;
} else {
optionsValue = [];
}
findData(); findData();
super.initState(); super.initState();
} }

View File

@@ -294,27 +294,9 @@ abstract mixin class _ComicPageActions {
} }
void onTapTag(String tag, String namespace) { void onTapTag(String tag, String namespace) {
var config = comicSource.handleClickTagEvent?.call(namespace, tag) ?? var target = comicSource.handleClickTagEvent?.call(namespace, tag);
{
'action': 'search',
'keyword': tag,
};
var context = App.mainNavigatorKey!.currentContext!; var context = App.mainNavigatorKey!.currentContext!;
if (config['action'] == 'search') { target?.jump(context);
context.to(() => SearchResultPage(
text: config['keyword'] ?? '',
sourceKey: comicSource.key,
options: const [],
));
} else if (config['action'] == 'category') {
context.to(
() => CategoryComicsPage(
category: config['keyword'] ?? '',
categoryKey: comicSource.categoryData!.key,
param: config['param'],
),
);
}
} }
void showMoreActions() { void showMoreActions() {

View File

@@ -27,7 +27,7 @@ class _NormalComicChapters extends StatefulWidget {
class _NormalComicChaptersState extends State<_NormalComicChapters> { class _NormalComicChaptersState extends State<_NormalComicChapters> {
late _ComicPageState state; late _ComicPageState state;
bool reverse = false; late bool reverse;
bool showAll = false; bool showAll = false;
@@ -38,6 +38,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history; history = widget.history;
} }
@@ -105,7 +106,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
var value = chapters[key]!; var value = chapters[key]!;
bool visited = (history?.readEpisode ?? {}).contains(i + 1); bool visited = (history?.readEpisode ?? {}).contains(i + 1);
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Material( child: Material(
color: context.colorScheme.surfaceContainer, color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -113,7 +114,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
onTap: () => state.read(i + 1), onTap: () => state.read(i + 1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center( child: Center(
child: Text( child: Text(
value, value,
@@ -134,7 +135,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
}, },
), ),
gridDelegate: const SliverGridDelegateWithFixedHeight( gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 250,
itemHeight: 48, itemHeight: 48,
), ),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)), ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
@@ -176,7 +177,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late _ComicPageState state; late _ComicPageState state;
bool reverse = false; late bool reverse;
bool showAll = false; bool showAll = false;
@@ -191,6 +192,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history; history = widget.history;
if (history?.group != null) { if (history?.group != null) {
index = history!.group! - 1; index = history!.group! - 1;
@@ -300,15 +302,15 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
history!.readEpisode.contains(rawIndex); history!.readEpisode.contains(rawIndex);
} }
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
child: Material( child: Material(
color: context.colorScheme.surfaceContainer, color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
child: InkWell( child: InkWell(
onTap: () => state.read(chapterIndex + 1), onTap: () => state.read(chapterIndex + 1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Center( child: Center(
child: Text( child: Text(
value, value,
@@ -329,7 +331,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
}, },
), ),
gridDelegate: const SliverGridDelegateWithFixedHeight( gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 250,
itemHeight: 48, itemHeight: 48,
), ),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)), ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),

View File

@@ -17,10 +17,8 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/category_comics_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -75,6 +73,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
bool isDownloaded = false; bool isDownloaded = false;
bool showFAB = false;
@override @override
void onReadEnd() { void onReadEnd() {
history ??= history ??=
@@ -114,7 +114,15 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ComicDetails get comic => data!; ComicDetails get comic => data!;
void onScroll() { void onScroll() {
if (scrollController.offset > 100) { var offset = scrollController.position.pixels -
scrollController.position.minScrollExtent;
var showFAB = offset > 0;
if (showFAB != this.showFAB) {
setState(() {
this.showFAB = showFAB;
});
}
if (offset > 100) {
if (!showAppbarTitle) { if (!showAppbarTitle) {
setState(() { setState(() {
showAppbarTitle = true; showAppbarTitle = true;
@@ -133,19 +141,33 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override @override
Widget buildContent(BuildContext context, ComicDetails data) { Widget buildContent(BuildContext context, ComicDetails data) {
return SmoothCustomScrollView( return Scaffold(
controller: scrollController, floatingActionButton: showFAB
slivers: [ ? FloatingActionButton(
...buildTitle(), onPressed: () {
buildActions(), scrollController.animateTo(0,
buildDescription(), duration: const Duration(milliseconds: 200),
buildInfo(), curve: Curves.ease);
buildChapters(), },
buildComments(), child: const Icon(Icons.arrow_upward),
buildThumbnails(), )
buildRecommend(), : null,
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), body: SmoothCustomScrollView(
], controller: scrollController,
slivers: [
...buildTitle(),
buildActions(),
buildDescription(),
buildInfo(),
buildChapters(),
buildComments(),
buildThumbnails(),
buildRecommend(),
SliverPadding(
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB
),
],
),
); );
} }
@@ -387,15 +409,27 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
var group = history!.group; var group = history!.group;
String text; String text;
if (haveChapter) { if (haveChapter) {
var epName = group == null var epName = "E$ep";
? comic.chapters!.titles.elementAt( String? groupName;
math.min(ep - 1, comic.chapters!.length - 1), try {
) if (group == null){
: comic.chapters! epName = comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
);
} else {
groupName = comic.chapters!.groups.elementAt(group - 1);
epName = comic.chapters!
.getGroupByIndex(group - 1) .getGroupByIndex(group - 1)
.values .values
.elementAt(ep - 1); .elementAt(ep - 1);
text = "${"Last Reading".tl}: $epName P$page"; }
}
catch(e) {
// ignore
}
text = groupName == null
? "${"Last Reading".tl}: $epName P$page"
: "${"Last Reading".tl}: $groupName $epName P$page";
} else { } else {
text = "${"Last Reading".tl}: P$page"; text = "${"Last Reading".tl}: P$page";
} }
@@ -437,7 +471,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.tags.isEmpty && if (comic.tags.isEmpty &&
comic.uploader == null && comic.uploader == null &&
comic.uploadTime == null && comic.uploadTime == null &&
comic.uploadTime == null) { comic.uploadTime == null &&
comic.maxPage == null) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
@@ -601,6 +636,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildTag(text: formatTime(comic.updateTime!)), buildTag(text: formatTime(comic.updateTime!)),
], ],
), ),
if (comic.maxPage != null)
buildWrap(
children: [
buildTag(text: 'Pages'.tl, isTitle: true),
buildTag(text: comic.maxPage.toString()),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
const Divider(), const Divider(),
], ],

View File

@@ -99,61 +99,67 @@ class _CommentsPageState extends State<CommentsPage> {
return Column( return Column(
children: [ children: [
Expanded( Expanded(
child: ListView.builder( child: SmoothScrollProvider(
primary: false, builder: (context, controller, physics) {
padding: EdgeInsets.zero, return ListView.builder(
itemCount: _comments!.length + 2, controller: controller,
itemBuilder: (context, index) { physics: physics,
if (index == 0) { primary: false,
if (widget.replyComment != null) { padding: EdgeInsets.zero,
return Column( itemCount: _comments!.length + 2,
children: [ itemBuilder: (context, index) {
_CommentTile( if (index == 0) {
comment: widget.replyComment!, if (widget.replyComment != null) {
source: widget.source, return Column(
comic: widget.data, children: [
showAvatar: showAvatar, _CommentTile(
showActions: false, comment: widget.replyComment!,
), source: widget.source,
const SizedBox(height: 8), comic: widget.data,
Container( showAvatar: showAvatar,
alignment: Alignment.centerLeft, showActions: false,
padding: const EdgeInsets.all(16), ),
decoration: BoxDecoration( const SizedBox(height: 8),
border: Border( Container(
top: BorderSide( alignment: Alignment.centerLeft,
color: context.colorScheme.outlineVariant, padding: const EdgeInsets.all(16),
width: 0.6, decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Text(
"Replies".tl,
style: ts.s18,
), ),
), ),
), ],
child: Text( );
"Replies".tl, } else {
style: ts.s18, return const SizedBox();
), }
), }
], index--;
if (index == _comments!.length) {
if (_page < (maxPage ?? _page + 1)) {
loadMore();
return const ListLoadingIndicator();
} else {
return const SizedBox();
}
}
return _CommentTile(
comment: _comments![index],
source: widget.source,
comic: widget.data,
showAvatar: showAvatar,
); );
} else { },
return const SizedBox();
}
}
index--;
if (index == _comments!.length) {
if (_page < (maxPage ?? _page + 1)) {
loadMore();
return const ListLoadingIndicator();
} else {
return const SizedBox();
}
}
return _CommentTile(
comment: _comments![index],
source: widget.source,
comic: widget.data,
showAvatar: showAvatar,
); );
}, },
), ),

View File

@@ -51,9 +51,7 @@ class ComicSourcePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(body: const _Body());
body: const _Body(),
);
} }
} }
@@ -87,10 +85,7 @@ class _BodyState extends State<_Body> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SmoothCustomScrollView( return SmoothCustomScrollView(
slivers: [ slivers: [
SliverAppbar( SliverAppbar(title: Text('Comic Source'.tl), style: AppbarStyle.shadow),
title: Text('Comic Source'.tl),
style: AppbarStyle.shadow,
),
buildCard(context), buildCard(context),
for (var source in ComicSource.all()) for (var source in ComicSource.all())
_SliverComicSource( _SliverComicSource(
@@ -109,9 +104,7 @@ class _BodyState extends State<_Body> {
showConfirmDialog( showConfirmDialog(
context: App.rootContext, context: App.rootContext,
title: "Delete".tl, title: "Delete".tl,
content: "Delete comic source '@n' ?".tlParams({ content: "Delete comic source '@n' ?".tlParams({"n": source.name}),
"n": source.name,
}),
btnColor: context.colorScheme.error, btnColor: context.colorScheme.error,
onConfirm: () { onConfirm: () {
var file = File(source.filePath); var file = File(source.filePath);
@@ -133,14 +126,16 @@ class _BodyState extends State<_Body> {
title: const Text("Reload Configs"), title: const Text("Reload Configs"),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: const Text("cancel")), child: const Text("cancel"),
),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await ComicSourceManager().reload(); await ComicSourceManager().reload();
App.forceRebuild(); App.forceRebuild();
}, },
child: const Text("continue")), child: const Text("continue"),
),
], ],
), ),
); );
@@ -157,8 +152,10 @@ class _BodyState extends State<_Body> {
); );
} }
static Future<void> update(ComicSource source, static Future<void> update(
[bool showLoading = true]) async { ComicSource source, [
bool showLoading = true,
]) 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;
@@ -174,8 +171,10 @@ class _BodyState extends State<_Body> {
); );
} }
try { try {
var res = await AppDio().get<String>(source.url, var res = await AppDio().get<String>(
options: Options(responseType: ResponseType.plain)); source.url,
options: Options(responseType: ResponseType.plain),
);
if (cancel) return; if (cancel) return;
controller?.close(); controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath); await ComicSourceParser().parse(res.data!, source.filePath);
@@ -192,12 +191,11 @@ class _BodyState extends State<_Body> {
} }
Widget buildCard(BuildContext context) { Widget buildCard(BuildContext context) {
Widget buildButton( Widget buildButton({
{required Widget child, required VoidCallback onPressed}) { required Widget child,
return Button.normal( required VoidCallback onPressed,
onPressed: onPressed, }) {
child: child, return Button.normal(onPressed: onPressed, child: child).fixHeight(32);
).fixHeight(32);
} }
return SliverToBoxAdapter( return SliverToBoxAdapter(
@@ -213,12 +211,14 @@ class _BodyState extends State<_Body> {
), ),
TextField( TextField(
decoration: InputDecoration( decoration: InputDecoration(
hintText: "URL", hintText: "URL",
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12), contentPadding: 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;
}, },
@@ -245,10 +245,7 @@ class _BodyState extends State<_Body> {
), ),
ListTile( ListTile(
title: Text("Help".tl), title: Text("Help".tl),
trailing: buildButton( trailing: buildButton(onPressed: help, child: Text("Open".tl)),
onPressed: help,
child: Text("Open".tl),
),
), ),
ListTile( ListTile(
title: Text("Check updates".tl), title: Text("Check updates".tl),
@@ -277,7 +274,8 @@ class _BodyState extends State<_Body> {
void help() { void help() {
launchUrlString( launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md"); "https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
);
} }
Future<void> handleAddSource(String url) async { Future<void> handleAddSource(String url) async {
@@ -288,11 +286,16 @@ class _BodyState extends State<_Body> {
splits.removeWhere((element) => element == ""); splits.removeWhere((element) => element == "");
var fileName = splits.last; var fileName = splits.last;
bool cancel = false; bool cancel = false;
var controller = showLoadingDialog(App.rootContext, var controller = showLoadingDialog(
onCancel: () => cancel = true, barrierDismissible: false); App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
try { try {
var res = await AppDio() var res = await AppDio().get<String>(
.get<String>(url, options: Options(responseType: ResponseType.plain)); url,
options: Options(responseType: ResponseType.plain),
);
if (cancel) return; if (cancel) return;
controller.close(); controller.close();
await addSource(res.data!, fileName); await addSource(res.data!, fileName);
@@ -322,95 +325,178 @@ class _ComicSourceList extends StatefulWidget {
} }
class _ComicSourceListState extends State<_ComicSourceList> { class _ComicSourceListState extends State<_ComicSourceList> {
bool loading = true;
List? json; List? json;
bool changed = false;
var controller = TextEditingController();
void load() async { void load() async {
var dio = AppDio(); if (json != null) {
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']); setState(() {
if (res.statusCode != 200) { json = null;
context.showMessage(message: "Network error".tl); });
}
if (controller.text.isEmpty) {
setState(() {
json = [];
});
return; return;
} }
setState(() { var dio = AppDio();
json = jsonDecode(res.data!); try {
loading = false; var res = await dio.get<String>(controller.text);
}); if (res.statusCode != 200) {
throw "error";
}
if (mounted) {
setState(() {
json = jsonDecode(res.data!);
});
}
} catch (e) {
context.showMessage(message: "Network error".tl);
if (mounted) {
setState(() {
json = [];
});
}
}
}
@override
void initState() {
super.initState();
controller.text = appdata.settings['comicSourceListUrl'];
load();
}
@override
void dispose() {
super.dispose();
if (changed) {
appdata.settings['comicSourceListUrl'] = controller.text;
appdata.saveData();
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopUpWidgetScaffold( return PopUpWidgetScaffold(title: "Comic Source".tl, body: buildBody());
title: "Comic Source".tl,
tailing: [
IconButton(
icon: Icon(Icons.settings),
onPressed: () async {
await showInputDialog(
context: context,
title: "Set comic source list url".tl,
initialValue: appdata.settings['comicSourceListUrl'],
onConfirm: (value) {
appdata.settings['comicSourceListUrl'] = value;
appdata.saveData();
setState(() {
loading = true;
json = null;
});
return null;
},
);
},
)
],
body: buildBody(),
);
} }
Widget buildBody() { Widget buildBody() {
if (loading) { var currentKey = ComicSource.all().map((e) => e.key).toList();
load();
return const Center(child: CircularProgressIndicator());
} else {
var currentKey = ComicSource.all().map((e) => e.key).toList();
return ListView.builder(
itemCount: json!.length,
itemBuilder: (context, index) {
var key = json![index]["key"];
var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8)
: Button.filled(
child: Text("Add".tl),
onPressed: () async {
var fileName = json![index]["fileName"];
var url = json![index]["url"];
if (url == null || !(url.toString()).isURL) {
var listUrl =
appdata.settings['comicSourceListUrl'] as String;
if (listUrl
.replaceFirst("https://", "")
.replaceFirst("http://", "")
.contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
},
).fixHeight(32);
return ListTile( return ListView.builder(
title: Text(json![index]["name"]), itemCount: (json?.length ?? 1) + 1,
subtitle: Text(json![index]["version"]), itemBuilder: (context, index) {
trailing: action, if (index == 0) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.source_outlined),
title: Text("Repo URL".tl),
),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
),
onChanged: (value) {
changed = true;
},
).paddingHorizontal(16).paddingBottom(8),
Text(
"The URL should point to a 'index.json' file".tl,
).paddingLeft(16),
Text(
"Do not report any issues related to sources to App repo.".tl,
).paddingLeft(16),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
);
},
child: Text("Help".tl),
),
FilledButton.tonal(
onPressed: load,
child: Text("Refresh".tl),
),
const SizedBox(width: 16),
],
),
const SizedBox(height: 16),
],
),
); );
}, }
);
} if (index == 1 && json == null) {
return Center(
child: CircularProgressIndicator(
strokeWidth: 2,
).fixWidth(24).fixHeight(24),
);
}
index--;
var key = json![index]["key"];
var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8)
: Button.filled(
child: Text("Add".tl),
onPressed: () async {
var fileName = json![index]["fileName"];
var url = json![index]["url"];
if (url == null || !(url.toString()).isURL) {
var listUrl =
appdata.settings['comicSourceListUrl'] as String;
if (listUrl
.replaceFirst("https://", "")
.replaceFirst("http://", "")
.contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
},
).fixHeight(32);
var description = json![index]["version"];
if (json![index]["description"] != null) {
description = "$description\n${json![index]["description"]}";
}
return ListTile(
title: Text(json![index]["name"]),
subtitle: Text(description),
trailing: action,
);
},
);
} }
} }
@@ -461,6 +547,7 @@ void _addAllPagesWithComicSource(ComicSource source) {
var explorePages = appdata.settings['explore_pages']; var explorePages = appdata.settings['explore_pages'];
var categoryPages = appdata.settings['categories']; var categoryPages = appdata.settings['categories'];
var networkFavorites = appdata.settings['favorites']; var networkFavorites = appdata.settings['favorites'];
var searchPages = appdata.settings['searchSources'];
if (source.explorePages.isNotEmpty) { if (source.explorePages.isNotEmpty) {
for (var page in source.explorePages) { for (var page in source.explorePages) {
@@ -477,10 +564,14 @@ void _addAllPagesWithComicSource(ComicSource source) {
!networkFavorites.contains(source.favoriteData!.key)) { !networkFavorites.contains(source.favoriteData!.key)) {
networkFavorites.add(source.favoriteData!.key); networkFavorites.add(source.favoriteData!.key);
} }
if (source.searchPageData != null && !searchPages.contains(source.key)) {
searchPages.add(source.key);
}
appdata.settings['explore_pages'] = explorePages.toSet().toList(); appdata.settings['explore_pages'] = explorePages.toSet().toList();
appdata.settings['categories'] = categoryPages.toSet().toList(); appdata.settings['categories'] = categoryPages.toSet().toList();
appdata.settings['favorites'] = networkFavorites.toSet().toList(); appdata.settings['favorites'] = networkFavorites.toSet().toList();
appdata.settings['searchSources'] = searchPages.toSet().toList();
appdata.saveData(); appdata.saveData();
} }
@@ -515,15 +606,10 @@ class __EditFilePageState extends State<_EditFilePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: Appbar( appBar: Appbar(title: Text("Edit".tl)),
title: Text("Edit".tl),
),
body: Column( body: Column(
children: [ children: [
Container( Container(height: 0.6, color: context.colorScheme.outlineVariant),
height: 0.6,
color: context.colorScheme.outlineVariant,
),
Expanded( Expanded(
child: CodeEditor( child: CodeEditor(
initialValue: current, initialValue: current,
@@ -564,9 +650,11 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
} }
void showUpdateDialog() async { void showUpdateDialog() async {
var text = ComicSourceManager().availableUpdates.entries.map((e) { var text = ComicSourceManager().availableUpdates.entries
return "${ComicSource.find(e.key)!.name}: ${e.value}"; .map((e) {
}).join("\n"); return "${ComicSource.find(e.key)!.name}: ${e.value}";
})
.join("\n");
bool doUpdate = false; bool doUpdate = false;
await showDialog( await showDialog(
context: App.rootContext, context: App.rootContext,
@@ -704,10 +792,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
child: ListTile( child: ListTile(
title: Row( title: Row(
children: [ children: [
Text( Text(source.name, style: ts.s18),
source.name,
style: ts.s18,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -740,7 +825,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
), ),
).paddingLeft(4) ).paddingLeft(4),
], ],
), ),
trailing: Row( trailing: Row(
@@ -785,15 +870,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(children: buildSourceSettings().toList()),
children: buildSourceSettings().toList(),
),
),
SliverToBoxAdapter(
child: Column(
children: _buildAccount().toList(),
),
), ),
SliverToBoxAdapter(child: Column(children: _buildAccount().toList())),
], ],
); );
} }
@@ -819,8 +898,10 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
} }
} }
} else { } else {
current = item.value['options'] current =
.firstWhere((e) => e['value'] == current)['text'] ?? item.value['options'].firstWhere(
(e) => e['value'] == current,
)['text'] ??
current; current;
} }
yield ListTile( yield ListTile(
@@ -828,8 +909,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
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>((e) => .map<String>(
((e['text'] ?? e['value']) as String).ts(source.key)) (e) => ((e['text'] ?? e['value']) as String).ts(source.key),
)
.toList(), .toList(),
onTap: (i) { onTap: (i) {
source.data['settings'][key] = source.data['settings'][key] =
@@ -857,8 +939,11 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
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: subtitle: Text(
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis), current,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.edit), icon: const Icon(Icons.edit),
onPressed: () { onPressed: () {
@@ -899,10 +984,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () async { onTap: () async {
await context.to( await context.to(
() => _LoginPage( () => _LoginPage(config: source.account!, source: source),
config: source.account!,
source: source,
),
); );
source.saveData(); source.saveData();
setState(() {}); setState(() {});
@@ -948,9 +1030,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: loading trailing: loading
? const SizedBox.square( ? const SizedBox.square(
dimension: 24, dimension: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(strokeWidth: 2),
strokeWidth: 2,
),
) )
: const Icon(Icons.refresh), : const Icon(Icons.refresh),
); );
@@ -991,9 +1071,7 @@ class _LoginPageState extends State<_LoginPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: const Appbar( appBar: const Appbar(title: Text('')),
title: Text(''),
),
body: Center( body: Center(
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -1121,8 +1199,9 @@ class _LoginPageState extends State<_LoginPage> {
setState(() { setState(() {
loading = true; loading = true;
}); });
var cookies = var cookies = widget.config.cookieFields!
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList(); .map((e) => _cookies[e] ?? '')
.toList();
widget.config.validateCookies!(cookies).then((value) { widget.config.validateCookies!(cookies).then((value) {
if (value) { if (value) {
widget.source.data['account'] = 'ok'; widget.source.data['account'] = 'ok';

View File

@@ -6,13 +6,10 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/global_state.dart'; import 'package:venera/foundation/global_state.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'category_comics_page.dart';
class ExplorePage extends StatefulWidget { class ExplorePage extends StatefulWidget {
const ExplorePage({super.key}); const ExplorePage({super.key});
@@ -445,30 +442,7 @@ Iterable<Widget> _buildExplorePagePart(
TextButton( TextButton(
onPressed: () { onPressed: () {
var context = App.mainNavigatorKey!.currentContext!; var context = App.mainNavigatorKey!.currentContext!;
if (part.viewMore!.startsWith("search:")) { part.viewMore!.jump(context);
context.to(
() => SearchResultPage(
text: part.viewMore!.replaceFirst("search:", ""),
options: const [],
sourceKey: sourceKey,
),
);
} else if (part.viewMore!.startsWith("category:")) {
var cp = part.viewMore!.replaceFirst("category:", "");
var c = cp.split('@').first;
String? p = cp.split('@').last;
if (p == c) {
p = null;
}
context.to(
() => CategoryComicsPage(
category: c,
categoryKey:
ComicSource.find(sourceKey)!.categoryData!.key,
param: p,
),
);
}
}, },
child: Text("View more".tl), child: Text("View more".tl),
) )

View File

@@ -133,7 +133,7 @@ void addFavorite(List<Comic> comics) {
} }
Future<List<FavoriteItem>> updateComicsInfo(String folder) async { Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var comics = LocalFavoritesManager().getAllComics(folder); var comics = LocalFavoritesManager().getFolderComics(folder);
Future<void> updateSingleComic(int index) async { Future<void> updateSingleComic(int index) async {
int retry = 3; int retry = 3;

View File

@@ -18,14 +18,15 @@ import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
part 'favorite_actions.dart'; part 'favorite_actions.dart';
part 'side_bar.dart'; part 'side_bar.dart';
part 'local_favorites_page.dart'; part 'local_favorites_page.dart';
part 'network_favorites_page.dart'; part 'network_favorites_page.dart';
part 'local_search_page.dart';
const _kLeftBarWidth = 256.0; const _kLeftBarWidth = 256.0;
@@ -65,6 +66,11 @@ class _FavoritesPageState extends State<FavoritesPage> {
folder = data['name']; folder = data['name'];
isNetwork = data['isNetwork'] ?? false; isNetwork = data['isNetwork'] ?? false;
} }
if (folder != null
&& !isNetwork
&& !LocalFavoritesManager().existsFolder(folder!)) {
folder = null;
}
super.initState(); super.initState();
} }

View File

@@ -1,5 +1,11 @@
part of 'favorites_page.dart'; part of 'favorites_page.dart';
const _localAllFolderLabel = '^_^[%local_all%]^_^';
/// If the number of comics in a folder exceeds this limit, it will be
/// fetched asynchronously.
const _asyncDataFetchLimit = 500;
class _LocalFavoritesPage extends StatefulWidget { class _LocalFavoritesPage extends StatefulWidget {
const _LocalFavoritesPage({required this.folder, super.key}); const _LocalFavoritesPage({required this.folder, super.key});
@@ -31,25 +37,112 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
int? lastSelectedIndex; int? lastSelectedIndex;
bool get isAllFolder => widget.folder == _localAllFolderLabel;
LocalFavoritesManager get manager => LocalFavoritesManager();
bool isLoading = false;
var searchResults = <FavoriteItem>[];
void updateSearchResult() {
setState(() {
if (keyword.trim().isEmpty) {
searchResults = comics;
} else {
searchResults = [];
for (var comic in comics) {
if (matchKeyword(keyword, comic)) {
searchResults.add(comic);
}
}
}
});
}
void updateComics() { void updateComics() {
if (keyword.isEmpty) { if (isLoading) return;
setState(() { if (isAllFolder) {
comics = LocalFavoritesManager().getAllComics(widget.folder); var totalComics = manager.totalComics;
}); if (totalComics < _asyncDataFetchLimit) {
comics = manager.getAllComics();
} else {
isLoading = true;
manager
.getAllComicsAsync()
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
} else { } else {
setState(() { var folderComics = manager.folderComics(widget.folder);
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword); if (folderComics < _asyncDataFetchLimit) {
}); comics = manager.getFolderComics(widget.folder);
} else {
isLoading = true;
manager
.getFolderComicsAsync(widget.folder)
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
} }
setState(() {});
}
bool matchKeyword(String keyword, FavoriteItem comic) {
var list = keyword.split(" ");
for (var k in list) {
if (k.isEmpty) continue;
if (comic.title.contains(k)) {
continue;
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) {
continue;
} else if (comic.tags.any((tag) {
if (tag == k) {
return true;
} else if (tag.contains(':') && tag.split(':')[1] == k) {
return true;
} else if (App.locale.languageCode != 'en' &&
tag.translateTagsToCN == k) {
return true;
}
return false;
})) {
continue;
} else if (comic.author == k) {
continue;
}
return false;
}
return true;
} }
@override @override
void initState() { void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
comics = LocalFavoritesManager().getAllComics(widget.folder); if (!isAllFolder) {
var (a, b) = LocalFavoritesManager().findLinked(widget.folder); var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a; networkSource = a;
networkFolder = b; networkFolder = b;
} else {
networkSource = null;
networkFolder = null;
}
comics = [];
updateComics();
LocalFavoritesManager().addListener(updateComics); LocalFavoritesManager().addListener(updateComics);
super.initState(); super.initState();
} }
@@ -62,16 +155,33 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
void selectAll() { void selectAll() {
setState(() { setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); if (searchMode) {
selectedComics = searchResults.asMap().map((k, v) => MapEntry(v, true));
} else {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
}
}); });
} }
void invertSelection() { void invertSelection() {
setState(() { setState(() {
comics.asMap().forEach((k, v) { if (searchMode) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false); for (var c in searchResults) {
}); if (selectedComics.containsKey(c)) {
selectedComics.removeWhere((k, v) => !v); selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
} else {
for (var c in comics) {
if (selectedComics.containsKey(c)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
}
}); });
} }
@@ -113,6 +223,11 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var title = favPage.folder ?? "Unselected".tl;
if (title == _localAllFolderLabel) {
title = "All".tl;
}
Widget body = SmoothCustomScrollView( Widget body = SmoothCustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
@@ -135,10 +250,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
onTap: context.width < _kTwoPanelChangeWidth onTap: context.width < _kTwoPanelChangeWidth
? favPage.showFolderSelector ? favPage.showFolderSelector
: null, : null,
child: Text(favPage.folder ?? "Unselected".tl), child: Text(title),
), ),
actions: [ actions: [
if (networkSource != null) if (networkSource != null && !isAllFolder)
Tooltip( Tooltip(
message: "Sync".tl, message: "Sync".tl,
child: Flyout( child: Flyout(
@@ -191,14 +306,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
setState(() { setState(() {
keyword = "";
searchMode = true; searchMode = true;
updateSearchResult();
}); });
}, },
), ),
), ),
MenuButton( if (!isAllFolder)
entries: [ MenuButton(
MenuEntry( entries: [
MenuEntry(
icon: Icons.edit_outlined, icon: Icons.edit_outlined,
text: "Rename".tl, text: "Rename".tl,
onClick: () { onClick: () {
@@ -220,8 +338,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return null; return null;
}, },
); );
}), },
MenuEntry( ),
MenuEntry(
icon: Icons.reorder, icon: Icons.reorder,
text: "Reorder".tl, text: "Reorder".tl,
onClick: () { onClick: () {
@@ -241,8 +360,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} }
}, },
); );
}), },
MenuEntry( ),
MenuEntry(
icon: Icons.upload_file, icon: Icons.upload_file,
text: "Export".tl, text: "Export".tl,
onClick: () { onClick: () {
@@ -253,8 +373,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
data: utf8.encode(json), data: utf8.encode(json),
filename: "${widget.folder}.json", filename: "${widget.folder}.json",
); );
}), },
MenuEntry( ),
MenuEntry(
icon: Icons.update, icon: Icons.update,
text: "Update Comics Info".tl, text: "Update Comics Info".tl,
onClick: () { onClick: () {
@@ -265,8 +386,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}); });
} }
}); });
}), },
MenuEntry( ),
MenuEntry(
icon: Icons.delete_outline, icon: Icons.delete_outline,
text: "Delete Folder".tl, text: "Delete Folder".tl,
color: context.colorScheme.error, color: context.colorScheme.error,
@@ -284,9 +406,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
favPage.folderList?.updateFolders(); favPage.folderList?.updateFolders();
}, },
); );
}), },
], ),
), ],
),
], ],
) )
else if (multiSelectMode) else if (multiSelectMode)
@@ -310,10 +433,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
"Selected @c comics".tlParams({"c": selectedComics.length})), "Selected @c comics".tlParams({"c": selectedComics.length})),
actions: [ actions: [
MenuButton(entries: [ MenuButton(entries: [
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.drive_file_move, icon: Icons.drive_file_move,
text: "Move to folder".tl, text: "Move to folder".tl,
onClick: () => favoriteOption('move')), onClick: () => favoriteOption('move')),
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.copy, icon: Icons.copy,
text: "Copy to folder".tl, text: "Copy to folder".tl,
@@ -330,22 +455,23 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: Icons.flip, icon: Icons.flip,
text: "Invert Selection".tl, text: "Invert Selection".tl,
onClick: invertSelection), onClick: invertSelection),
MenuEntry( if (!isAllFolder)
icon: Icons.delete_outline, MenuEntry(
text: "Delete Comic".tl, icon: Icons.delete_outline,
color: context.colorScheme.error, text: "Delete Comic".tl,
onClick: () { color: context.colorScheme.error,
showConfirmDialog( onClick: () {
context: context, showConfirmDialog(
title: "Delete".tl, context: context,
content: "Delete @c comics?" title: "Delete".tl,
.tlParams({"c": selectedComics.length}), content: "Delete @c comics?"
btnColor: context.colorScheme.error, .tlParams({"c": selectedComics.length}),
onConfirm: () { btnColor: context.colorScheme.error,
_deleteComicWithId(); onConfirm: () {
}, _deleteComicWithId();
); },
}), );
}),
MenuEntry( MenuEntry(
icon: Icons.download, icon: Icons.download,
text: "Download".tl, text: "Download".tl,
@@ -380,9 +506,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () { onPressed: () {
setState(() { setState(() {
searchMode = false; setState(() {
keyword = ""; searchMode = false;
updateComics(); });
}); });
}, },
), ),
@@ -391,131 +517,142 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Search".tl, hintText: "Search".tl,
border: InputBorder.none, border: UnderlineInputBorder(),
), ),
onChanged: (v) { onChanged: (v) {
keyword = v; keyword = v;
updateComics(); updateSearchResult();
}, },
), ).paddingBottom(8).paddingRight(8),
), ),
SliverGridComics( if (isLoading)
comics: comics, SliverToBoxAdapter(
selections: selectedComics, child: SizedBox(
menuBuilder: (c) { height: 200,
return [ child: const Center(
MenuEntry( child: CircularProgressIndicator(),
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
), ),
MenuEntry( ),
icon: Icons.check, )
text: "Select".tl, else
onClick: () { SliverGridComics(
setState(() { comics: searchMode ? searchResults : comics,
if (!multiSelectMode) { selections: selectedComics,
multiSelectMode = true; menuBuilder: (c) {
} return [
if (selectedComics.containsKey(c as FavoriteItem)) { if (!isAllFolder)
selectedComics.remove(c); MenuEntry(
_checkExitSelectMode(); icon: Icons.delete,
} else { text: "Delete".tl,
selectedComics[c] = true; onClick: () {
} LocalFavoritesManager().deleteComicWithId(
lastSelectedIndex = comics.indexOf(c); widget.folder,
}); c.id,
}, (c as FavoriteItem).type,
), );
MenuEntry( },
icon: Icons.download, ),
text: "Download".tl,
onClick: () {
downloadComic(c as FavoriteItem);
context.showMessage(
message: "Download started".tl,
);
},
),
if (appdata.settings["onClickFavorite"] == "viewDetail")
MenuEntry( MenuEntry(
icon: Icons.menu_book_outlined, icon: Icons.check,
text: "Read".tl, text: "Select".tl,
onClick: () { onClick: () {
App.mainNavigatorKey?.currentContext?.to( setState(() {
() => ReaderWithLoading( if (!multiSelectMode) {
id: c.id, multiSelectMode = true;
sourceKey: c.sourceKey, }
), if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
onClick: () {
downloadComic(c as FavoriteItem);
context.showMessage(
message: "Download started".tl,
); );
}, },
), ),
]; if (appdata.settings["onClickFavorite"] == "viewDetail")
}, MenuEntry(
onTap: (c) { icon: Icons.menu_book_outlined,
if (multiSelectMode) { text: "Read".tl,
setState(() { onClick: () {
if (selectedComics.containsKey(c as FavoriteItem)) { App.mainNavigatorKey?.currentContext?.to(
selectedComics.remove(c); () => ReaderWithLoading(
_checkExitSelectMode(); id: c.id,
} else { sourceKey: c.sourceKey,
selectedComics[c] = true; ),
} );
lastSelectedIndex = comics.indexOf(c); },
}); ),
} else if (appdata.settings["onClickFavorite"] == "viewDetail") { ];
App.mainNavigatorKey?.currentContext },
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey)); onTap: (c) {
} else { if (multiSelectMode) {
App.mainNavigatorKey?.currentContext?.to( setState(() {
() => ReaderWithLoading( if (selectedComics.containsKey(c as FavoriteItem)) {
id: c.id, selectedComics.remove(c);
sourceKey: c.sourceKey, _checkExitSelectMode();
), } else {
); selectedComics[c] = true;
}
},
onLongPressed: (c) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
} else {
if (lastSelectedIndex != null) {
int start = lastSelectedIndex!;
int end = comics.indexOf(c as FavoriteItem);
if (start > end) {
int temp = start;
start = end;
end = temp;
} }
lastSelectedIndex = comics.indexOf(c);
});
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
} else {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
}
},
onLongPressed: (c) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
} else {
if (lastSelectedIndex != null) {
int start = lastSelectedIndex!;
int end = comics.indexOf(c as FavoriteItem);
if (start > end) {
int temp = start;
start = end;
end = temp;
}
for (int i = start; i <= end; i++) { for (int i = start; i <= end; i++) {
if (i == lastSelectedIndex) continue; if (i == lastSelectedIndex) continue;
var comic = comics[i]; var comic = comics[i];
if (selectedComics.containsKey(comic)) { if (selectedComics.containsKey(comic)) {
selectedComics.remove(comic); selectedComics.remove(comic);
} else { } else {
selectedComics[comic] = true; selectedComics[comic] = true;
}
} }
} }
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
} }
lastSelectedIndex = comics.indexOf(c as FavoriteItem); _checkExitSelectMode();
} });
_checkExitSelectMode(); },
}); ),
},
),
], ],
); );
body = AppScrollBar( body = AppScrollBar(
@@ -638,32 +775,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return; return;
} }
if (option == 'move') { if (option == 'move') {
for (var c in selectedComics.keys) { var comics = selectedComics.keys
for (var s in selectedLocalFolders) { .map((e) => e as FavoriteItem)
LocalFavoritesManager().moveFavorite( .toList();
favPage.folder as String, for (var f in selectedLocalFolders) {
s, LocalFavoritesManager().batchMoveFavorites(
c.id, favPage.folder as String,
(c as FavoriteItem).type); f,
} comics,
);
} }
} else { } else {
for (var c in selectedComics.keys) { var comics = selectedComics.keys
for (var s in selectedLocalFolders) { .map((e) => e as FavoriteItem)
LocalFavoritesManager().addComic( .toList();
s, for (var f in selectedLocalFolders) {
FavoriteItem( LocalFavoritesManager().batchCopyFavorites(
id: c.id, favPage.folder as String,
name: c.title, f,
coverPath: c.cover, comics,
author: c.subtitle ?? '', );
type: ComicType((c.sourceKey == 'local'
? 0
: c.sourceKey.hashCode)),
tags: c.tags ?? [],
),
);
}
} }
} }
App.rootContext.pop(); App.rootContext.pop();
@@ -699,13 +830,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} }
void _deleteComicWithId() { void _deleteComicWithId() {
for (var c in selectedComics.keys) { var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList();
LocalFavoritesManager().deleteComicWithId( LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted);
widget.folder,
c.id,
(c as FavoriteItem).type,
);
}
_cancel(); _cancel();
} }
} }
@@ -725,7 +851,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
final _key = GlobalKey(); final _key = GlobalKey();
var reorderWidgetKey = UniqueKey(); var reorderWidgetKey = UniqueKey();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
late var comics = LocalFavoritesManager().getAllComics(widget.name); late var comics = LocalFavoritesManager().getFolderComics(widget.name);
bool changed = false; bool changed = false;
static int _floatToInt8(double x) { static int _floatToInt8(double x) {
@@ -746,7 +872,10 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
@override @override
void dispose() { void dispose() {
if (changed) { if (changed) {
LocalFavoritesManager().reorder(comics, widget.name); // Delay to ensure navigation is completed
Future.delayed(const Duration(milliseconds: 200), () {
LocalFavoritesManager().reorder(comics, widget.name);
});
} }
super.dispose(); super.dispose();
} }
@@ -781,27 +910,31 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
appBar: Appbar( appBar: Appbar(
title: Text("Reorder".tl), title: Text("Reorder".tl),
actions: [ actions: [
IconButton( Tooltip(
icon: const Icon(Icons.info_outline), message: "Information".tl,
onPressed: () { child: IconButton(
showInfoDialog( icon: const Icon(Icons.info_outline),
context: context, onPressed: () {
title: "Reorder".tl, showInfoDialog(
content: "Long press and drag to reorder.".tl, context: context,
); title: "Reorder".tl,
}, content: "Long press and drag to reorder.".tl,
), );
IconButton( },
icon: const Icon(Icons.swap_vert), ),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
});
},
), ),
Tooltip(
message: "Reverse".tl,
child: IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
});
},
),
)
], ],
), ),
body: ReorderableBuilder<FavoriteItem>( body: ReorderableBuilder<FavoriteItem>(

View File

@@ -1,41 +0,0 @@
part of 'favorites_page.dart';
class LocalSearchPage extends StatefulWidget {
const LocalSearchPage({super.key});
@override
State<LocalSearchPage> createState() => _LocalSearchPageState();
}
class _LocalSearchPageState extends State<LocalSearchPage> {
String keyword = '';
var comics = <FavoriteItemWithFolderInfo>[];
late final SearchBarController controller;
@override
void initState() {
super.initState();
controller = SearchBarController(onSearch: (text) {
keyword = text;
comics = LocalFavoritesManager().search(keyword);
setState(() {});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(slivers: [
SliverSearchBar(controller: controller),
SliverGridComics(
comics: comics,
badgeBuilder: (c) {
return (c as FavoriteItemWithFolderInfo).folder;
},
),
]),
);
}
}

View File

@@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
findNetworkFolders(); findNetworkFolders();
appdata.settings.addListener(updateFolders); appdata.settings.addListener(updateFolders);
LocalFavoritesManager().addListener(updateFolders);
super.initState(); super.initState();
} }
@@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
void dispose() { void dispose() {
super.dispose(); super.dispose();
appdata.settings.removeListener(updateFolders); appdata.settings.removeListener(updateFolders);
LocalFavoritesManager().removeListener(updateFolders);
} }
@override @override
@@ -86,58 +88,14 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
padding: widget.withAppbar padding: widget.withAppbar
? EdgeInsets.zero ? EdgeInsets.zero
: EdgeInsets.only(top: context.padding.top), : EdgeInsets.only(top: context.padding.top),
itemCount: folders.length + networkFolders.length + 2, itemCount: folders.length + networkFolders.length + 3,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) { if (index == 0) {
return Container( return buildLocalTitle();
padding: const EdgeInsets.symmetric(vertical: 8), }
child: Row( index--;
children: [ if (index == 0) {
Icon( return buildLocalFolder(_localAllFolderLabel);
Icons.local_activity,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.search,
text: 'Search'.tl,
onClick: () {
context.to(() => const LocalSearchPage());
},
),
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders =
LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders =
LocalFavoritesManager().folderNames;
});
});
},
),
],
),
],
).paddingHorizontal(16),
);
} }
index--; index--;
if (index < folders.length) { if (index < folders.length) {
@@ -145,38 +103,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
} }
index -= folders.length; index -= folders.length;
if (index == 0) { if (index == 0) {
return Container( return buildNetworkTitle();
padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
).paddingHorizontal(16),
);
} }
index--; index--;
return buildNetworkFolder(networkFolders[index]); return buildNetworkFolder(networkFolders[index]);
@@ -188,8 +115,95 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
); );
} }
Widget buildLocalTitle() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
Icons.local_activity,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
],
),
],
).paddingHorizontal(16),
);
}
Widget buildNetworkTitle() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
).paddingHorizontal(16),
);
}
Widget buildLocalFolder(String name) { Widget buildLocalFolder(String name) {
bool isSelected = name == favPage.folder && !favPage.isNetwork; bool isSelected = name == favPage.folder && !favPage.isNetwork;
int count = 0;
if (name == _localAllFolderLabel) {
count = LocalFavoritesManager().totalComics;
} else {
count = LocalFavoritesManager().folderComics(name);
}
var folderName = name == _localAllFolderLabel
? "All".tl
: getFavoriteDataOrNull(name)?.title ?? name;
return InkWell( return InkWell(
onTap: () { onTap: () {
if (isSelected) { if (isSelected) {
@@ -214,7 +228,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
), ),
), ),
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: Text(name), child: Row(
children: [
Expanded(
child: Text(folderName),
),
Container(
margin: EdgeInsets.only(right: 8),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(count.toString()),
),
],
),
), ),
); );
} }

View File

@@ -140,6 +140,14 @@ class _HistoryPageState extends State<HistoryPage> {
title: 'Clear History'.tl, title: 'Clear History'.tl,
content: Text('Are you sure you want to clear your history?'.tl), content: Text('Are you sure you want to clear your history?'.tl),
actions: [ actions: [
Button.outlined(
onPressed: () {
HistoryManager().clearUnfavoritedHistory();
context.pop();
},
child: Text('Clear Unfavorited'.tl),
),
const SizedBox(width: 4),
Button.filled( Button.filled(
color: context.colorScheme.error, color: context.colorScheme.error,
onPressed: () { onPressed: () {

View File

@@ -52,7 +52,7 @@ class _SearchBar extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Container( child: Container(
height: 52, height: App.isMobile ? 52 : 46,
width: double.infinity, width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Material( child: Material(
@@ -942,7 +942,7 @@ class _ImageFavoritesState extends State<ImageFavorites> {
displayType = type; displayType = type;
}); });
await Future.delayed(const Duration(milliseconds: 20)); await Future.delayed(const Duration(milliseconds: 20));
var scrollController = ScrollControllerProvider.of(context); var scrollController = ScrollState.of(context).controller;
scrollController.animateTo( scrollController.animateTo(
scrollController.position.maxScrollExtent, scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),

View File

@@ -306,7 +306,8 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
}); });
} else { } else {
// prevent dirty data // prevent dirty data
var comic = LocalManager().find(c.id, ComicType(c.sourceKey.hashCode))!; var comic =
LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
comic.read(); comic.read();
} }
}, },
@@ -360,28 +361,49 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {
bool removeComicFile = true; bool removeComicFile = true;
bool removeFavoriteAndHistory = true;
return StatefulBuilder(builder: (context, state) { return StatefulBuilder(builder: (context, state) {
return ContentDialog( return ContentDialog(
title: "Delete".tl, title: "Delete".tl,
content: CheckboxListTile( content: Column(
title: Text("Also remove files on disk".tl), children: [
value: removeComicFile, CheckboxListTile(
onChanged: (v) { title: Text("Remove local favorite and history".tl),
state(() { value: removeFavoriteAndHistory,
removeComicFile = !removeComicFile; onChanged: (v) {
}); state(() {
}, removeFavoriteAndHistory = !removeFavoriteAndHistory;
});
},
),
CheckboxListTile(
title: Text("Also remove files on disk".tl),
value: removeComicFile,
onChanged: (v) {
state(() {
removeComicFile = !removeComicFile;
});
},
)
],
), ),
actions: [ actions: [
if (comics.length == 1 && comics.first.hasChapters)
TextButton(
child: Text("Delete Chapters".tl),
onPressed: () {
context.pop();
showDeleteChaptersPopWindow(context, comics.first);
},
),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
context.pop(); context.pop();
for (var comic in comics) { LocalManager().batchDeleteComics(
LocalManager().deleteComic( comics,
comic, removeComicFile,
removeComicFile, removeFavoriteAndHistory,
); );
}
isDeleted = true; isDeleted = true;
}, },
child: Text("Confirm".tl), child: Text("Confirm".tl),
@@ -444,7 +466,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
var fileName = ""; var fileName = "";
// For each comic, export it to a file // For each comic, export it to a file
for (var comic in comics) { for (var comic in comics) {
fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext); fileName = FilePath.join(
cacheDir,
sanitizeFileName(comic.title, maxLength: 100) + ext,
);
await export(comic, fileName); await export(comic, fileName);
current++; current++;
if (comics.length > 1) { if (comics.length > 1) {
@@ -493,3 +518,59 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
typedef ExportComicFunc = Future<File> Function( typedef ExportComicFunc = Future<File> Function(
LocalComic comic, String outFilePath); LocalComic comic, String outFilePath);
void showDeleteChaptersPopWindow(BuildContext context, LocalComic comic) {
var chapters = <String>[];
showPopUpWidget(
context,
PopUpWidgetScaffold(
title: "Delete Chapters".tl,
body: StatefulBuilder(builder: (context, setState) {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: comic.downloadedChapters.length,
itemBuilder: (context, index) {
var id = comic.downloadedChapters[index];
var chapter = comic.chapters![id] ?? "Unknown Chapter";
return CheckboxListTile(
title: Text(chapter),
value: chapters.contains(id),
onChanged: (v) {
setState(() {
if (v == true) {
chapters.add(id);
} else {
chapters.remove(id);
}
});
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton(
onPressed: () {
Future.delayed(const Duration(milliseconds: 200), () {
LocalManager().deleteComicChapters(comic, chapters);
});
App.rootContext.pop();
},
child: Text("Submit".tl),
)
],
),
)
],
);
}),
),
);
}

View File

@@ -24,6 +24,8 @@ class ComicImage extends StatefulWidget {
Map<String, String>? headers, Map<String, String>? headers,
int? cacheWidth, int? cacheWidth,
int? cacheHeight, int? cacheHeight,
this.onInit,
this.onDispose,
}) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0), assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0); assert(cacheHeight == null || cacheHeight > 0);
@@ -60,6 +62,10 @@ class ComicImage extends StatefulWidget {
final bool isAntiAlias; final bool isAntiAlias;
final void Function(State<ComicImage> state)? onInit;
final void Function(State<ComicImage> state)? onDispose;
static void clear() => _ComicImageState.clear(); static void clear() => _ComicImageState.clear();
@override @override
@@ -87,6 +93,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this); _scrollAwareContext = DisposableBuildContext<State<ComicImage>>(this);
widget.onInit?.call(this);
} }
@override @override
@@ -97,6 +104,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
_completerHandle?.dispose(); _completerHandle?.dispose();
_scrollAwareContext.dispose(); _scrollAwareContext.dispose();
_replaceImage(info: null); _replaceImage(info: null);
widget.onDispose?.call(this);
super.dispose(); super.dispose();
} }
@@ -136,6 +144,15 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.reassemble(); super.reassemble();
} }
bool containsPoint(Offset point) {
if (!mounted) {
return false;
}
var renderBox = context.findRenderObject() as RenderBox;
var localPoint = renderBox.globalToLocal(point);
return renderBox.paintBounds.contains(localPoint);
}
void _updateInvertColors() { void _updateInvertColors() {
_invertColors = MediaQuery.maybeInvertColorsOf(context) ?? _invertColors = MediaQuery.maybeInvertColorsOf(context) ??
SemanticsBinding.instance.accessibilityFeatures.invertColors; SemanticsBinding.instance.accessibilityFeatures.invertColors;

View File

@@ -152,12 +152,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
bool _dragInProgress = false; bool _dragInProgress = false;
bool get _enableDoubleTapToZoom => appdata.settings["enableDoubleTapToZoom"];
void onTapUp(TapUpDetails event) { void onTapUp(TapUpDetails event) {
if (_longPressInProgress) { if (_longPressInProgress) {
_longPressInProgress = false; _longPressInProgress = false;
return; return;
} }
final location = event.globalPosition; final location = event.globalPosition;
if (!_enableDoubleTapToZoom) {
onTap(location);
return;
}
final previousLocation = _previousEvent?.globalPosition; final previousLocation = _previousEvent?.globalPosition;
if (previousLocation != null) { if (previousLocation != null) {
if ((location - previousLocation).distanceSquared < if ((location - previousLocation).distanceSquared <
@@ -281,6 +287,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
context.pop(); context.pop();
}, },
), ),
if (App.isDesktop && !reader.isLoading)
MenuEntry(
icon: Icons.copy,
text: "Copy Image".tl,
onClick: () => copyImage(location),
),
if (!reader.isLoading)
MenuEntry(
icon: Icons.download_outlined,
text: "Save Image".tl,
onClick: () => saveImage(location),
),
], ],
); );
} }
@@ -303,6 +321,27 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
@override @override
Object? get key => "reader_gesture"; Object? get key => "reader_gesture";
void copyImage(Offset location) async {
var controller = reader._imageViewController;
var image = await controller!.getImageByOffset(location);
if (image != null) {
writeImageToClipboard(image);
} else {
context.showMessage(message: "No Image");
}
}
void saveImage(Offset location) async {
var controller = reader._imageViewController;
var image = await controller!.getImageByOffset(location);
if (image != null) {
var filetype = detectFileType(image);
saveFile(filename: "image${filetype.ext}", data: image);
} else {
context.showMessage(message: "No Image");
}
}
} }
class _DragListener { class _DragListener {

View File

@@ -21,12 +21,18 @@ class _ReaderImagesState extends State<_ReaderImages> {
super.initState(); super.initState();
} }
@override
void dispose() {
super.dispose();
ImageDownloader.cancelAllLoadingImages();
}
void load() async { void load() async {
if (inProgress) return; if (inProgress) return;
inProgress = true; inProgress = true;
if (reader.type == ComicType.local || if (reader.type == ComicType.local ||
(LocalManager() (LocalManager().isDownloaded(
.isDownloaded(reader.cid, reader.type, reader.chapter, reader.widget.chapters))) { reader.cid, reader.type, reader.chapter, reader.widget.chapters))) {
try { try {
var images = await LocalManager() var images = await LocalManager()
.getImages(reader.cid, reader.type, reader.chapter); .getImages(reader.cid, reader.type, reader.chapter);
@@ -34,6 +40,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
reader.images = images; reader.images = images;
reader.isLoading = false; reader.isLoading = false;
inProgress = false; inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
}); });
} catch (e) { } catch (e) {
setState(() { setState(() {
@@ -43,9 +52,10 @@ class _ReaderImagesState extends State<_ReaderImages> {
}); });
} }
} else { } else {
var cp = reader.widget.chapters?.ids.elementAtOrNull(reader.chapter - 1);
var res = await reader.type.comicSource!.loadComicPages!( var res = await reader.type.comicSource!.loadComicPages!(
reader.widget.cid, reader.widget.cid,
reader.widget.chapters?.ids.elementAt(reader.chapter - 1), cp,
); );
if (res.error) { if (res.error) {
setState(() { setState(() {
@@ -58,6 +68,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
reader.images = res.data; reader.images = res.data;
reader.isLoading = false; reader.isLoading = false;
inProgress = false; inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
}); });
} }
} }
@@ -72,14 +85,21 @@ class _ReaderImagesState extends State<_ReaderImages> {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
} else if (error != null) { } else if (error != null) {
return NetworkError( return GestureDetector(
message: error!, onTap: () {
retry: () { context.readerScaffold.openOrClose();
setState(() {
reader.isLoading = true;
error = null;
});
}, },
child: SizedBox.expand(
child: NetworkError(
message: error!,
retry: () {
setState(() {
reader.isLoading = true;
error = null;
});
},
),
),
); );
} else { } else {
if (reader.mode.isGallery) { if (reader.mode.isGallery) {
@@ -103,143 +123,255 @@ class _GalleryModeState extends State<_GalleryMode>
implements _ImageViewController { implements _ImageViewController {
late PageController controller; late PageController controller;
late List<bool> cached;
int get preCacheCount => appdata.settings["preloadImageCount"]; int get preCacheCount => appdata.settings["preloadImageCount"];
var photoViewControllers = <int, PhotoViewController>{}; var photoViewControllers = <int, PhotoViewController>{};
late _ReaderState reader; late _ReaderState reader;
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil(); /// [totalPages] is the total number of pages in the current chapter.
/// More than one images can be displayed on one page.
int get totalPages {
if (!reader.showSingleImageOnFirstPage) {
return (reader.images!.length / reader.imagesPerPage).ceil();
} else {
return 1 +
((reader.images!.length - 1) / reader.imagesPerPage).ceil();
}
}
var imageStates = <State<ComicImage>>{};
bool isLongPressing = false;
int fingers = 0;
@override @override
void initState() { void initState() {
reader = context.reader; reader = context.reader;
controller = PageController(initialPage: reader.page); controller = PageController(initialPage: reader.page);
reader._imageViewController = this; reader._imageViewController = this;
cached = List.filled(reader.maxPage + 2, false);
Future.microtask(() { Future.microtask(() {
context.readerScaffold.setFloatingButton(0); context.readerScaffold.setFloatingButton(0);
}); });
super.initState(); super.initState();
} }
void cache(int current) { /// Get the range of images for the given page. [page] is 1-based.
for (int i = current + 1; i <= current + preCacheCount; i++) { (int start, int end) getPageImagesRange(int page) {
if (i <= totalPages && !cached[i]) { if (reader.showSingleImageOnFirstPage) {
int startIndex = (i - 1) * reader.imagesPerPage; if (page == 1) {
int endIndex = return (0, 1);
math.min(startIndex + reader.imagesPerPage, reader.images!.length); } else {
for (int i = startIndex; i < endIndex; i++) { int startIndex = (page - 2) * reader.imagesPerPage + 1;
precacheImage( int endIndex = math.min(
_createImageProviderFromKey(reader.images![i], context), context); startIndex + reader.imagesPerPage, reader.images!.length);
} return (startIndex, endIndex);
cached[i] = true; }
} else {
int startIndex = (page - 1) * reader.imagesPerPage;
int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length);
return (startIndex, endIndex);
}
}
/// [cache] is used to cache the images.
/// The count of images to cache is determined by the [preCacheCount] setting.
/// For previous page and next page, it will do a memory cache.
/// For current page, it will do nothing because it is already on the screen.
/// For other pages, it will do a pre-download cache.
void cache(int startPage) {
for (int i = startPage - 1; i <= startPage + preCacheCount; i++) {
if (i == startPage || i <= 0 || i > totalPages) continue;
bool shouldPreCache = i == startPage + 1 || i == startPage - 1;
_cachePage(i, shouldPreCache);
}
}
void _cachePage(int page, bool shouldPreCache) {
var (startIndex, endIndex) = getPageImagesRange(page);
for (int i = startIndex; i < endIndex; i++) {
if (shouldPreCache) {
_precacheImage(i+1, context);
} else {
_preDownloadImage(i+1, context);
} }
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PhotoViewGallery.builder( return Listener(
backgroundDecoration: BoxDecoration( onPointerDown: (event) {
color: context.colorScheme.surface, fingers++;
), },
reverse: reader.mode == ReaderMode.galleryRightToLeft, onPointerUp: (event) {
scrollDirection: reader.mode == ReaderMode.galleryTopToBottom fingers--;
? Axis.vertical },
: Axis.horizontal, onPointerCancel: (event) {
itemCount: totalPages + 2, fingers--;
builder: (BuildContext context, int index) { },
if (index == 0 || index == totalPages + 1) { onPointerMove: (event) {
return PhotoViewGalleryPageOptions.customChild( if (isLongPressing) {
child: const SizedBox(), var controller = photoViewControllers[reader.page]!;
); Offset value = event.delta;
} else { if (isLongPressing) {
int pageIndex = index - 1; controller.updateMultiple(
int startIndex = pageIndex * reader.imagesPerPage; position: controller.position + value,
int endIndex = math.min(
startIndex + reader.imagesPerPage, reader.images!.length);
List<String> pageImages =
reader.images!.sublist(startIndex, endIndex);
cached[index] = true;
cache(index);
photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage == 1) {
return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium,
controller: photoViewControllers[index],
imageProvider:
_createImageProviderFromKey(pageImages[0], context),
fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry);
},
); );
} }
return PhotoViewGalleryPageOptions.customChild(
controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
child: buildPageImages(pageImages),
);
} }
}, },
pageController: controller, child: PhotoViewGallery.builder(
loadingBuilder: (context, event) => Center( backgroundDecoration: BoxDecoration(
child: SizedBox( color: context.colorScheme.surface,
width: 20.0, ),
height: 20.0, reverse: reader.mode == ReaderMode.galleryRightToLeft,
child: CircularProgressIndicator( scrollDirection: reader.mode == ReaderMode.galleryTopToBottom
backgroundColor: context.colorScheme.surfaceContainerHigh, ? Axis.vertical
value: event == null || event.expectedTotalBytes == null : Axis.horizontal,
? null itemCount: totalPages + 2,
: event.cumulativeBytesLoaded / event.expectedTotalBytes!, builder: (BuildContext context, int index) {
if (index == 0 || index == totalPages + 1) {
return PhotoViewGalleryPageOptions.customChild(
child: const SizedBox(),
);
} else {
var (startIndex, endIndex) = getPageImagesRange(index);
List<String> pageImages =
reader.images!.sublist(startIndex, endIndex);
cache(index);
photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage == 1 || pageImages.length == 1) {
return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium,
controller: photoViewControllers[index],
imageProvider: _createImageProviderFromKey(
pageImages[0],
context,
startIndex + 1,
),
fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry);
},
);
}
return PhotoViewGalleryPageOptions.customChild(
childSize: reader.size * 2,
controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
child: buildPageImages(pageImages, startIndex),
);
}
},
pageController: controller,
loadingBuilder: (context, event) => Center(
child: SizedBox(
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
backgroundColor: context.colorScheme.surfaceContainerHigh,
value: event == null || event.expectedTotalBytes == null
? null
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
),
), ),
), ),
onPageChanged: (i) {
if (i == 0) {
if (reader.isFirstChapterOfGroup || !reader.toPrevChapter()) {
reader.toPage(1);
}
} else if (i == totalPages + 1) {
if (reader.isLastChapterOfGroup || !reader.toNextChapter()) {
reader.toPage(totalPages);
}
} else {
reader.setPage(i);
context.readerScaffold.update();
}
// Remove other pages' controllers to reset their state.
var keys = photoViewControllers.keys.toList();
for (var key in keys) {
if (key != i) {
photoViewControllers.remove(key);
}
}
},
), ),
onPageChanged: (i) {
if (i == 0) {
if (reader.isFirstChapterOfGroup || !reader.toPrevChapter()) {
reader.toPage(1);
}
} else if (i == totalPages + 1) {
if (reader.isLastChapterOfGroup || !reader.toNextChapter()) {
reader.toPage(totalPages);
}
} else {
reader.setPage(i);
context.readerScaffold.update();
}
},
); );
} }
Widget buildPageImages(List<String> images) { Widget buildPageImages(List<String> images, int startIndex) {
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom) Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
? Axis.vertical ? Axis.vertical
: Axis.horizontal; : Axis.horizontal;
bool reverse = reader.mode == ReaderMode.galleryRightToLeft; bool reverse = reader.mode == ReaderMode.galleryRightToLeft;
List<Widget> imageWidgets = images.map((imageKey) {
ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context);
return Expanded(
child: ComicImage(
image: imageProvider,
fit: BoxFit.contain,
),
);
}).toList();
if (reverse) { if (reverse) {
imageWidgets = imageWidgets.reversed.toList(); images = images.reversed.toList();
}
List<Widget> imageWidgets;
if (images.length == 2) {
imageWidgets = [
Expanded(
child: ComicImage(
width: double.infinity,
height: double.infinity,
image: _createImageProviderFromKey(
images[0],
context,
startIndex + 1,
),
fit: BoxFit.contain,
alignment: axis == Axis.vertical
? Alignment.bottomCenter
: Alignment.centerRight,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
),
),
Expanded(
child: ComicImage(
width: double.infinity,
height: double.infinity,
image: _createImageProviderFromKey(
images[1],
context,
startIndex + 2,
),
fit: BoxFit.contain,
alignment: axis == Axis.vertical
? Alignment.topCenter
: Alignment.centerLeft,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
),
)
];
} else {
imageWidgets = images.map((imageKey) {
startIndex++;
ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context, startIndex);
return Expanded(
child: ComicImage(
image: imageProvider,
fit: BoxFit.contain,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
),
);
}).toList();
} }
return axis == Axis.vertical return axis == Axis.vertical
@@ -276,28 +408,41 @@ class _GalleryModeState extends State<_GalleryMode>
@override @override
void handleLongPressDown(Offset location) { void handleLongPressDown(Offset location) {
if (!appdata.settings['enableLongPressToZoom']) { if (!appdata.settings['enableLongPressToZoom'] || fingers != 1) {
return; return;
} }
var photoViewController = photoViewControllers[reader.page]!; var photoViewController = photoViewControllers[reader.page]!;
double target = photoViewController.getInitialScale!.call()! * 1.75; double target = photoViewController.getInitialScale!.call()! * 1.75;
var size = MediaQuery.of(context).size; var size = reader.size;
Offset zoomPosition;
if (appdata.settings['longPressZoomPosition'] != 'center') {
zoomPosition = Offset(
size.width / 2 - location.dx,
size.height / 2 - location.dy,
);
} else {
zoomPosition = Offset(0, 0);
}
photoViewController.animateScale?.call( photoViewController.animateScale?.call(
target, target,
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy), zoomPosition,
); );
isLongPressing = true;
} }
@override @override
void handleLongPressUp(Offset location) { void handleLongPressUp(Offset location) {
if (!appdata.settings['enableLongPressToZoom']) { if (!appdata.settings['enableLongPressToZoom'] || !isLongPressing) {
return; return;
} }
var photoViewController = photoViewControllers[reader.page]!; var photoViewController = photoViewControllers[reader.page]!;
double target = photoViewController.getInitialScale!.call()!; double target = photoViewController.getInitialScale!.call()!;
photoViewController.animateScale?.call(target); photoViewController.animateScale?.call(target);
isLongPressing = false;
} }
Timer? keyRepeatTimer;
@override @override
void handleKeyEvent(KeyEvent event) { void handleKeyEvent(KeyEvent event) {
bool? forward; bool? forward;
@@ -320,18 +465,37 @@ class _GalleryModeState extends State<_GalleryMode>
event.logicalKey == LogicalKeyboardKey.arrowRight) { event.logicalKey == LogicalKeyboardKey.arrowRight) {
forward = false; forward = false;
} }
if (event is KeyDownEvent || event is KeyRepeatEvent) { if (event is KeyDownEvent) {
if (forward == true) { if (keyRepeatTimer != null) {
controller.nextPage( keyRepeatTimer!.cancel();
duration: const Duration(milliseconds: 200), keyRepeatTimer = null;
curve: Curves.ease,
);
} else if (forward == false) {
controller.previousPage(
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
} }
if (forward == true) {
reader.toPage(reader.page+1);
} else if (forward == false) {
reader.toPage(reader.page-1);
}
}
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
keyRepeatTimer = Timer.periodic(
reader.enablePageAnimation
? const Duration(milliseconds: 200)
: const Duration(milliseconds: 50),
(timer) {
if (!mounted) {
timer.cancel();
return;
} else if (forward == true) {
reader.toPage(reader.page+1);
} else if (forward == false) {
reader.toPage(reader.page-1);
}
},
);
}
if (event is KeyUpEvent && keyRepeatTimer != null) {
keyRepeatTimer!.cancel();
keyRepeatTimer = null;
} }
} }
@@ -339,6 +503,34 @@ class _GalleryModeState extends State<_GalleryMode>
bool handleOnTap(Offset location) { bool handleOnTap(Offset location) {
return false; return false;
} }
@override
Future<Uint8List?> getImageByOffset(Offset offset) async {
var imageKey = getImageKeyByOffset(offset);
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
@override
String? getImageKeyByOffset(Offset offset) {
String? imageKey;
if (reader.imagesPerPage == 1) {
imageKey = reader.images![reader.page - 1];
} else {
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
}
return imageKey;
}
} }
const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
@@ -383,6 +575,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
/// To handle the tap event, we need to know if the user was scrolling before the delay. /// To handle the tap event, we need to know if the user was scrolling before the delay.
bool delayedIsScrolling = false; bool delayedIsScrolling = false;
var imageStates = <State<ComicImage>>{};
void delayedSetIsScrolling(bool value) { void delayedSetIsScrolling(bool value) {
Future.delayed( Future.delayed(
const Duration(milliseconds: 300), const Duration(milliseconds: 300),
@@ -395,6 +589,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
bool jumpToNextChapter = false; bool jumpToNextChapter = false;
bool jumpToPrevChapter = false; bool jumpToPrevChapter = false;
bool isZoomedIn = false;
bool isLongPressing = false;
@override @override
void initState() { void initState() {
reader = context.reader; reader = context.reader;
@@ -467,7 +664,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
void cacheImages(int current) { void cacheImages(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) { for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= reader.maxPage && !cached[i]) { if (i <= reader.maxPage && !cached[i]) {
_precacheImage(i, context); _preDownloadImage(i, context);
cached[i] = true; cached[i] = true;
} }
} }
@@ -485,6 +682,23 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
} }
bool onScaleUpdate([double? scale]) {
if (prepareToNextChapter || prepareToPrevChapter) {
setState(() {
prepareToPrevChapter = false;
prepareToNextChapter = false;
});
context.readerScaffold.setFloatingButton(0);
}
var isZoomedIn = (scale ?? photoViewController.scale) != 1.0;
if (isZoomedIn != this.isZoomedIn) {
setState(() {
this.isZoomedIn = isZoomedIn;
});
}
return false;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget widget = ScrollablePositionedList.builder( Widget widget = ScrollablePositionedList.builder(
@@ -506,7 +720,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
reverse: reader.mode == ReaderMode.continuousRightToLeft, reverse: reader.mode == ReaderMode.continuousRightToLeft,
physics: isCTRLPressed || _isMouseScrolling || disableScroll physics: isCTRLPressed || _isMouseScrolling || disableScroll
? const NeverScrollableScrollPhysics() ? const NeverScrollableScrollPhysics()
: const BouncingScrollPhysics(), : isZoomedIn
? const ClampingScrollPhysics()
: const BouncingScrollPhysics(),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0 || index == reader.maxPage + 1) { if (index == 0 || index == reader.maxPage + 1) {
return const SizedBox(); return const SizedBox();
@@ -529,6 +745,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
width: width, width: width,
height: height, height: height,
fit: BoxFit.contain, fit: BoxFit.contain,
onInit: (state) => imageStates.add(state),
onDispose: (state) => imageStates.remove(state),
), ),
); );
}, },
@@ -593,18 +811,24 @@ class _ContinuousModeState extends State<_ContinuousMode>
if (photoViewController.scale == 1 || fingers != 1) { if (photoViewController.scale == 1 || fingers != 1) {
return; return;
} }
if (scrollController.offset != Offset offset;
scrollController.position.maxScrollExtent && var sp = scrollController.position;
scrollController.offset != if (sp.pixels <= sp.minScrollExtent ||
scrollController.position.minScrollExtent) { sp.pixels >= sp.maxScrollExtent) {
offset = Offset(value.dx, value.dy);
} else {
if (reader.mode == ReaderMode.continuousTopToBottom) { if (reader.mode == ReaderMode.continuousTopToBottom) {
value = Offset(value.dx, 0); offset = Offset(value.dx, 0);
} else { } else {
value = Offset(0, value.dy); offset = Offset(0, value.dy);
} }
} }
if (isLongPressing) {
offset += value;
}
photoViewController.updateMultiple( photoViewController.updateMultiple(
position: photoViewController.position + value); position: photoViewController.position + offset,
);
}, },
onPointerSignal: onPointerSignal, onPointerSignal: onPointerSignal,
child: widget, child: widget,
@@ -618,7 +842,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
delayedSetIsScrolling(false); delayedSetIsScrolling(false);
} }
if (notification is ScrollUpdateNotification) { var scale = photoViewController.scale ?? 1.0;
if (notification is ScrollUpdateNotification &&
(scale - 1).abs() < 0.05) {
if (!scrollController.hasClients) return false; if (!scrollController.hasClients) return false;
if (scrollController.position.pixels <= if (scrollController.position.pixels <=
scrollController.position.minScrollExtent && scrollController.position.minScrollExtent &&
@@ -659,8 +886,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
}, },
child: widget, child: widget,
); );
var width = MediaQuery.of(context).size.width; var width = reader.size.width;
var height = MediaQuery.of(context).size.height; var height = reader.size.height;
if (appdata.settings['limitImageWidth'] && if (appdata.settings['limitImageWidth'] &&
width / height > 0.7 && width / height > 0.7 &&
reader.mode == ReaderMode.continuousTopToBottom) { reader.mode == ReaderMode.continuousTopToBottom) {
@@ -676,6 +903,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
maxScale: 2.5, maxScale: 2.5,
strictScale: true, strictScale: true,
controller: photoViewController, controller: photoViewController,
onScaleUpdate: onScaleUpdate,
child: SizedBox( child: SizedBox(
width: width, width: width,
height: height, height: height,
@@ -731,6 +959,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
target, target,
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy), Offset(size.width / 2 - location.dx, size.height / 2 - location.dy),
); );
onScaleUpdate(target);
} }
@override @override
@@ -739,11 +968,22 @@ class _ContinuousModeState extends State<_ContinuousMode>
return; return;
} }
double target = photoViewController.getInitialScale!.call()! * 1.75; double target = photoViewController.getInitialScale!.call()! * 1.75;
var size = MediaQuery.of(context).size; var size = reader.size;
Offset zoomPosition;
if (appdata.settings['longPressZoomPosition'] != 'center') {
zoomPosition = Offset(
size.width / 2 - location.dx,
size.height / 2 - location.dy,
);
} else {
zoomPosition = Offset(0, 0);
}
photoViewController.animateScale?.call( photoViewController.animateScale?.call(
target, target,
Offset(size.width / 2 - location.dx, size.height / 2 - location.dy), zoomPosition,
); );
onScaleUpdate(target);
isLongPressing = true;
} }
@override @override
@@ -753,6 +993,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
double target = photoViewController.getInitialScale!.call()!; double target = photoViewController.getInitialScale!.call()!;
photoViewController.animateScale?.call(target); photoViewController.animateScale?.call(target);
onScaleUpdate(target);
isLongPressing = false;
} }
@override @override
@@ -798,13 +1040,13 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
if (forward == true) { if (forward == true) {
scrollController.animateTo( scrollController.animateTo(
scrollController.offset + context.height, scrollController.offset + context.height * 0.25,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.ease, curve: Curves.ease,
); );
} else if (forward == false) { } else if (forward == false) {
scrollController.animateTo( scrollController.animateTo(
scrollController.offset - context.height, scrollController.offset - context.height * 0.25,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.ease, curve: Curves.ease,
); );
@@ -818,10 +1060,37 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
return false; return false;
} }
@override
Future<Uint8List?> getImageByOffset(Offset offset) async {
var imageKey = getImageKeyByOffset(offset);
if (imageKey == null) return null;
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
@override
String? getImageKeyByOffset(Offset offset) {
String? imageKey;
for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
}
}
return imageKey;
}
} }
ImageProvider _createImageProviderFromKey( ImageProvider _createImageProviderFromKey(
String imageKey, BuildContext context) { String imageKey,
BuildContext context,
int page,
) {
var reader = context.reader; var reader = context.reader;
return ReaderImageProvider( return ReaderImageProvider(
imageKey, imageKey,
@@ -835,16 +1104,39 @@ ImageProvider _createImageProviderFromKey(
ImageProvider _createImageProvider(int page, BuildContext context) { ImageProvider _createImageProvider(int page, BuildContext context) {
var reader = context.reader; var reader = context.reader;
var imageKey = reader.images![page - 1]; var imageKey = reader.images![page - 1];
return _createImageProviderFromKey(imageKey, context); return _createImageProviderFromKey(imageKey, context, page);
} }
/// [_precacheImage] is used to precache the image for the given page.
/// The image is cached using the flutter's [precacheImage] method.
/// The image will be downloaded and decoded into memory.
void _precacheImage(int page, BuildContext context) { void _precacheImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) {
return;
}
precacheImage( precacheImage(
_createImageProvider(page, context), _createImageProvider(page, context),
context, context,
); );
} }
/// [_preDownloadImage] is used to download the image for the given page.
/// The image is downloaded using the [CacheManager] and saved to the local storage.
void _preDownloadImage(int page, BuildContext context) {
if (page <= 0 || page > context.reader.images!.length) {
return;
}
var reader = context.reader;
var imageKey = reader.images![page - 1];
if (imageKey.startsWith("file://")) {
return;
}
var cid = reader.cid;
var eid = reader.eid;
var sourceKey = reader.type.comicSource?.key;
ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid);
}
class _SwipeChangeChapterProgress extends StatefulWidget { class _SwipeChangeChapterProgress extends StatefulWidget {
const _SwipeChangeChapterProgress({ const _SwipeChangeChapterProgress({
this.controller, this.controller,

View File

@@ -29,7 +29,9 @@ import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/images.dart';
import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/clipboard_image.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/file_type.dart';
@@ -109,7 +111,16 @@ class _ReaderState extends State<Reader>
} }
@override @override
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil(); int get maxPage {
if (images == null) {
return 1;
}
if (!showSingleImageOnFirstPage) {
return (images!.length / imagesPerPage).ceil();
} else {
return 1 + ((images!.length - 1) / imagesPerPage).ceil();
}
}
ComicType get type => widget.type; ComicType get type => widget.type;
@@ -123,7 +134,8 @@ class _ReaderState extends State<Reader>
late ReaderMode mode; late ReaderMode mode;
@override @override
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait; bool get isPortrait =>
MediaQuery.of(context).orientation == Orientation.portrait;
History? history; History? history;
@@ -152,10 +164,9 @@ class _ReaderState extends State<Reader>
} }
mode = ReaderMode.fromKey(appdata.settings['readerMode']); mode = ReaderMode.fromKey(appdata.settings['readerMode']);
history = widget.history; history = widget.history;
Future.microtask(() { if (!appdata.settings['showSystemStatusBar']) {
updateHistory(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}); }
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if (appdata.settings['enableTurnPageByVolumeKey']) { if (appdata.settings['enableTurnPageByVolumeKey']) {
handleVolumeEvent(); handleVolumeEvent();
} }
@@ -166,10 +177,18 @@ class _ReaderState extends State<Reader>
super.initState(); super.initState();
} }
bool _isInitialized = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
initImagesPerPage(widget.initialPage ?? 1); if (!_isInitialized) {
initImagesPerPage(widget.initialPage ?? 1);
_isInitialized = true;
} else {
// For orientation changed
_checkImagesPerPageChange();
}
initReaderWindow(); initReaderWindow();
} }
@@ -215,10 +234,16 @@ class _ReaderState extends State<Reader>
focusNode: focusNode, focusNode: focusNode,
autofocus: true, autofocus: true,
onKeyEvent: onKeyEvent, onKeyEvent: onKeyEvent,
child: _ReaderScaffold( child: Overlay(
child: _ReaderGestureDetector( initialEntries: [
child: _ReaderImages(key: Key(chapter.toString())), OverlayEntry(builder: (context) {
), return _ReaderScaffold(
child: _ReaderGestureDetector(
child: _ReaderImages(key: Key(chapter.toString())),
),
);
})
],
), ),
); );
} }
@@ -249,7 +274,15 @@ class _ReaderState extends State<Reader>
history!.page = images?.length ?? 1; history!.page = images?.length ?? 1;
} else { } else {
/// Record the first image of the page /// Record the first image of the page
history!.page = (page - 1) * imagesPerPage + 1; if (!showSingleImageOnFirstPage || imagesPerPage == 1) {
history!.page = (page - 1) * imagesPerPage + 1;
} else {
if (page == 1) {
history!.page = 1;
} else {
history!.page = (page - 2) * imagesPerPage + 2;
}
}
} }
history!.maxPage = images?.length ?? 1; history!.maxPage = images?.length ?? 1;
if (widget.chapters?.isGrouped ?? false) { if (widget.chapters?.isGrouped ?? false) {
@@ -308,11 +341,20 @@ class _ReaderState extends State<Reader>
} }
return chapter == maxChapter; return chapter == maxChapter;
} }
/// Get the size of the reader.
/// The size is not always the same as the size of the screen.
Size get size {
var renderBox = context.findRenderObject() as RenderBox;
return renderBox.size;
}
} }
abstract mixin class _ImagePerPageHandler { abstract mixin class _ImagePerPageHandler {
late int _lastImagesPerPage; late int _lastImagesPerPage;
late bool _lastOrientation;
bool get isPortrait; bool get isPortrait;
int get page; int get page;
@@ -323,11 +365,19 @@ abstract mixin class _ImagePerPageHandler {
void initImagesPerPage(int initialPage) { void initImagesPerPage(int initialPage) {
_lastImagesPerPage = imagesPerPage; _lastImagesPerPage = imagesPerPage;
_lastOrientation = isPortrait;
if (imagesPerPage != 1) { if (imagesPerPage != 1) {
page = (initialPage / imagesPerPage).ceil(); if (showSingleImageOnFirstPage) {
page = ((initialPage - 1) / imagesPerPage).ceil() + 1;
} else {
page = (initialPage / imagesPerPage).ceil();
}
} }
} }
bool get showSingleImageOnFirstPage =>
appdata.settings["showSingleImageOnFirstPage"];
/// The number of images displayed on one screen /// The number of images displayed on one screen
int get imagesPerPage { int get imagesPerPage {
if (mode.isContinuous) return 1; if (mode.isContinuous) return 1;
@@ -341,19 +391,42 @@ abstract mixin class _ImagePerPageHandler {
/// Check if the number of images per page has changed /// Check if the number of images per page has changed
void _checkImagesPerPageChange() { void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage; int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) { bool currentOrientation = isPortrait;
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
_adjustPageForImagesPerPageChange( _adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage); _lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage; _lastImagesPerPage = currentImagesPerPage;
_lastOrientation = currentOrientation;
} }
} }
/// Adjust the page number when the number of images per page changes /// Adjust the page number when the number of images per page changes
void _adjustPageForImagesPerPageChange( void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) { int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage; int previousImageIndex = 1;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1; if (!showSingleImageOnFirstPage || oldImagesPerPage == 1) {
page = newPage; previousImageIndex = (page - 1) * oldImagesPerPage + 1;
} else {
if (page == 1) {
previousImageIndex = 1;
} else {
previousImageIndex = (page - 2) * oldImagesPerPage + 2;
}
}
int newPage;
if (newImagesPerPage != 1) {
if (showSingleImageOnFirstPage) {
newPage = ((previousImageIndex - 1) / newImagesPerPage).ceil() + 1;
} else {
newPage = (previousImageIndex / newImagesPerPage).ceil();
}
} else {
newPage = previousImageIndex;
}
page = newPage>0 ? newPage : 1;
} }
} }
@@ -362,8 +435,24 @@ abstract mixin class _VolumeListener {
bool toPrevPage(); bool toPrevPage();
bool toNextChapter();
bool toPrevChapter();
VolumeListener? volumeListener; VolumeListener? volumeListener;
void onDown() {
if (!toNextPage()) {
toNextChapter();
}
}
void onUp() {
if (!toPrevPage()) {
toPrevChapter();
}
}
void handleVolumeEvent() { void handleVolumeEvent() {
if (!App.isAndroid) { if (!App.isAndroid) {
// Currently only support Android // Currently only support Android
@@ -373,8 +462,8 @@ abstract mixin class _VolumeListener {
volumeListener?.cancel(); volumeListener?.cancel();
} }
volumeListener = VolumeListener( volumeListener = VolumeListener(
onDown: toNextPage, onDown: onDown,
onUp: toPrevPage, onUp: onUp,
)..listen(); )..listen();
} }
@@ -577,4 +666,8 @@ abstract interface class _ImageViewController {
/// Returns true if the event is handled. /// Returns true if the event is handled.
bool handleOnTap(Offset location); bool handleOnTap(Offset location);
Future<Uint8List?> getImageByOffset(Offset offset);
String? getImageKeyByOffset(Offset offset);
} }

View File

@@ -107,7 +107,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
if (!_isOpen) { if (!_isOpen) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else { } else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); if (!appdata.settings['showSystemStatusBar']) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
} }
setState(() { setState(() {
_isOpen = !_isOpen; _isOpen = !_isOpen;
@@ -127,7 +131,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
Positioned.fill( Positioned.fill(
child: widget.child, child: widget.child,
), ),
buildPageInfoText(), if (appdata.settings['showPageNumberInReader'] == true)
buildPageInfoText(),
buildStatusInfo(), buildStatusInfo(),
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
@@ -161,7 +166,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: Container( child: Container(
padding: EdgeInsets.only(top: context.padding.top), padding: EdgeInsets.only(top: context.padding.top),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.82), color: context.colorScheme.surface.toOpacity(0.92),
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: Colors.grey.toOpacity(0.5), color: Colors.grey.toOpacity(0.5),
@@ -207,7 +212,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
} }
void addImageFavorite() { void addImageFavorite() async {
try { try {
if (context.reader.images![0].contains('file://')) { if (context.reader.images![0].contains('file://')) {
showToast( showToast(
@@ -221,7 +226,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
String title = context.reader.history!.title; String title = context.reader.history!.title;
String subTitle = context.reader.history!.subtitle; String subTitle = context.reader.history!.subtitle;
int maxPage = context.reader.images!.length; int maxPage = context.reader.images!.length;
int page = context.reader.page; int? page = await selectImage();
if (page == null) return;
page += 1;
String sourceKey = context.reader.type.sourceKey; String sourceKey = context.reader.type.sourceKey;
String imageKey = context.reader.images![page - 1]; String imageKey = context.reader.images![page - 1];
List<String> tags = context.reader.widget.tags; List<String> tags = context.reader.widget.tags;
@@ -377,11 +384,12 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
Tooltip( Tooltip(
message: "Collect the image".tl, message: "Collect the image".tl,
child: IconButton( child: IconButton(
icon: Icon( icon:
isLiked() ? Icons.favorite : Icons.favorite_border), Icon(isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite), onPressed: addImageFavorite,
),
), ),
if (App.isWindows) if (App.isDesktop)
Tooltip( Tooltip(
message: "${"Full Screen".tl}(F12)", message: "${"Full Screen".tl}(F12)",
child: IconButton( child: IconButton(
@@ -475,7 +483,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
return BlurEffect( return BlurEffect(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.82), color: context.colorScheme.surface.toOpacity(0.92),
border: isOpen border: isOpen
? Border( ? Border(
top: BorderSide( top: BorderSide(
@@ -569,94 +577,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
); );
} }
Future<Uint8List?> _getCurrentImageData() async {
var imageKey = context.reader.images![context.reader.page - 1];
var reader = context.reader;
if (context.reader.mode.isContinuous) {
var continuesState =
context.reader._imageViewController as _ContinuousModeState;
var imagesOnScreen =
continuesState.itemPositionsListener.itemPositions.value;
var images = imagesOnScreen
.map((e) => context.reader.images!.elementAtOrNull(e.index - 1))
.whereType<String>()
.toList();
String? selected;
if (images.length > 1) {
await showPopUpWidget(
context,
PopUpWidgetScaffold(
title: "Select an image on screen".tl,
body: GridView.builder(
itemCount: images.length,
itemBuilder: (context, index) {
ImageProvider image;
var imageKey = images[index];
if (imageKey.startsWith('file://')) {
image = FileImage(File(imageKey.replaceFirst("file://", '')));
} else {
image = ReaderImageProvider(
imageKey,
reader.type.comicSource!.key,
reader.cid,
reader.eid,
reader.page,
);
}
return InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
onTap: () {
selected = images[index];
App.rootContext.pop();
},
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
width: double.infinity,
height: double.infinity,
child: Image(
width: double.infinity,
height: double.infinity,
image: image,
),
),
).padding(const EdgeInsets.all(8));
},
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.7,
),
),
),
);
} else {
selected = images.first;
}
if (selected == null) {
return null;
} else {
imageKey = selected!;
}
}
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
void saveCurrentImage() async { void saveCurrentImage() async {
var data = await _getCurrentImageData(); var data = await selectImageToData();
if (data == null) { if (data == null) {
return; return;
} }
@@ -666,7 +588,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
void share() async { void share() async {
var data = await _getCurrentImageData(); var data = await selectImageToData();
if (data == null) { if (data == null) {
return; return;
} }
@@ -749,9 +671,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
? Icons.arrow_forward_ios ? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined, : Icons.arrow_back_ios_outlined,
size: 24, size: 24,
color: Theme.of(context) color: Theme.of(context).colorScheme.onPrimaryContainer,
.colorScheme
.onPrimaryContainer,
), ),
), ),
), ),
@@ -760,6 +680,74 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
return const SizedBox(); return const SizedBox();
} }
/// If there is only one image on screen, return it.
///
/// If there are multiple images on screen,
/// show an overlay to let the user select an image.
///
/// The return value is the index of the selected image.
Future<int?> selectImage() async {
var reader = context.reader;
var imageViewController = context.reader._imageViewController;
if (imageViewController is _GalleryModeState && reader.imagesPerPage == 1) {
return reader.page - 1;
} else {
var location = await _showSelectImageOverlay();
if (location == null) {
return null;
}
var imageKey = imageViewController!.getImageKeyByOffset(location);
if (imageKey == null) {
return null;
}
return reader.images!.indexOf(imageKey);
}
}
/// Same as [selectImage], but return the image data.
Future<Uint8List?> selectImageToData() async {
var i = await selectImage();
if (i == null) {
return null;
}
var imageKey = context.reader.images![i];
if (imageKey.startsWith("file://")) {
return await File(imageKey.substring(7)).readAsBytes();
} else {
return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
.readAsBytes();
}
}
Future<Offset?> _showSelectImageOverlay() {
if (_isOpen) {
openOrClose();
}
var completer = Completer<Offset?>();
var overlay = Overlay.of(context);
OverlayEntry? entry;
entry = OverlayEntry(
builder: (context) {
return Positioned.fill(
child: _SelectImageOverlayContent(onTap: (offset) {
completer.complete(offset);
entry!.remove();
}, onDispose: () {
if (!completer.isCompleted) {
completer.complete(null);
}
}),
);
},
);
overlay.insert(entry);
return completer.future;
}
} }
class _BatteryWidget extends StatefulWidget { class _BatteryWidget extends StatefulWidget {
@@ -940,3 +928,69 @@ class _ClockWidgetState extends State<_ClockWidget> {
); );
} }
} }
class _SelectImageOverlayContent extends StatefulWidget {
const _SelectImageOverlayContent({
required this.onTap,
required this.onDispose,
});
final void Function(Offset) onTap;
final void Function() onDispose;
@override
State<_SelectImageOverlayContent> createState() => _SelectImageOverlayContentState();
}
class _SelectImageOverlayContentState extends State<_SelectImageOverlayContent> {
@override
void dispose() {
widget.onDispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (details) {
widget.onTap(details.globalPosition);
},
child: Container(
color: Colors.black.withAlpha(50),
child: Align(
alignment: Alignment(
0,
-0.8,
),
child: Container(
width: 232,
height: 42,
decoration: BoxDecoration(
color: context.colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: context.colorScheme.outlineVariant,
),
),
child: Row(
children: [
const SizedBox(width: 8),
const Icon(Icons.info_outline),
const SizedBox(width: 16),
Text(
"Click to select an image".tl,
style: TextStyle(
fontSize: 16,
color: context.colorScheme.onSurface,
),
),
],
),
),
),
),
);
}
}

View File

@@ -441,6 +441,11 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var sources = ComicSource.all();
var enabled = appdata.settings['searchSources'] as List;
sources.removeWhere((e) {
return !enabled.contains(e.key);
});
return ContentDialog( return ContentDialog(
title: "Settings".tl, title: "Settings".tl,
content: Column( content: Column(
@@ -452,7 +457,7 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: ComicSource.all().map((e) { children: sources.map((e) {
return OptionChip( return OptionChip(
text: e.name.tl, text: e.name.tl,
isSelected: searchTarget == e.key, isSelected: searchTarget == e.key,

View File

@@ -96,10 +96,13 @@ Future<bool> checkUpdate() async {
return false; return false;
} }
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async { Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true, bool delay = false]) async {
try { try {
var value = await checkUpdate(); var value = await checkUpdate();
if (value) { if (value) {
if (delay) {
await Future.delayed(const Duration(seconds: 2));
}
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {

View File

@@ -140,17 +140,6 @@ class _AppSettingsState extends State<AppSettings> {
}, },
actionTitle: 'Set'.tl, actionTitle: 'Set'.tl,
).toSliver(), ).toSliver(),
_SettingPartTitle(
title: "Log".tl,
icon: Icons.error_outline,
),
_CallbackSetting(
title: "Open Log".tl,
callback: () {
context.to(() => const LogsPage());
},
actionTitle: 'Open'.tl,
).toSliver(),
_SettingPartTitle( _SettingPartTitle(
title: "User".tl, title: "User".tl,
icon: Icons.person_outline, icon: Icons.person_outline,
@@ -204,12 +193,46 @@ class LogsPage extends StatefulWidget {
} }
class _LogsPageState extends State<LogsPage> { class _LogsPageState extends State<LogsPage> {
String logLevelToShow = "all";
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var logToShow = logLevelToShow == "all"
? Log.logs
: Log.logs.where((log) => log.level.name == logLevelToShow).toList();
return Scaffold( return Scaffold(
appBar: Appbar( appBar: Appbar(
title: const Text("Logs"), title: Text("Logs".tl),
actions: [ actions: [
IconButton(
onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
MediaQuery.of(context).padding.top + kToolbarHeight,
0.0,
0.0,
);
showMenu(context: context, position: position, items: [
PopupMenuItem(
child: Text("all"),
onTap: () => setState(() => logLevelToShow = "all")
),
PopupMenuItem(
child: Text("info"),
onTap: () => setState(() => logLevelToShow = "info")
),
PopupMenuItem(
child: Text("warning"),
onTap: () => setState(() => logLevelToShow = "warning")
),
PopupMenuItem(
child: Text("error"),
onTap: () => setState(() => logLevelToShow = "error")
),
]);
}),
icon: const Icon(Icons.filter_list_outlined)
),
IconButton( IconButton(
onPressed: () => setState(() { onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB( final RelativeRect position = RelativeRect.fromLTRB(
@@ -228,7 +251,7 @@ class _LogsPageState extends State<LogsPage> {
onTap: () { onTap: () {
Log.ignoreLimitation = true; Log.ignoreLimitation = true;
context.showMessage( context.showMessage(
message: "Only valid for this run"); message: "Only valid for this run".tl);
}, },
), ),
PopupMenuItem( PopupMenuItem(
@@ -243,9 +266,9 @@ class _LogsPageState extends State<LogsPage> {
body: ListView.builder( body: ListView.builder(
reverse: true, reverse: true,
controller: ScrollController(), controller: ScrollController(),
itemCount: Log.logs.length, itemCount: logToShow.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
index = Log.logs.length - index - 1; index = logToShow.length - index - 1;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: SelectionArea( child: SelectionArea(
@@ -264,7 +287,7 @@ class _LogsPageState extends State<LogsPage> {
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1), padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(Log.logs[index].title), child: Text(logToShow[index].title),
), ),
), ),
const SizedBox( const SizedBox(
@@ -276,16 +299,16 @@ class _LogsPageState extends State<LogsPage> {
Theme.of(context).colorScheme.error, Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.errorContainer, Theme.of(context).colorScheme.errorContainer,
Theme.of(context).colorScheme.primaryContainer Theme.of(context).colorScheme.primaryContainer
][Log.logs[index].level.index], ][logToShow[index].level.index],
borderRadius: borderRadius:
const BorderRadius.all(Radius.circular(16)), const BorderRadius.all(Radius.circular(16)),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1), padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text( child: Text(
Log.logs[index].level.name, logToShow[index].level.name,
style: TextStyle( style: TextStyle(
color: Log.logs[index].level.index == 0 color: logToShow[index].level.index == 0
? Colors.white ? Colors.white
: Colors.black), : Colors.black),
), ),
@@ -293,14 +316,14 @@ class _LogsPageState extends State<LogsPage> {
), ),
], ],
), ),
Text(Log.logs[index].content), Text(logToShow[index].content),
Text(Log.logs[index].time Text(logToShow[index].time
.toString() .toString()
.replaceAll(RegExp(r"\.\w+"), "")), .replaceAll(RegExp(r"\.\w+"), "")),
TextButton( TextButton(
onPressed: () { onPressed: () {
Clipboard.setData( Clipboard.setData(
ClipboardData(text: Log.logs[index].content)); ClipboardData(text: logToShow[index].content));
}, },
child: Text("Copy".tl), child: Text("Copy".tl),
), ),

View File

@@ -0,0 +1,95 @@
part of 'settings_page.dart';
class DebugPage extends StatefulWidget {
const DebugPage({super.key});
@override
State<DebugPage> createState() => DebugPageState();
}
class DebugPageState extends State<DebugPage> {
final controller = TextEditingController();
var result = "";
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Debug".tl)),
_CallbackSetting(
title: "Reload Configs".tl,
actionTitle: "Reload".tl,
callback: () {
ComicSourceManager().reload();
},
).toSliver(),
_CallbackSetting(
title: "Open Log".tl,
callback: () {
context.to(() => const LogsPage());
},
actionTitle: 'Open'.tl,
).toSliver(),
SliverToBoxAdapter(
child: Column(
children: [
const SizedBox(height: 8),
const Text(
"JS Evaluator",
style: TextStyle(fontSize: 16),
).toAlign(Alignment.centerLeft).paddingLeft(16),
Container(
width: double.infinity,
height: 200,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: TextField(
controller: controller,
maxLines: null,
expands: true,
textAlign: TextAlign.start,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.all(8),
),
),
),
TextButton(
onPressed: () {
try {
var res = JsEngine().runCode(controller.text);
setState(() {
result = res.toString();
});
} catch (e) {
setState(() {
result = e.toString();
});
}
},
child: const Text("Run"),
).toAlign(Alignment.centerRight).paddingRight(16),
const Text(
"Result",
style: TextStyle(fontSize: 16),
).toAlign(Alignment.centerLeft).paddingLeft(16),
Container(
width: double.infinity,
height: 200,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.outline),
borderRadius: BorderRadius.circular(4),
),
child: SingleChildScrollView(
child: Text(result).paddingAll(4),
),
),
],
),
),
],
);
}
}

View File

@@ -25,8 +25,8 @@ class _ExploreSettingsState extends State<ExploreSettings> {
title: "Size of comic tile".tl, title: "Size of comic tile".tl,
settingsIndex: "comicTileScale", settingsIndex: "comicTileScale",
interval: 0.05, interval: 0.05,
min: 0.75, min: 0.5,
max: 1.25, max: 1.5,
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Explore Pages".tl, title: "Explore Pages".tl,
@@ -52,6 +52,10 @@ class _ExploreSettingsState extends State<ExploreSettings> {
title: "Show history on comic tile".tl, title: "Show history on comic tile".tl,
settingKey: "showHistoryStatusOnTile", settingKey: "showHistoryStatusOnTile",
).toSliver(), ).toSliver(),
_SwitchSetting(
title: "Reverse default chapter order".tl,
settingKey: "reverseChapterOrder",
).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Keyword blocking".tl, title: "Keyword blocking".tl,
builder: () => const _ManageBlockingWordView(), builder: () => const _ManageBlockingWordView(),

View File

@@ -48,6 +48,7 @@ class _ReaderSettingsState extends State<ReaderSettings> {
"continuousTopToBottom": "Continuous (Top to Bottom)".tl, "continuousTopToBottom": "Continuous (Top to Bottom)".tl,
}, },
onChanged: () { onChanged: () {
setState(() {});
var readerMode = appdata.settings['readerMode']; var readerMode = appdata.settings['readerMode'];
if (readerMode?.toLowerCase().startsWith('continuous') ?? false) { if (readerMode?.toLowerCase().startsWith('continuous') ?? false) {
appdata.settings['readerScreenPicNumberForLandscape'] = 1; appdata.settings['readerScreenPicNumberForLandscape'] = 1;
@@ -65,70 +66,80 @@ class _ReaderSettingsState extends State<ReaderSettings> {
min: 1, min: 1,
max: 20, max: 20,
onChanged: () { onChanged: () {
setState(() {});
widget.onChanged?.call("autoPageTurningInterval"); widget.onChanged?.call("autoPageTurningInterval");
}, },
).toSliver(), ).toSliver(),
SliverToBoxAdapter( SliverAnimatedVisibility(
child: AbsorbPointer( visible: appdata.settings['readerMode']!.startsWith('gallery'),
absorbing: (appdata.settings['readerMode'] child: _SliderSetting(
?.toLowerCase() title:
.startsWith('continuous') ?? "The number of pic in screen for landscape (Only Gallery Mode)"
false), .tl,
child: AnimatedOpacity( settingsIndex: "readerScreenPicNumberForLandscape",
opacity: (appdata.settings['readerMode'] interval: 1,
?.toLowerCase() min: 1,
.startsWith('continuous') ?? max: 5,
false) onChanged: () {
? 0.5 setState(() {});
: 1.0, widget.onChanged?.call("readerScreenPicNumberForLandscape");
duration: Duration(milliseconds: 300), },
child: _SliderSetting(
title: "The number of pic in screen for landscape (Only Gallery Mode)".tl,
settingsIndex: "readerScreenPicNumberForLandscape",
interval: 1,
min: 1,
max: 5,
onChanged: () {
widget.onChanged?.call("readerScreenPicNumberForLandscape");
},
),
),
), ),
), ),
SliverToBoxAdapter( SliverAnimatedVisibility(
child: AbsorbPointer( visible: appdata.settings['readerMode']!.startsWith('gallery'),
absorbing: (appdata.settings['readerMode'] child: _SliderSetting(
?.toLowerCase() title:
.startsWith('continuous') ?? "The number of pic in screen for portrait (Only Gallery Mode)"
false), .tl,
child: AnimatedOpacity( settingsIndex: "readerScreenPicNumberForPortrait",
opacity: (appdata.settings['readerMode'] interval: 1,
?.toLowerCase() min: 1,
.startsWith('continuous') ?? max: 5,
false) onChanged: () {
? 0.5 widget.onChanged?.call("readerScreenPicNumberForPortrait");
: 1.0, },
duration: Duration(milliseconds: 300),
child: _SliderSetting(
title: "The number of pic in screen for portrait (Only Gallery Mode)".tl,
settingsIndex: "readerScreenPicNumberForPortrait",
interval: 1,
min: 1,
max: 5,
onChanged: () {
widget.onChanged?.call("readerScreenPicNumberForPortrait");
},
),
),
), ),
), ),
SliverAnimatedVisibility(
visible: appdata.settings['readerMode']!.startsWith('gallery') &&
(appdata.settings['readerScreenPicNumberForLandscape'] > 1 ||
appdata.settings['readerScreenPicNumberForPortrait'] > 1),
child: _SwitchSetting(
title: "Show single image on first page".tl,
settingKey: "showSingleImageOnFirstPage",
onChanged: () {
widget.onChanged?.call("showSingleImageOnFirstPage");
},
),
),
_SwitchSetting(
title: 'Double tap to zoom'.tl,
settingKey: 'enableDoubleTapToZoom',
onChanged: () {
setState(() {});
widget.onChanged?.call('enableDoubleTapToZoom');
},
).toSliver(),
_SwitchSetting( _SwitchSetting(
title: 'Long press to zoom'.tl, title: 'Long press to zoom'.tl,
settingKey: 'enableLongPressToZoom', settingKey: 'enableLongPressToZoom',
onChanged: () { onChanged: () {
setState(() {});
widget.onChanged?.call('enableLongPressToZoom'); widget.onChanged?.call('enableLongPressToZoom');
}, },
).toSliver(), ).toSliver(),
SliverAnimatedVisibility(
visible: appdata.settings['enableLongPressToZoom'] == true,
child: SelectSetting(
title: "Long press zoom position".tl,
settingKey: "longPressZoomPosition",
optionTranslation: {
"press": "Press position".tl,
"center": "Screen center".tl,
},
),
),
_SwitchSetting( _SwitchSetting(
title: 'Limit image width'.tl, title: 'Limit image width'.tl,
subtitle: 'When using Continuous(Top to Bottom) mode'.tl, subtitle: 'When using Continuous(Top to Bottom) mode'.tl,
@@ -152,6 +163,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call("enableClockAndBatteryInfoInReader"); widget.onChanged?.call("enableClockAndBatteryInfoInReader");
}, },
).toSliver(), ).toSliver(),
_SwitchSetting(
title: "Show system status bar".tl,
settingKey: "showSystemStatusBar",
onChanged: () {
widget.onChanged?.call("showSystemStatusBar");
},
).toSliver(),
SelectSetting( SelectSetting(
title: "Quick collect image".tl, title: "Quick collect image".tl,
settingKey: "quickCollectImage", settingKey: "quickCollectImage",
@@ -179,6 +197,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
min: 1, min: 1,
max: 16, max: 16,
).toSliver(), ).toSliver(),
_SwitchSetting(
title: "Show Page Number".tl,
settingKey: "showPageNumberInReader",
onChanged: () {
widget.onChanged?.call("showPageNumberInReader");
},
).toSliver(),
], ],
); );
} }

View File

@@ -30,6 +30,7 @@ part 'local_favorites.dart';
part 'app.dart'; part 'app.dart';
part 'about.dart'; part 'about.dart';
part 'network.dart'; part 'network.dart';
part 'debug.dart';
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
const SettingsPage({this.initialPage = -1, super.key}); const SettingsPage({this.initialPage = -1, super.key});
@@ -55,6 +56,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
"APP", "APP",
"Network", "Network",
"About", "About",
"Debug"
]; ];
final icons = <IconData>[ final icons = <IconData>[
@@ -64,7 +66,8 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
Icons.collections_bookmark_rounded, Icons.collections_bookmark_rounded,
Icons.apps, Icons.apps,
Icons.public, Icons.public,
Icons.info Icons.info,
Icons.bug_report,
]; ];
double offset = 0; double offset = 0;
@@ -246,6 +249,9 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
} }
void handlePointerDown(PointerDownEvent event) { void handlePointerDown(PointerDownEvent event) {
if (!App.isIOS) {
return;
}
if (event.position.dx < 20) { if (event.position.dx < 20) {
gestureRecognizer.addPointer(event); gestureRecognizer.addPointer(event);
} }
@@ -350,6 +356,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
4 => const AppSettings(), 4 => const AppSettings(),
5 => const NetworkSettings(), 5 => const NetworkSettings(),
6 => const AboutSettings(), 6 => const AboutSettings(),
7 => const DebugPage(),
_ => throw UnimplementedError() _ => throw UnimplementedError()
}; };
} }

View File

@@ -9,7 +9,7 @@ import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/proxy.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'dart:io' as io; import 'dart:io' as io;
@@ -308,7 +308,7 @@ class DesktopWebview {
useWindowPositionAndSize: true, useWindowPositionAndSize: true,
userDataFolderWindows: "${App.dataPath}\\webview", userDataFolderWindows: "${App.dataPath}\\webview",
title: "webview", title: "webview",
proxy: AppDio.proxy, proxy: await getProxy(),
)); ));
_webview!.addOnWebMessageReceivedCallback(onMessage); _webview!.addOnWebMessageReceivedCallback(onMessage);
_webview!.setOnNavigation((s) { _webview!.setOnNavigation((s) {

View File

@@ -112,7 +112,7 @@ abstract class CBZ {
var ext = e.path.split('.').last; var ext = e.path.split('.').last;
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
}); });
if(files.isEmpty) { if (files.isEmpty) {
cache.deleteSync(recursive: true); cache.deleteSync(recursive: true);
throw Exception('No images found in the archive'); throw Exception('No images found in the archive');
} }
@@ -141,8 +141,7 @@ abstract class CBZ {
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)), FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
); );
dest.createSync(); dest.createSync();
coverFile.copyMem( coverFile.copyMem(FilePath.join(dest.path, 'cover.${coverFile.extension}'));
FilePath.join(dest.path, 'cover.${coverFile.extension}'));
if (metaData.chapters == null) { if (metaData.chapters == null) {
for (var i = 0; i < files.length; i++) { for (var i = 0; i < files.length; i++) {
var src = files[i]; var src = files[i];
@@ -233,17 +232,19 @@ abstract class CBZ {
} }
} }
var cover = comic.coverFile; var cover = comic.coverFile;
await cover await cover.copyMem(
.copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}')); FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
final metaData = ComicMetaData(
title: comic.title,
author: comic.subtitle,
tags: comic.tags,
chapters: chapters,
);
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString( await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
jsonEncode( jsonEncode(metaData),
ComicMetaData( );
title: comic.title, await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString(
author: comic.subtitle, _buildComicInfoXml(metaData),
tags: comic.tags,
chapters: chapters,
).toJson(),
),
); );
var cbz = File(outFilePath); var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync(); if (cbz.existsSync()) cbz.deleteSync();
@@ -252,7 +253,54 @@ abstract class CBZ {
return cbz; return cbz;
} }
static String _buildComicInfoXml(ComicMetaData data) {
final buffer = StringBuffer();
buffer.writeln('<?xml version="1.0" encoding="utf-8"?>');
buffer.writeln('<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">');
buffer.writeln(' <Title>${_escapeXml(data.title)}</Title>');
buffer.writeln(' <Series>${_escapeXml(data.title)}</Series>');
if (data.author.isNotEmpty) {
buffer.writeln(' <Writer>${_escapeXml(data.author)}</Writer>');
}
if (data.tags.isNotEmpty) {
var tags = data.tags;
if (tags.length > 5) {
tags = tags.sublist(0, 5);
}
buffer.writeln(' <Genre>${_escapeXml(tags.join(', '))}</Genre>');
}
if (data.chapters != null && data.chapters!.isNotEmpty) {
final chaptersInfo = data.chapters!.map((chapter) =>
'${_escapeXml(chapter.title)}: ${chapter.start}-${chapter.end}'
).join('; ');
buffer.writeln(' <Notes>Chapters: $chaptersInfo</Notes>');
}
buffer.writeln(' <Manga>Unknown</Manga>');
buffer.writeln(' <BlackAndWhite>Unknown</BlackAndWhite>');
final now = DateTime.now();
buffer.writeln(' <Year>${now.year}</Year>');
buffer.writeln('</ComicInfo>');
return buffer.toString();
}
static String _escapeXml(String text) {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
static _compress(String src, String dst) async { static _compress(String src, String dst) async {
await ZipFile.compressFolderAsync(src, dst, 4); await ZipFile.compressFolderAsync(src, dst, 4);
} }
} }

View File

@@ -0,0 +1,25 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/services.dart';
Future<void> writeImageToClipboard(Uint8List imageBytes) async {
const channel = MethodChannel("venera/clipboard");
if (Platform.isWindows || Platform.isLinux) {
var image = await instantiateImageCodec(imageBytes);
var frame = await image.getNextFrame();
var data = await frame.image.toByteData(format: ImageByteFormat.rawRgba);
await channel.invokeMethod("writeImageToClipboard", {
"width": frame.image.width,
"height": frame.image.height,
"data": Uint8List.view(data!.buffer)
});
image.dispose();
} else if (Platform.isMacOS) {
await channel.invokeMethod("writeImageToClipboard", {
"data": imageBytes,
});
} else {
throw UnsupportedError("Clipboard image is not supported on this platform");
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:venera/components/components.dart';
import 'package:venera/components/window_frame.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -9,7 +11,7 @@ import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/data.dart'; import 'package:venera/utils/data.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:webdav_client/webdav_client.dart' hide File; import 'package:webdav_client/webdav_client.dart' hide File;
import 'package:rhttp/rhttp.dart' as rhttp; import 'package:venera/utils/translations.dart';
import 'io.dart'; import 'io.dart';
@@ -20,6 +22,12 @@ class DataSync with ChangeNotifier {
} }
LocalFavoritesManager().addListener(onDataChanged); LocalFavoritesManager().addListener(onDataChanged);
ComicSourceManager().addListener(onDataChanged); ComicSourceManager().addListener(onDataChanged);
if (App.isDesktop) {
Future.delayed(const Duration(seconds: 1), () {
var controller = WindowFrame.of(App.rootContext);
controller.addCloseListener(_handleWindowClose);
});
}
} }
void onDataChanged() { void onDataChanged() {
@@ -28,6 +36,28 @@ class DataSync with ChangeNotifier {
} }
} }
bool _handleWindowClose() {
if (_isUploading) {
_showWindowCloseDialog();
return false;
}
return true;
}
void _showWindowCloseDialog() async {
showLoadingDialog(
App.rootContext,
cancelButtonText: "Shut Down".tl,
onCancel: () => exit(0),
barrierDismissible: false,
message: "Uploading data...".tl,
);
while (_isUploading) {
await Future.delayed(const Duration(milliseconds: 50));
}
exit(0);
}
static DataSync? instance; static DataSync? instance;
factory DataSync() => instance ?? (instance = DataSync._()); factory DataSync() => instance ?? (instance = DataSync._());
@@ -90,18 +120,11 @@ class DataSync with ChangeNotifier {
String user = config[1]; String user = config[1];
String pass = config[2]; String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient( var client = newClient(
url, url,
user: user, user: user,
password: pass, password: pass,
adapter: RHttpAdapter( adapter: RHttpAdapter(),
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
),
),
); );
try { try {
@@ -162,18 +185,11 @@ class DataSync with ChangeNotifier {
String user = config[1]; String user = config[1];
String pass = config[2]; String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient( var client = newClient(
url, url,
user: user, user: user,
password: pass, password: pass,
adapter: RHttpAdapter( adapter: RHttpAdapter(),
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
),
),
); );
try { try {

View File

@@ -107,4 +107,15 @@ abstract class MapOrNull{
static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? i){ static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? i){
return i == null ? null : Map<K, V>.from(i); return i == null ? null : Map<K, V>.from(i);
} }
}
extension FutureExt<T> on Future<T>{
/// Wrap the future to make sure it will return at least the duration.
Future<T> minTime(Duration duration) async {
var res = await Future.wait([
this,
Future.delayed(duration),
]);
return res[0];
}
} }

View File

@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate'; import 'dart:isolate';
@@ -132,25 +131,28 @@ extension DirectoryExtension on Directory {
} }
/// Sanitize the file name. Remove invalid characters and trim the file name. /// Sanitize the file name. Remove invalid characters and trim the file name.
String sanitizeFileName(String fileName) { String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
if (fileName.endsWith('.')) { while (fileName.endsWith('.')) {
fileName = fileName.substring(0, fileName.length - 1); fileName = fileName.substring(0, fileName.length - 1);
} }
const maxLength = 255; var length = maxLength ?? 255;
if (dir != null) {
if (!dir.endsWith('/') && !dir.endsWith('\\')) {
dir = "$dir/";
}
length -= dir.length;
}
final invalidChars = RegExp(r'[<>:"/\\|?*]'); final invalidChars = RegExp(r'[<>:"/\\|?*]');
final sanitizedFileName = fileName.replaceAll(invalidChars, ' '); final sanitizedFileName = fileName.replaceAll(invalidChars, ' ');
var trimmedFileName = sanitizedFileName.trim(); var trimmedFileName = sanitizedFileName.trim();
if (trimmedFileName.isEmpty) { if (trimmedFileName.isEmpty) {
throw Exception('Invalid File Name: Empty length.'); throw Exception('Invalid File Name: Empty length.');
} }
while (true) { if (length <= 0) {
final bytes = utf8.encode(trimmedFileName); throw Exception('Invalid File Name: Max length is less than 0.');
if (bytes.length > maxLength) { }
trimmedFileName = if (trimmedFileName.length > length) {
trimmedFileName.substring(0, trimmedFileName.length - 1); trimmedFileName = trimmedFileName.substring(0, length);
} else {
break;
}
} }
return trimmedFileName; return trimmedFileName;
} }

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate'; import 'dart:isolate';
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/utils/image.dart'; import 'package:venera/utils/image.dart';
@@ -74,6 +75,9 @@ Future<Isolate> _runIsolate(
return Isolate.spawn<SendPort>( return Isolate.spawn<SendPort>(
(sendPort) => overrideIO( (sendPort) => overrideIO(
() async { () async {
if (App.isAndroid) {
await SAFTaskWorker().init();
}
var receivePort = ReceivePort(); var receivePort = ReceivePort();
sendPort.send(receivePort.sendPort); sendPort.send(receivePort.sendPort);

View File

@@ -35,8 +35,10 @@ extension TagsTranslation on String{
/// 对tag进行处理后进行翻译: 代表'或'的分割符'|', namespace. /// 对tag进行处理后进行翻译: 代表'或'的分割符'|', namespace.
static String _translateTags(String tag){ static String _translateTags(String tag){
if (tag.contains('|')) { if (tag.contains('|')) {
var splits = tag.split(' | '); var splits = tag.split('|');
return enTagsTranslations[splits[0]]??enTagsTranslations[splits[1]]??tag; return enTagsTranslations[splits[0].trim()]
?? enTagsTranslations[splits[1].trim()]
?? tag;
} else if(tag.contains(':')) { } else if(tag.contains(':')) {
var splits = tag.split(':'); var splits = tag.split(':');
if(_haveNamespace(splits[0])) { if(_haveNamespace(splits[0])) {

View File

@@ -5,15 +5,45 @@
#include <gdk/gdkx.h> #include <gdk/gdkx.h>
#endif #endif
#include <iostream>
#include "flutter/generated_plugin_registrant.h" #include "flutter/generated_plugin_registrant.h"
struct _MyApplication { struct _MyApplication {
GtkApplication parent_instance; GtkApplication parent_instance;
char** dart_entrypoint_arguments; char** dart_entrypoint_arguments;
FlMethodChannel* clipboard_channel;
}; };
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
static void handle_clipboard_call(FlMethodChannel* channel, FlMethodCall* call, gpointer user_data) {
if (strcmp(fl_method_call_get_name(call), "writeImageToClipboard") == 0) {
const auto args = fl_method_call_get_args(call);
const auto width = fl_value_get_int(fl_value_get_map_value(args, 0));
const auto height = fl_value_get_int(fl_value_get_map_value(args, 1));
const auto data = fl_value_get_uint8_list(fl_value_get_map_value(args, 2));
std::cout << width << " " << height << " " << data[0] << " " << data[1] << std::endl;
GBytes* bytes = g_bytes_new(data, width * height * 4);
GdkDisplay* display = gdk_display_get_default();
GtkClipboard* clipboard = gtk_clipboard_get_default(display);
GdkPixbuf* pixbuf = gdk_pixbuf_new_from_bytes(
bytes,
GDK_COLORSPACE_RGB,
true,
8,
width,
height,
width * 4
);
gtk_clipboard_set_image(clipboard, pixbuf);
fl_method_call_respond_success(call, fl_value_new_string("Ok"), nullptr);
}
}
// Implements GApplication::activate. // Implements GApplication::activate.
static void my_application_activate(GApplication* application) { static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application); MyApplication* self = MY_APPLICATION(application);
@@ -50,6 +80,7 @@ static void my_application_activate(GApplication* application) {
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);
GdkVisual* visual; GdkVisual* visual;
gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE);
gtk_window_set_decorated(window, FALSE);
visual = gdk_screen_get_rgba_visual(screen); visual = gdk_screen_get_rgba_visual(screen);
if (visual != NULL && gdk_screen_is_composited(screen)) { if (visual != NULL && gdk_screen_is_composited(screen)) {
gtk_widget_set_visual(GTK_WIDGET(window), visual); gtk_widget_set_visual(GTK_WIDGET(window), visual);
@@ -64,6 +95,14 @@ static void my_application_activate(GApplication* application) {
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view)); fl_register_plugins(FL_PLUGIN_REGISTRY(view));
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
self->clipboard_channel = fl_method_channel_new(
fl_engine_get_binary_messenger(fl_view_get_engine(view)),
"venera/clipboard", FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(
self->clipboard_channel, handle_clipboard_call, self, nullptr);
gtk_widget_hide(GTK_WIDGET(window)); gtk_widget_hide(GTK_WIDGET(window));
gtk_widget_grab_focus(GTK_WIDGET(view)); gtk_widget_grab_focus(GTK_WIDGET(view));
@@ -110,6 +149,7 @@ static void my_application_shutdown(GApplication* application) {
static void my_application_dispose(GObject* object) { static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object); MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
g_clear_object(&self->clipboard_channel);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object); G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
} }

View File

@@ -38,6 +38,31 @@ class AppDelegate: FlutterAppDelegate {
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }
} }
let clipboardChannel = FlutterMethodChannel(name: "venera/clipboard", binaryMessenger: controller.engine.binaryMessenger)
clipboardChannel.setMethodCallHandler { (call, result) in
switch call.method {
case "writeImageToClipboard":
guard let arguments = call.arguments as? [String: Any],
let data = arguments["data"] as? FlutterStandardTypedData else {
result(FlutterError(code: "INVALID_ARGUMENTS", message: "Invalid arguments", details: nil))
return
}
guard let image = NSImage(data: data.data) else {
result(FlutterError(code: "INVALID_IMAGE", message: "Could not create image from data", details: nil))
return
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.writeObjects([image])
result(true)
default:
result(FlutterMethodNotImplemented)
}
}
} }
func getDirectoryPath() { func getDirectoryPath() {

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" version: "2.13.0"
battery_plus: battery_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -149,8 +149,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "packages/desktop_webview_window" path: "packages/desktop_webview_window"
ref: HEAD ref: "7801fc582ecf5a7351632887891ecf309a7b2583"
resolved-ref: b8f7e94c576acf4ca3dce5b9f8fb8076e5eaca5e resolved-ref: "7801fc582ecf5a7351632887891ecf309a7b2583"
url: "https://github.com/wgh136/flutter_desktop_webview" url: "https://github.com/wgh136/flutter_desktop_webview"
source: git source: git
version: "0.2.4" version: "0.2.4"
@@ -170,6 +170,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
display_mode:
dependency: "direct main"
description:
name: display_mode
sha256: "8a381f3602a09dc4e96140a0df30808631468d6d0dfff7722f67b1f83757a7cc"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
dynamic_color: dynamic_color:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -178,14 +186,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.7.0" version: "1.7.0"
enough_convert:
dependency: "direct main"
description:
name: enough_convert
sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01
url: "https://pub.dev"
source: hosted
version: "1.6.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@@ -308,18 +324,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: flutter_inappwebview path: flutter_inappwebview
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "6.2.0-beta.3" version: "6.2.0-beta.3"
flutter_inappwebview_android: flutter_inappwebview_android:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_android path: flutter_inappwebview_android
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.2.0-beta.3" version: "1.2.0-beta.3"
flutter_inappwebview_internal_annotations: flutter_inappwebview_internal_annotations:
@@ -334,45 +350,45 @@ packages:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_ios path: flutter_inappwebview_ios
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.2.0-beta.3" version: "1.2.0-beta.3"
flutter_inappwebview_macos: flutter_inappwebview_macos:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_macos path: flutter_inappwebview_macos
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.2.0-beta.3" version: "1.2.0-beta.3"
flutter_inappwebview_platform_interface: flutter_inappwebview_platform_interface:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_platform_interface path: flutter_inappwebview_platform_interface
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.4.0-beta.3" version: "1.4.0-beta.3"
flutter_inappwebview_web: flutter_inappwebview_web:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_web path: flutter_inappwebview_web
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "1.2.0-beta.3" version: "1.2.0-beta.3"
flutter_inappwebview_windows: flutter_inappwebview_windows:
dependency: transitive dependency: transitive
description: description:
path: flutter_inappwebview_windows path: flutter_inappwebview_windows
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" ref: "3ef899b3db57c911b080979f1392253b835f98ab"
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676" resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
url: "https://github.com/pichillilorenzo/flutter_inappwebview" url: "https://github.com/venera-app/flutter_inappwebview"
source: git source: git
version: "0.7.0-beta.3" version: "0.7.0-beta.3"
flutter_lints: flutter_lints:
@@ -425,16 +441,16 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_rust_bridge name: flutter_rust_bridge
sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611" sha256: b416ff56002789e636244fb4cc449f587656eff995e5a7169457eb0593fcaddb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.9.0" version: "2.10.0"
flutter_saf: flutter_saf:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "690a03a954f1603e0149cfd479c8961b88f21336" ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
resolved-ref: "690a03a954f1603e0149cfd479c8961b88f21336" resolved-ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
url: "https://github.com/venera-app/flutter_saf" url: "https://github.com/venera-app/flutter_saf"
source: git source: git
version: "0.0.1" version: "0.0.1"
@@ -516,10 +532,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.20.2"
io: io:
dependency: transitive dependency: transitive
description: description:
@@ -540,10 +556,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.8" version: "10.0.9"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
@@ -580,10 +596,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: local_auth_android name: local_auth_android
sha256: "6763aaf8965f21822624cb2fd3c03d2a8b3791037b5efb0fe4b13e110f5afc92" sha256: "0abe4e72f55c785b28900de52a2522c86baba0988838b5dc22241b072ecccd74"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.46" version: "1.0.48"
local_auth_darwin: local_auth_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -725,8 +741,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6 ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
resolved-ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6 resolved-ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
url: "https://github.com/wgh136/photo_view" url: "https://github.com/wgh136/photo_view"
source: git source: git
version: "0.14.0" version: "0.14.0"
@@ -757,11 +773,12 @@ packages:
rhttp: rhttp:
dependency: "direct main" dependency: "direct main"
description: description:
name: rhttp path: rhttp
sha256: "037e9b59a68bb4ba664db1cbb4601e878cf5a2fc1cb3d0a9c58e3776609dec4d" ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
url: "https://pub.dev" resolved-ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
source: hosted url: "https://github.com/wgh136/rhttp"
version: "0.11.0" source: git
version: "0.12.0"
screen_retriever: screen_retriever:
dependency: transitive dependency: transitive
description: description:
@@ -1028,10 +1045,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.1" version: "15.0.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -1098,5 +1115,5 @@ packages:
source: hosted source: hosted
version: "0.0.12" version: "0.0.12"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" dart: ">=3.8.0 <4.0.0"
flutter: ">=3.29.2" flutter: ">=3.32.4"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.3.3+133 version: 1.4.5+145
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
flutter: 3.29.2 flutter: 3.32.4
dependencies: dependencies:
flutter: flutter:
@@ -29,7 +29,7 @@ dependencies:
photo_view: photo_view:
git: git:
url: https://github.com/wgh136/photo_view url: https://github.com/wgh136/photo_view
ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6 ref: a1255d1b5945aad4b7323303ec2ecdf0c90ffc4c
mime: ^2.0.0 mime: ^2.0.0
share_plus: ^10.1.4 share_plus: ^10.1.4
scrollable_positioned_list: scrollable_positioned_list:
@@ -43,11 +43,12 @@ dependencies:
git: git:
url: https://github.com/wgh136/flutter_desktop_webview url: https://github.com/wgh136/flutter_desktop_webview
path: packages/desktop_webview_window path: packages/desktop_webview_window
ref: 7801fc582ecf5a7351632887891ecf309a7b2583
flutter_inappwebview: flutter_inappwebview:
git: git:
url: https://github.com/pichillilorenzo/flutter_inappwebview url: https://github.com/venera-app/flutter_inappwebview
path: flutter_inappwebview path: flutter_inappwebview
ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676 ref: 3ef899b3db57c911b080979f1392253b835f98ab
app_links: ^6.4.0 app_links: ^6.4.0
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2 flutter_file_dialog: ^3.0.2
@@ -57,7 +58,11 @@ dependencies:
git: git:
url: https://github.com/venera-app/lodepng_flutter url: https://github.com/venera-app/lodepng_flutter
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1 ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
rhttp: ^0.11.0 rhttp:
git:
url: https://github.com/wgh136/rhttp
ref: 1f0ff50336062c5f809c256726dc55cd30b9ce59
path: rhttp
webdav_client: webdav_client:
git: git:
url: https://github.com/wgh136/webdav_client url: https://github.com/wgh136/webdav_client
@@ -67,7 +72,7 @@ dependencies:
flutter_saf: flutter_saf:
git: git:
url: https://github.com/venera-app/flutter_saf url: https://github.com/venera-app/flutter_saf
ref: 690a03a954f1603e0149cfd479c8961b88f21336 ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
shimmer_animation: ^2.1.0 shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1 flutter_memory_info: ^0.0.1
@@ -80,6 +85,8 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
yaml: ^3.1.3 yaml: ^3.1.3
enough_convert: ^1.6.0
display_mode: ^0.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

73
windows/build_arm64.iss Normal file
View File

@@ -0,0 +1,73 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Venera"
#define MyAppVersion "{{version}}"
#define MyAppPublisher "nyne"
#define MyAppURL "https://github.com/venera-app/venera"
#define MyAppExeName "venera.exe"
#define RootPath "{{root_path}}"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{1A39CB64-0A5B-478E-9590-978614C804A8}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
; Uncomment the following line to run in non administrative install mode (install for current user only.)
;PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
OutputDir={#RootPath}\build\windows
OutputBaseFilename=Venera-{#MyAppVersion}-windows-arm64-installer
SetupIconFile={#RootPath}\windows\runner\resources\app_icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=arm64
ArchitecturesAllowed=arm64
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "chinesesimplified"; MessagesFile: "{#RootPath}\windows\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#RootPath}\build\windows\arm64\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\flutter_inappwebview_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\file_selector_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\app_links_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\sqlite3.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\sqlite3_flutter_libs_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\flutter_qjs_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\desktop_webview_window_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\WebView2Loader.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\share_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\battery_plus_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\screen_retriever_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\window_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\local_auth_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\zip_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\rhttp.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\lodepng_flutter.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\dynamic_color_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\flutter_7zip.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#RootPath}\build\windows\arm64\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall

43
windows/build_arm64.py Normal file
View File

@@ -0,0 +1,43 @@
import platform
import subprocess
import os
import httpx
file = open('pubspec.yaml', 'r')
content = file.read()
file.close()
subprocess.run(["flutter", "build", "windows"], shell=True)
if os.path.exists("build/app-windows.zip"):
os.remove("build/app-windows.zip")
version = str.split(str.split(content, 'version: ')[1], '+')[0]
subprocess.run(["tar", "-a", "-c", "-f", f"build/windows/Venera-{version}-windows-arm64.zip", "-C", "build/windows/x64/runner/Release", "*"]
, shell=True)
issPath = "windows/build_arm64.iss"
issContent = ""
file = open(issPath, 'r')
issContent = file.read()
newContent = issContent
newContent = newContent.replace("{{version}}", version)
newContent = newContent.replace("{{root_path}}", os.getcwd())
file.close()
file = open(issPath, 'w')
file.write(newContent)
file.close()
if not os.path.exists("windows/ChineseSimplified.isl"):
# download ChineseSimplified.isl
url = "https://cdn.jsdelivr.net/gh/kira-96/Inno-Setup-Chinese-Simplified-Translation@latest/ChineseSimplified.isl"
response = httpx.get(url)
with open('windows/ChineseSimplified.isl', 'wb') as file:
file.write(response.content)
subprocess.run(["iscc", issPath], shell=True)
with open(issPath, 'w') as file:
file.write(issContent)

View File

@@ -10,11 +10,16 @@
#include <flutter/event_stream_handler_functions.h> #include <flutter/event_stream_handler_functions.h>
#include <flutter/standard_method_codec.h> #include <flutter/standard_method_codec.h>
#include "flutter/generated_plugin_registrant.h" #include "flutter/generated_plugin_registrant.h"
#include <thread>
#define _CRT_SECURE_NO_WARNINGS #define _CRT_SECURE_NO_WARNINGS
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&& mouseEvents = nullptr; std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&& mouseEvents = nullptr;
std::atomic<bool> mainThreadAlive(true);
std::atomic<std::chrono::steady_clock::time_point> lastHeartbeat(std::chrono::steady_clock::now());
std::thread* monitorThread = nullptr;
char* wideCharToMultiByte(wchar_t* pWCStrKey) char* wideCharToMultiByte(wchar_t* pWCStrKey)
{ {
size_t pSize = WideCharToMultiByte(CP_OEMCP, 0, pWCStrKey, wcslen(pWCStrKey), NULL, 0, NULL, NULL); size_t pSize = WideCharToMultiByte(CP_OEMCP, 0, pWCStrKey, wcslen(pWCStrKey), NULL, 0, NULL, NULL);
@@ -45,6 +50,22 @@ FlutterWindow::FlutterWindow(const flutter::DartProject& project)
FlutterWindow::~FlutterWindow() {} FlutterWindow::~FlutterWindow() {}
void monitorUIThread() {
const auto timeout = std::chrono::seconds(5);
while (mainThreadAlive.load()) {
auto now = std::chrono::steady_clock::now();
auto duration = now - lastHeartbeat.load();
if (duration > timeout) {
std::cerr << "The UI thread is dead. Terminate the application.";
std::exit(0);
}
std::this_thread::sleep_for(std::chrono::seconds(1));
}
}
bool FlutterWindow::OnCreate() { bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) { if (!Win32Window::OnCreate()) {
return false; return false;
@@ -78,6 +99,13 @@ bool FlutterWindow::OnCreate() {
result->Success(flutter::EncodableValue("No Proxy")); result->Success(flutter::EncodableValue("No Proxy"));
delete(res); delete(res);
} }
else if (call.method_name() == "heartBeat") {
if (monitorThread == nullptr) {
monitorThread = new std::thread{ monitorUIThread };
}
lastHeartbeat = std::chrono::steady_clock::now();
result->Success();
}
}); });
flutter::EventChannel<> channel2( flutter::EventChannel<> channel2(
@@ -102,6 +130,47 @@ bool FlutterWindow::OnCreate() {
channel2.SetStreamHandler(std::move(eventHandler)); channel2.SetStreamHandler(std::move(eventHandler));
const flutter::MethodChannel<> channel3(
flutter_controller_->engine()->messenger(), "venera/clipboard",
&flutter::StandardMethodCodec::GetInstance()
);
channel3.SetMethodCallHandler(
[](const flutter::MethodCall<>& call,const std::unique_ptr<flutter::MethodResult<>>& result) {
if(call.method_name() == "writeImageToClipboard"){
flutter::EncodableMap arguments = std::get<flutter::EncodableMap>(*call.arguments());
std::vector<uint8_t> data = std::get<std::vector<uint8_t>>(arguments["data"]);
std::int32_t width = std::get<std::int32_t>(arguments["width"]);
std::int32_t height = std::get<std::int32_t>(arguments["height"]);
// convert rgba to bgra
for (int i = 0; i < data.size()/4; i++) {
uint8_t temp = data[i * 4];
data[i * 4] = data[i * 4 + 2];
data[i * 4 + 2] = temp;
}
auto bitmap = CreateBitmap((int)width, (int)height, 1, 32, data.data());
if (!bitmap) {
result->Error("0", "Invalid Image Data");
return;
}
if (OpenClipboard(NULL))
{
EmptyClipboard();
SetClipboardData(CF_BITMAP, bitmap);
CloseClipboard();
result->Success();
}
else {
result->Error("Failed to open clipboard");
}
DeleteObject(bitmap);
}
});
SetChildContent(flutter_controller_->view()->GetNativeWindow()); SetChildContent(flutter_controller_->view()->GetNativeWindow());
flutter_controller_->engine()->SetNextFrameCallback([&]() { flutter_controller_->engine()->SetNextFrameCallback([&]() {
@@ -122,6 +191,10 @@ void FlutterWindow::OnDestroy() {
} }
Win32Window::OnDestroy(); Win32Window::OnDestroy();
if (monitorThread != nullptr) {
mainThreadAlive = false;
monitorThread->join();
}
} }
void mouse_side_button_listener(unsigned int input) void mouse_side_button_listener(unsigned int input)