mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Compare commits
106 Commits
v1.3.4
...
ccb03343f4
Author | SHA1 | Date | |
---|---|---|---|
ccb03343f4 | |||
![]() |
b9817ec030 | ||
![]() |
5ebb554e54 | ||
23ee79fe9d | |||
![]() |
85baac657a | ||
![]() |
cceca6b96f | ||
![]() |
b5b0dc85e3 | ||
![]() |
50044c4372 | ||
![]() |
5fd7f1b880 | ||
![]() |
058fde3f5a | ||
![]() |
a2d46123dd | ||
![]() |
01acc4f9de | ||
![]() |
856aae0769 | ||
![]() |
8eda8adcc8 | ||
defd4b8624 | |||
b2a164e066 | |||
a46ceebf19 | |||
cc08445f13 | |||
93f7f72d07 | |||
20f7ab4866 | |||
54363919cd | |||
182a821fc5 | |||
8868c6edb3 | |||
![]() |
fffbb4ed23 | ||
![]() |
b057be0311 | ||
![]() |
fc5fed1707 | ||
![]() |
8525f5318f | ||
![]() |
d58cafc4a0 | ||
23afafd1d6 | |||
![]() |
3b6e0adbbb | ||
20a57c7a36 | |||
665f50ed2a | |||
55733ef505 | |||
0c46214619 | |||
749a1a47fb | |||
76e9ef87d4 | |||
dcd6466547 | |||
ed70fdba93 | |||
ded0068ea6 | |||
![]() |
7dc6be622a | ||
![]() |
88f093f7e5 | ||
8f357b3e6c | |||
9ee82975e8 | |||
![]() |
9f048685e4 | ||
![]() |
bc1f5e11b5 | ||
1f2147ef72 | |||
fba365fd93 | |||
a5e3fbaee5 | |||
190e645a12 | |||
![]() |
8a83ff5367 | ||
6e14942dab | |||
146fc70143 | |||
b37ea01aca | |||
bf7b90313a | |||
929c1a9d91 | |||
9ff68d0701 | |||
dfd15ed34a | |||
![]() |
dfe2a0db6a | ||
c6714f79b6 | |||
552a42fb27 | |||
af456c52f1 | |||
f38129133a | |||
17e2696ca4 | |||
9d6999af33 | |||
ae5548918c | |||
92d22c977c | |||
8cc3702e1a | |||
3131ce52a7 | |||
62e4056f4a | |||
a29a7cbaf3 | |||
7bdab7ade7 | |||
ea99e87afb | |||
0d3fde9457 | |||
aa9f4dae82 | |||
6877aa120f | |||
d25d72a5f7 | |||
![]() |
97768b4945 | ||
2481780ab3 | |||
![]() |
49481bfa6a | ||
211850d73e | |||
fcf0334d55 | |||
aa8eec5792 | |||
6eb0060dd6 | |||
c096f5a2d8 | |||
554b9f2a77 | |||
f87afbe397 | |||
6ff30f8ac3 | |||
118941f239 | |||
d91bca6913 | |||
463ad5b5bc | |||
971fc1da92 | |||
37af7e266a | |||
276e23354d | |||
3da00595b7 | |||
![]() |
d3c115ee0c | ||
dcc94c5b3d | |||
a116b5b615 | |||
05fcb23a4d | |||
daa6e8ce18 | |||
8665994572 | |||
90441af989 | |||
7631fab86b | |||
cd9b07bb3e | |||
6c179ceb95 | |||
ec48dbef57 | |||
cd1cc1229e |
@@ -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'
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
|
@@ -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": "初始页面",
|
||||||
@@ -378,7 +380,31 @@
|
|||||||
"Page": "页面",
|
"Page": "页面",
|
||||||
"Jump": "跳转",
|
"Jump": "跳转",
|
||||||
"Copy Image": "复制图片",
|
"Copy Image": "复制图片",
|
||||||
"A valid WebDav directory URL": "有效的WebDav目录URL"
|
"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": "首頁",
|
||||||
@@ -520,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": "查看更多",
|
||||||
@@ -615,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": "本機收藏暫不支援",
|
||||||
@@ -675,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": "自訂圖片處理",
|
||||||
@@ -723,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": "更新",
|
||||||
@@ -741,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": "初始頁面",
|
||||||
@@ -759,6 +787,30 @@
|
|||||||
"Page": "頁面",
|
"Page": "頁面",
|
||||||
"Jump": "跳轉",
|
"Jump": "跳轉",
|
||||||
"Copy Image": "複製圖片",
|
"Copy Image": "複製圖片",
|
||||||
"A valid WebDav directory URL": "有效的WebDav目錄URL"
|
"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
BIN
debian/gui/venera.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 64 KiB |
@@ -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
|
||||||
|
@@ -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>
|
||||||
|
@@ -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
|
||||||
|
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -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(
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -82,10 +82,7 @@ class _WindowFrameState extends State<WindowFrame> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
windowManager.close().then((_) {
|
exit(0);
|
||||||
// Make sure the app exits when the window is closed.
|
|
||||||
exit(0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -564,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
|
||||||
|
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
|||||||
export "context.dart";
|
export "context.dart";
|
||||||
|
|
||||||
class _App {
|
class _App {
|
||||||
final version = "1.3.4";
|
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 {
|
||||||
|
@@ -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';
|
||||||
@@ -110,21 +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']);
|
||||||
}
|
}
|
||||||
searchHistory = List.from(json['searchHistory']);
|
catch(e) {
|
||||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
Log.error("Appdata", "Failed to load appdata", e);
|
||||||
if (await implicitDataFile.exists()) {
|
Log.info("Appdata", "Resetting appdata");
|
||||||
try {
|
file.deleteIgnoreError();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||||
|
if (await implicitDataFile.exists()) {
|
||||||
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
||||||
}
|
}
|
||||||
catch(_) {
|
}
|
||||||
// ignore
|
catch (e) {
|
||||||
}
|
Log.error("Appdata", "Failed to load implicit data", e);
|
||||||
|
Log.info("Appdata", "Resetting implicit data");
|
||||||
|
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||||
|
implicitDataFile.deleteIgnoreError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,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
|
||||||
@@ -177,13 +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,
|
'showPageNumberInReader': true,
|
||||||
|
'showSingleImageOnFirstPage': false,
|
||||||
|
'enableDoubleTapToZoom': true,
|
||||||
|
'reverseChapterOrder': false,
|
||||||
|
'showSystemStatusBar': false,
|
||||||
};
|
};
|
||||||
|
|
||||||
operator [](String key) {
|
operator [](String key) {
|
||||||
@@ -192,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
|
||||||
|
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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) {
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
|
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -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((e) => e.trim().startsWith("class "));
|
|
||||||
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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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.
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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';
|
||||||
@@ -194,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()
|
||||||
@@ -371,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":
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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(
|
||||||
|
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
|
@@ -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()
|
||||||
|
@@ -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
60
lib/network/proxy.dart
Normal 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;
|
||||||
|
}
|
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@@ -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();
|
||||||
}
|
}
|
||||||
|
@@ -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() {
|
||||||
|
@@ -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)),
|
||||||
|
@@ -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';
|
||||||
@@ -411,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";
|
||||||
}
|
}
|
||||||
@@ -461,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,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(),
|
||||||
],
|
],
|
||||||
|
@@ -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,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@@ -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';
|
||||||
|
@@ -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),
|
||||||
)
|
)
|
||||||
|
@@ -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;
|
||||||
|
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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>(
|
||||||
|
@@ -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;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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: () {
|
||||||
|
@@ -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),
|
||||||
|
@@ -306,7 +306,8 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// prevent dirty data
|
// prevent dirty data
|
||||||
var comic = LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
|
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),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -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 <
|
||||||
@@ -287,6 +293,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
|||||||
text: "Copy Image".tl,
|
text: "Copy Image".tl,
|
||||||
onClick: () => copyImage(location),
|
onClick: () => copyImage(location),
|
||||||
),
|
),
|
||||||
|
if (!reader.isLoading)
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.download_outlined,
|
||||||
|
text: "Save Image".tl,
|
||||||
|
onClick: () => saveImage(location),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -319,6 +331,17 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
|||||||
context.showMessage(message: "No Image");
|
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 {
|
||||||
|
@@ -21,6 +21,12 @@ 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;
|
||||||
@@ -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,15 +123,22 @@ 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>>{};
|
var imageStates = <State<ComicImage>>{};
|
||||||
|
|
||||||
@@ -124,24 +151,51 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,24 +238,23 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
child: const SizedBox(),
|
child: const SizedBox(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
int pageIndex = index - 1;
|
var (startIndex, endIndex) = getPageImagesRange(index);
|
||||||
int startIndex = pageIndex * reader.imagesPerPage;
|
|
||||||
int endIndex = math.min(
|
|
||||||
startIndex + reader.imagesPerPage, reader.images!.length);
|
|
||||||
List<String> pageImages =
|
List<String> pageImages =
|
||||||
reader.images!.sublist(startIndex, endIndex);
|
reader.images!.sublist(startIndex, endIndex);
|
||||||
|
|
||||||
cached[index] = true;
|
|
||||||
cache(index);
|
cache(index);
|
||||||
|
|
||||||
photoViewControllers[index] ??= PhotoViewController();
|
photoViewControllers[index] ??= PhotoViewController();
|
||||||
|
|
||||||
if (reader.imagesPerPage == 1) {
|
if (reader.imagesPerPage == 1 || pageImages.length == 1) {
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
filterQuality: FilterQuality.medium,
|
filterQuality: FilterQuality.medium,
|
||||||
controller: photoViewControllers[index],
|
controller: photoViewControllers[index],
|
||||||
imageProvider:
|
imageProvider: _createImageProviderFromKey(
|
||||||
_createImageProviderFromKey(pageImages[0], context),
|
pageImages[0],
|
||||||
|
context,
|
||||||
|
startIndex + 1,
|
||||||
|
),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, error, s, retry) {
|
errorBuilder: (_, error, s, retry) {
|
||||||
return NetworkError(message: error.toString(), retry: retry);
|
return NetworkError(message: error.toString(), retry: retry);
|
||||||
@@ -210,10 +263,11 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PhotoViewGalleryPageOptions.customChild(
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
|
childSize: reader.size * 2,
|
||||||
controller: photoViewControllers[index],
|
controller: photoViewControllers[index],
|
||||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||||
maxScale: PhotoViewComputedScale.covered * 10.0,
|
maxScale: PhotoViewComputedScale.covered * 10.0,
|
||||||
child: buildPageImages(pageImages),
|
child: buildPageImages(pageImages, startIndex),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -243,12 +297,19 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
reader.setPage(i);
|
reader.setPage(i);
|
||||||
context.readerScaffold.update();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -266,7 +327,11 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
child: ComicImage(
|
child: ComicImage(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
image: _createImageProviderFromKey(images[0], context),
|
image: _createImageProviderFromKey(
|
||||||
|
images[0],
|
||||||
|
context,
|
||||||
|
startIndex + 1,
|
||||||
|
),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
alignment: axis == Axis.vertical
|
alignment: axis == Axis.vertical
|
||||||
? Alignment.bottomCenter
|
? Alignment.bottomCenter
|
||||||
@@ -279,7 +344,11 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
child: ComicImage(
|
child: ComicImage(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
image: _createImageProviderFromKey(images[1], context),
|
image: _createImageProviderFromKey(
|
||||||
|
images[1],
|
||||||
|
context,
|
||||||
|
startIndex + 2,
|
||||||
|
),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
alignment: axis == Axis.vertical
|
alignment: axis == Axis.vertical
|
||||||
? Alignment.topCenter
|
? Alignment.topCenter
|
||||||
@@ -291,8 +360,9 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
imageWidgets = images.map((imageKey) {
|
imageWidgets = images.map((imageKey) {
|
||||||
|
startIndex++;
|
||||||
ImageProvider imageProvider =
|
ImageProvider imageProvider =
|
||||||
_createImageProviderFromKey(imageKey, context);
|
_createImageProviderFromKey(imageKey, context, startIndex);
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: ComicImage(
|
child: ComicImage(
|
||||||
image: imageProvider,
|
image: imageProvider,
|
||||||
@@ -343,10 +413,19 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
}
|
}
|
||||||
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;
|
isLongPressing = true;
|
||||||
}
|
}
|
||||||
@@ -392,34 +471,24 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
keyRepeatTimer = null;
|
keyRepeatTimer = null;
|
||||||
}
|
}
|
||||||
if (forward == true) {
|
if (forward == true) {
|
||||||
controller.nextPage(
|
reader.toPage(reader.page+1);
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.ease,
|
|
||||||
);
|
|
||||||
} else if (forward == false) {
|
} else if (forward == false) {
|
||||||
controller.previousPage(
|
reader.toPage(reader.page-1);
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.ease,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
|
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
|
||||||
keyRepeatTimer = Timer.periodic(
|
keyRepeatTimer = Timer.periodic(
|
||||||
const Duration(milliseconds: 100),
|
reader.enablePageAnimation
|
||||||
|
? const Duration(milliseconds: 200)
|
||||||
|
: const Duration(milliseconds: 50),
|
||||||
(timer) {
|
(timer) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
return;
|
return;
|
||||||
} else if (forward == true) {
|
} else if (forward == true) {
|
||||||
controller.nextPage(
|
reader.toPage(reader.page+1);
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
curve: Curves.ease,
|
|
||||||
);
|
|
||||||
} else if (forward == false) {
|
} else if (forward == false) {
|
||||||
controller.previousPage(
|
reader.toPage(reader.page-1);
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
curve: Curves.ease,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -437,6 +506,19 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
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;
|
String? imageKey;
|
||||||
if (reader.imagesPerPage == 1) {
|
if (reader.imagesPerPage == 1) {
|
||||||
imageKey = reader.images![reader.page - 1];
|
imageKey = reader.images![reader.page - 1];
|
||||||
@@ -447,14 +529,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (imageKey == null) return null;
|
return imageKey;
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,6 +683,13 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool onScaleUpdate([double? scale]) {
|
bool onScaleUpdate([double? scale]) {
|
||||||
|
if (prepareToNextChapter || prepareToPrevChapter) {
|
||||||
|
setState(() {
|
||||||
|
prepareToPrevChapter = false;
|
||||||
|
prepareToNextChapter = false;
|
||||||
|
});
|
||||||
|
context.readerScaffold.setFloatingButton(0);
|
||||||
|
}
|
||||||
var isZoomedIn = (scale ?? photoViewController.scale) != 1.0;
|
var isZoomedIn = (scale ?? photoViewController.scale) != 1.0;
|
||||||
if (isZoomedIn != this.isZoomedIn) {
|
if (isZoomedIn != this.isZoomedIn) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -731,7 +813,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
}
|
}
|
||||||
Offset offset;
|
Offset offset;
|
||||||
var sp = scrollController.position;
|
var sp = scrollController.position;
|
||||||
if (sp.pixels < sp.minScrollExtent || sp.pixels > sp.maxScrollExtent) {
|
if (sp.pixels <= sp.minScrollExtent ||
|
||||||
|
sp.pixels >= sp.maxScrollExtent) {
|
||||||
offset = Offset(value.dx, value.dy);
|
offset = Offset(value.dx, value.dy);
|
||||||
} else {
|
} else {
|
||||||
if (reader.mode == ReaderMode.continuousTopToBottom) {
|
if (reader.mode == ReaderMode.continuousTopToBottom) {
|
||||||
@@ -759,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 &&
|
||||||
@@ -800,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) {
|
||||||
@@ -882,9 +968,19 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
double target = photoViewController.getInitialScale!.call()! * 1.75;
|
double target = photoViewController.getInitialScale!.call()! * 1.75;
|
||||||
|
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(0, 0),
|
zoomPosition,
|
||||||
);
|
);
|
||||||
onScaleUpdate(target);
|
onScaleUpdate(target);
|
||||||
isLongPressing = true;
|
isLongPressing = true;
|
||||||
@@ -944,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,
|
||||||
);
|
);
|
||||||
@@ -967,12 +1063,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
||||||
String? imageKey;
|
var imageKey = getImageKeyByOffset(offset);
|
||||||
for (var imageState in imageStates) {
|
|
||||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
|
||||||
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (imageKey == null) return null;
|
if (imageKey == null) return null;
|
||||||
if (imageKey.startsWith("file://")) {
|
if (imageKey.startsWith("file://")) {
|
||||||
return await File(imageKey.substring(7)).readAsBytes();
|
return await File(imageKey.substring(7)).readAsBytes();
|
||||||
@@ -982,10 +1073,24 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
.readAsBytes();
|
.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,
|
||||||
@@ -999,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,
|
||||||
|
@@ -29,6 +29,7 @@ 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/clipboard_image.dart';
|
||||||
import 'package:venera/utils/data_sync.dart';
|
import 'package:venera/utils/data_sync.dart';
|
||||||
@@ -110,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;
|
||||||
|
|
||||||
@@ -124,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;
|
||||||
|
|
||||||
@@ -153,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();
|
||||||
}
|
}
|
||||||
@@ -167,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,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())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,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) {
|
||||||
@@ -309,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;
|
||||||
@@ -324,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;
|
||||||
@@ -342,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,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
|
||||||
@@ -374,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,4 +668,6 @@ abstract interface class _ImageViewController {
|
|||||||
bool handleOnTap(Offset location);
|
bool handleOnTap(Offset location);
|
||||||
|
|
||||||
Future<Uint8List?> getImageByOffset(Offset offset);
|
Future<Uint8List?> getImageByOffset(Offset offset);
|
||||||
|
|
||||||
|
String? getImageKeyByOffset(Offset offset);
|
||||||
}
|
}
|
||||||
|
@@ -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;
|
||||||
@@ -208,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(
|
||||||
@@ -222,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;
|
||||||
@@ -378,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(
|
||||||
@@ -570,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;
|
||||||
}
|
}
|
||||||
@@ -667,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;
|
||||||
}
|
}
|
||||||
@@ -750,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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -761,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 {
|
||||||
@@ -941,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -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,
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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),
|
||||||
),
|
),
|
||||||
|
95
lib/pages/settings/debug.dart
Normal file
95
lib/pages/settings/debug.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -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(),
|
||||||
|
@@ -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",
|
||||||
|
@@ -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()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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];
|
||||||
|
}
|
||||||
}
|
}
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
|
|
||||||
|
@@ -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])) {
|
||||||
|
@@ -80,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);
|
||||||
|
76
pubspec.lock
76
pubspec.lock
@@ -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,6 +186,14 @@ 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:
|
||||||
@@ -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"
|
||||||
@@ -758,11 +774,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: rhttp
|
path: rhttp
|
||||||
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
|
||||||
resolved-ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
resolved-ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
|
||||||
url: "https://github.com/wgh136/rhttp"
|
url: "https://github.com/wgh136/rhttp"
|
||||||
source: git
|
source: git
|
||||||
version: "0.11.0"
|
version: "0.12.0"
|
||||||
screen_retriever:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1099,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"
|
||||||
|
20
pubspec.yaml
20
pubspec.yaml
@@ -2,11 +2,11 @@ name: venera
|
|||||||
description: "A comic app."
|
description: "A comic app."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.3.4+134
|
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:
|
||||||
@@ -46,9 +46,9 @@ dependencies:
|
|||||||
ref: 7801fc582ecf5a7351632887891ecf309a7b2583
|
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
|
||||||
@@ -58,10 +58,10 @@ dependencies:
|
|||||||
git:
|
git:
|
||||||
url: https://github.com/venera-app/lodepng_flutter
|
url: https://github.com/venera-app/lodepng_flutter
|
||||||
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
||||||
rhttp:
|
rhttp:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/wgh136/rhttp
|
url: https://github.com/wgh136/rhttp
|
||||||
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
ref: 1f0ff50336062c5f809c256726dc55cd30b9ce59
|
||||||
path: rhttp
|
path: rhttp
|
||||||
webdav_client:
|
webdav_client:
|
||||||
git:
|
git:
|
||||||
@@ -72,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
|
||||||
@@ -85,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:
|
||||||
|
@@ -2,11 +2,11 @@
|
|||||||
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
|
||||||
|
|
||||||
#define MyAppName "Venera"
|
#define MyAppName "Venera"
|
||||||
#define MyAppVersion "1.3.4"
|
#define MyAppVersion "{{version}}"
|
||||||
#define MyAppPublisher "nyne"
|
#define MyAppPublisher "nyne"
|
||||||
#define MyAppURL "https://github.com/venera-app/venera"
|
#define MyAppURL "https://github.com/venera-app/venera"
|
||||||
#define MyAppExeName "venera.exe"
|
#define MyAppExeName "venera.exe"
|
||||||
#define RootPath "D:\code\venera"
|
#define RootPath "{{root_path}}"
|
||||||
|
|
||||||
[Setup]
|
[Setup]
|
||||||
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
|
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
|
||||||
|
@@ -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(
|
||||||
@@ -163,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)
|
||||||
|
Reference in New Issue
Block a user