82 Commits

Author SHA1 Message Date
nyne
169676fd9e Merge pull request #274 from venera-app/v1.3.3-dev
V1.3.3
2025-03-18 17:48:21 +08:00
332497cf90 Increase scroll indicator size. 2025-03-18 17:42:15 +08:00
5f15c08eef Update version code and dependencies. Close #265 2025-03-18 17:09:58 +08:00
3f6b3152b2 [Android] Support opening search page with shared text. Close #261 2025-03-18 16:19:32 +08:00
f5b3b36acb Fix webdav proxy. 2025-03-18 13:08:10 +08:00
fd8607777e Fix deleted comic sources will be restore after webdav sync. 2025-03-18 11:06:30 +08:00
fa951cac95 Support multiple webdav authentication methods. Close #271 2025-03-18 10:52:01 +08:00
55ad652191 Fix an issue where it was impossible to read a new chapter of a downloaded comic. Close #256 2025-03-17 19:18:18 +08:00
533497ead1 Improve aggregated search ui. 2025-03-17 18:51:27 +08:00
角砂糖
00cdc18ddd NDK r28 (#267) 2025-03-16 08:02:05 +08:00
角砂糖
474d9aa6f1 Default to displaying the last read chapter group. (#264) 2025-03-16 07:58:28 +08:00
ffa0c8f887 Prefer noto fonts. 2025-03-15 17:53:52 +08:00
0f3f3ea270 Add continuous mode for comic list. 2025-03-15 17:43:43 +08:00
ɴᴇᴋᴏ
b752caa079 Update translation.json (#257) 2025-03-14 09:34:30 +08:00
309df2143b Make sure the follow updates is initialized correctly. 2025-03-13 16:09:17 +08:00
8e964468ea Fix an issue where error message from webdav is null. 2025-03-13 15:54:18 +08:00
ca8f09807b Add a refresh button to network favorites page. 2025-03-09 16:38:39 +08:00
68b214e295 Fix comic list was not updated after delete a comic in favorites page. 2025-03-09 16:34:50 +08:00
00c0a64de0 Improve scroll bar of favorites page. 2025-03-09 12:55:36 +08:00
nyne
dbc2c27db0 Merge pull request #245 from venera-app/v1.3.2-dev
v1.3.2
2025-03-06 13:16:57 +08:00
fffb3dc973 Use rhttp to make webdav requests. 2025-03-05 21:47:28 +08:00
0ca8a28639 Update version code. 2025-03-05 17:46:07 +08:00
6426ebaf16 Add initial page setting. Close #240 2025-03-05 17:44:20 +08:00
316f61394d Try to fix #241 2025-03-04 22:17:21 +08:00
04ab75cf92 Fix WindowFrame on Android. 2025-03-04 21:50:22 +08:00
4828a57e1a Improve follow updates. Close #235 2025-03-04 19:30:24 +08:00
d089163220 Fix comment overflow. Close #237 2025-03-04 15:36:02 +08:00
7b5c13200d Improve init. Close #236 2025-03-04 15:30:40 +08:00
0f6874f8d7 Close reader when user click the close button on window frame. 2025-03-03 20:50:11 +08:00
4af15b9139 Improve fullscreen 2025-03-03 19:28:20 +08:00
9fe49217dc Add error status to data sync component. 2025-03-03 19:04:16 +08:00
76c56964a5 Fix archive download when using custom download path on Android. 2025-03-02 17:40:04 +08:00
e8afbca7b2 Fix empty reader page when current chapter is last chapter of the chapter group. 2025-03-01 09:29:04 +08:00
5843d7c919 Fix sidebar. 2025-03-01 09:20:33 +08:00
shenmo
de98dfaa1b Fix font problem on Linux ARM64 (#231) 2025-02-27 23:04:36 +08:00
AnxuNA
30cbfb54ef Fix Fullscreen switch (#229)
* Fix Fullscreen switch for windows

* Fix Fullscreen switch for windows
2025-02-26 10:43:28 +08:00
c633021963 Merge remote-tracking branch 'origin/master' into v1.3.2-dev 2025-02-26 09:34:02 +08:00
nyne
4640831e69 Disable blank issue 2025-02-25 21:43:30 +08:00
af7a7c220e Update issue templates. 2025-02-25 21:34:59 +08:00
fd19f6bf7d Fixed crash caused by empty chapter list. 2025-02-23 20:24:43 +08:00
96b4125613 Update issue template 2025-02-23 18:40:07 +08:00
nyne
587c5d8040 Merge pull request #222 from venera-app/v1.3.1-dev
V1.3.1
2025-02-22 21:36:44 +08:00
nyne
72730361c8 Merge branch 'master' into v1.3.1-dev 2025-02-22 21:34:56 +08:00
38d5563534 Show download status on reader chapter view. 2025-02-22 21:08:30 +08:00
5a886f7504 Improve ui 2025-02-22 19:31:23 +08:00
1464b7d5e5 Improve changing chapter gesture with continuous mode. 2025-02-22 11:33:58 +08:00
5645d805f5 Improve changing chapter gesture with continuous mode. 2025-02-22 10:41:56 +08:00
7fe81ae418 Improve switch pages gesture with gallery mode. 2025-02-21 22:53:01 +08:00
be0daddd82 Notify changes after the updating is completed. 2025-02-21 17:01:12 +08:00
buste
3efc4794d0 Fix webdav prevent immediate upload when webdavAutoSync toggle (#221) 2025-02-21 16:46:22 +08:00
角砂糖
4eff50dbed Fix history of maxPage when maxPage in reader is 1 (#220)
Due to the change of page and maxPage before, the history of maxPage should be real maxPage.
If not, when maxPage in reader is 1, the maxPage in history will be none or the last ep's real maxPage.
2025-02-21 14:25:38 +08:00
f3c191f7f3 update dependencies. 2025-02-21 14:24:36 +08:00
a014587a94 Do not switch chapters if the current chapter is the first or last chapter in the chapter group. 2025-02-21 14:13:05 +08:00
bf51cd5cee Improve checking follow updates. 2025-02-21 13:36:14 +08:00
3f10473fb6 Fix invalid number of available source updates. 2025-02-21 13:21:03 +08:00
fba49233c8 Refactor 2025-02-21 13:14:28 +08:00
8adf61b54f Fix multi-image mode 2025-02-21 13:00:15 +08:00
nyne
e829f567e5 Revert "Improve WebDAV data sync version handling and force sync (#207)" (#218)
This reverts commit a630771f0b.
2025-02-21 10:25:06 +08:00
ɴᴇᴋᴏ
701573ee19 Update zh-TW (#217) 2025-02-21 09:13:15 +08:00
角砂糖
7b601058eb Change history of page and maxPage (#216) 2025-02-21 09:12:53 +08:00
角砂糖
24b7319bb5 Add option to differentiate images per page for landscape and portrait orientations (#214) 2025-02-21 09:12:01 +08:00
角砂糖
26adfc6c4f Fix missing chapterGroup when continueRead (#213) 2025-02-21 09:09:01 +08:00
6db00eaf71 Fix variable type 2025-02-20 23:05:54 +08:00
buste
bbf31a4bbe Add AppImage build support (#210) 2025-02-20 22:59:08 +08:00
36ab104c81 Update version code. 2025-02-20 19:33:47 +08:00
a63d458707 Improve history with grouped chapters. 2025-02-20 19:16:26 +08:00
011619340f Fixed the update time was not updated after checking. 2025-02-20 16:21:39 +08:00
40b9b5b329 Fixed downloading cover. Close #208 2025-02-20 13:25:56 +08:00
edc2cb066b Fixed download speed display. 2025-02-20 13:16:09 +08:00
bd5d10e919 Improve comic chapters. 2025-02-20 13:08:55 +08:00
2b3c7a8564 Add a button to mark all comics as read. 2025-02-19 22:51:02 +08:00
buste
a630771f0b Improve WebDAV data sync version handling and force sync (#207)
* Fix WebDAV auto sync default setting initialization

* Improve WebDAV data sync version handling and  force sync
2025-02-19 22:43:23 +08:00
ee0da9a26a Fix the wrong sorting of follow_updates_page. Close #206 2025-02-19 22:38:18 +08:00
a471e79ef2 Improve init 2025-02-19 17:32:05 +08:00
26a1d68913 Fix invalid template. 2025-02-19 16:45:55 +08:00
buste
d0d27206cd Fix thumbnail tap functionality to navigate to the correct reader page (#205) 2025-02-19 11:09:18 +08:00
buste
90f0c9dab3 Fix comic menu cannot work in history_page when use mobile device (#204) 2025-02-18 23:11:51 +08:00
buste
0c54a9be11 Improve WebDAV: add auto sync option and improve settings UI (#203) 2025-02-18 22:22:09 +08:00
5fb0d2327d Improve history display. Part of #200 2025-02-18 19:39:15 +08:00
d73e152cec Fixed an issue where clicking on local comics on the home page would cause the history to be lost. Close #196 2025-02-18 18:49:25 +08:00
bd53416968 Improve webdav settings 2025-02-18 18:37:57 +08:00
buste
c28f4d40c2 Add selection in history page and refresh home page after history changed (#199) 2025-02-18 11:30:30 +08:00
69 changed files with 2777 additions and 1100 deletions

View File

@@ -8,9 +8,31 @@ body:
value: |
Thank you for reporting a problem, please complete the title and fill in the following information.
感谢您的反馈,请填写完整标题并填写以下信息。
**Please do not report any issues related to config files.**
To report a bug related to the config file, please send it to the [config repository](https://github.com/venera-app/venera-configs).
**请不要报告与配置文件相关的任何问题。**
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
- type: dropdown
id: bugType
attributes:
label: Bug type
description: What type of bug are you reporting?
options:
- Crash
- UI
- Performance
- Security
- Reader
- JS Engine
- Comic Source
- Other
validations:
required: true
- type: textarea
id: what-happened
attributes:

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -7,6 +7,16 @@ body:
attributes:
value: |
Welcome to make a feature request, please fill in the following information after completing the title.
欢迎提出功能建议,请填写完整标题后填写以下信息。
**Please do not report any issues related to config files.**
**请不要报告与配置文件相关的任何问题。**
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
- type: textarea
id: what-happened
attributes:

View File

@@ -1,9 +0,0 @@
name: other
description: Other contents
body:
- type: textarea
id: what-happened
attributes:
label: Content
validations:
required: true

View File

@@ -148,6 +148,45 @@ jobs:
sudo rm -rf build/linux/arch/pkg
sudo rm -rf build/linux/arch/src
sudo rm -rf build/linux/arch/PKGBUILD
- name: Build AppImage
run: |
sudo apt-get install -y libfuse2
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x appimagetool
mkdir -p Venera.AppDir
cp -r build/linux/x64/release/bundle/* Venera.AppDir/
cat > Venera.AppDir/venera.desktop << EOF
[Desktop Entry]
Name=Venera
Exec=venera
Icon=venera
Type=Application
Categories=Utility;
EOF
cp assets/app_icon.png Venera.AppDir/venera.png
cat > Venera.AppDir/AppRun << EOF
#!/bin/sh
HERE=\$(dirname \$(readlink -f "\${0}"))
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
exec "\${HERE}"/venera "\$@"
EOF
chmod +x Venera.AppDir/AppRun
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
./appimagetool Venera.AppDir Venera-${APP_VERSION}-x86_64.AppImage
mkdir -p build/linux/appimage
mv Venera-${APP_VERSION}-x86_64.AppImage build/linux/appimage/
- uses: actions/upload-artifact@v4
with:
name: appimage_build
path: build/linux/appimage
- uses: actions/upload-artifact@v4
with:
name: deb_build
@@ -170,6 +209,45 @@ jobs:
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py arm64
- name: Build AppImage
run: |
sudo apt-get install -y libfuse2
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage"
chmod +x appimagetool
mkdir -p Venera.AppDir
cp -r build/linux/arm64/release/bundle/* Venera.AppDir/
cat > Venera.AppDir/venera.desktop << EOF
[Desktop Entry]
Name=Venera
Exec=venera
Icon=venera
Type=Application
Categories=Utility;
EOF
cp assets/app_icon.png Venera.AppDir/venera.png
cat > Venera.AppDir/AppRun << EOF
#!/bin/sh
HERE=\$(dirname \$(readlink -f "\${0}"))
export PATH="\${HERE}"/usr/bin/:"\${HERE}"/usr/sbin/:"\${HERE}"/usr/games/:"\${HERE}"/bin/:"\${HERE}"/sbin/:\${PATH}
export LD_LIBRARY_PATH="\${HERE}"/usr/lib/:\${LD_LIBRARY_PATH}
export XDG_DATA_DIRS="\${HERE}"/usr/share/:\${XDG_DATA_DIRS}
exec "\${HERE}"/venera "\$@"
EOF
chmod +x Venera.AppDir/AppRun
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
./appimagetool Venera.AppDir Venera-${APP_VERSION}-aarch64.AppImage
mkdir -p build/linux/appimage
mv Venera-${APP_VERSION}-aarch64.AppImage build/linux/appimage/
- uses: actions/upload-artifact@v4
with:
name: appimage_arm64_build
path: build/linux/appimage
- uses: actions/upload-artifact@v4
with:
name: deb_arm64_build
@@ -208,6 +286,14 @@ jobs:
with:
name: deb_arm64_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: appimage_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: appimage_arm64_build
path: outputs
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
@@ -219,5 +305,6 @@ jobs:
outputs/*.exe
outputs/*.deb
outputs/*.zst
outputs/*.AppImage
env:
GITHUB_TOKEN: ${{ secrets.ACTION_GITHUB_TOKEN }}

View File

@@ -32,7 +32,7 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android {
namespace = "com.github.wgh136.venera"
compileSdk = flutter.compileSdkVersion
ndkVersion "25.1.8937393"
ndkVersion "28.0.13004108"
splits{
abi {

View File

@@ -47,6 +47,11 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="exhentai.org" android:pathPrefix="/g" />
</intent-filter>
<intent-filter android:label="@string/share_text">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.util.Log
@@ -40,6 +41,41 @@ class MainActivity : FlutterFragmentActivity() {
private val nextLocalRequestCode = AtomicInteger()
private val sharedTexts = ArrayList<String>()
private var textShareHandler: ((String) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent?.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null)
handleSharedText(text)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null)
handleSharedText(text)
}
}
}
private fun handleSharedText(text: String) {
if (textShareHandler != null) {
textShareHandler?.invoke(text)
} else {
sharedTexts.add(text)
}
}
private fun <I, O> startContractForResult(
contract: ActivityResultContract<I, O>,
input: I,
@@ -134,6 +170,26 @@ class MainActivity : FlutterFragmentActivity() {
val mimeType = req.arguments<String>()
openFile(res, mimeType!!)
}
val shareTextChannel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/text_share")
shareTextChannel.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
textShareHandler = {text ->
events.success(text)
}
if (sharedTexts.isNotEmpty()) {
for (text in sharedTexts) {
events.success(text)
}
sharedTexts.clear()
}
}
override fun onCancel(arguments: Any?) {
textShareHandler = null
}
})
}
private fun getProxy(): String {

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">搜索</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">搜尋</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">Search</string>
</resources>

View File

@@ -106,7 +106,8 @@
"Continuous (Right to Left)": "连续(从右到左)",
"Continuous (Top to Bottom)": "连续(从上到下)",
"Auto page turning interval": "自动翻页间隔",
"The number of pic in screen (Only Gallery Mode)": "同屏幕图片数量(仅画廊模式)",
"The number of pic in screen for landscape (Only Gallery Mode)": "横屏同屏幕图片数量(仅画廊模式)",
"The number of pic in screen for portrait (Only Gallery Mode)": "竖屏同屏幕图片数量(仅画廊模式)",
"Theme Mode": "主题模式",
"System": "系统",
"Light": "浅色",
@@ -189,6 +190,7 @@
"Operation": "操作",
"Upload": "上传",
"Saved": "已保存",
"Saved Failed": "保存失败",
"Sync Data": "同步数据",
"Syncing Data": "正在同步数据",
"Data Sync": "数据同步",
@@ -336,8 +338,7 @@
"Number of images preloaded": "预加载图片数量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading: Chapter @ep Page @page": "上次阅读: 第 @ep 章 第 @page 页",
"Last Reading: Page @page": "上次阅读: 第 @page 页",
"Last Reading": "上次阅读",
"Replies": "回复",
"Follow Updates": "追更",
"Not Configured": "未配置",
@@ -353,7 +354,25 @@
"No updates found": "未找到更新",
"All Comics": "全部漫画",
"The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新",
"Disable": "禁用"
"Disable": "禁用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功后, APP将自动与服务器同步数据",
"Cache cleared": "缓存已清除",
"Disabled": "已禁用",
"WebDAV Auto Sync": "WebDAV 自动同步",
"Mark all as read": "全部标记为已读",
"Do you want to mark all as read?" : "您要全部标记为已读吗?",
"Swipe down for previous chapter": "向下滑动查看上一章",
"Swipe up for next chapter": "向上滑动查看下一章",
"Initial Page": "初始页面",
"Home Page": "主页",
"Favorites Page": "收藏页面",
"Explore Page": "探索页面",
"Categories Page": "分类页面",
"Convert to local": "转换为本地",
"Refresh": "刷新",
"Paging": "分页",
"Continuous": "连续",
"Display mode of comic list": "漫画列表的显示模式"
},
"zh_TW": {
"Home": "首頁",
@@ -363,7 +382,7 @@
"Settings": "設定",
"Search": "搜尋",
"History": "歷史",
"Local": "本",
"Local": "本",
"Import": "匯入",
"Comic Source": "漫畫源",
"Accounts": "帳戶",
@@ -375,14 +394,14 @@
"help": "幫助",
"Select": "選擇",
"Selected @a comics": "已選擇 @a 部漫畫",
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 載 @b 頁, 接收到 @c 部漫畫",
"Imported @a comics, loaded @b pages, received @c comics": "已匯入 @a 部漫畫, 載 @b 頁, 接收到 @c 部漫畫",
"Downloading": "下載中",
"Back": "後退",
"Delete": "刪除",
"Full Screen": "全螢幕",
"Auto Page Turning": "自動翻頁",
"Chapters": "章節",
"Save Image": "存圖片",
"Save Image": "存圖片",
"Share": "分享",
"Details": "詳情",
"Description": "描述",
@@ -390,19 +409,19 @@
"Add to favorites": "加入收藏",
"Error": "錯誤",
"Retry": "重試",
"Folders": "文件夾",
"Delete Folder": "刪除文件夾",
"Folders": "資料夾",
"Delete Folder": "刪除資料夾",
"Rename": "重新命名",
"Reorder": "重新排序",
"Network": "網路",
"more": "更多",
"Select a folder": "選擇一個文件夾",
"Folder": "文件夾",
"Select a folder": "選擇一個資料夾",
"Folder": "資料夾",
"Confirm": "確認",
"Remove comic from favorite?": "從收藏中移除漫畫?",
"Move": "移動",
"Move to folder": "移動到文件夾",
"Copy to folder": "複製到文件夾",
"Move to folder": "移動到資料夾",
"Copy to folder": "複製到資料夾",
"Delete Comic": "刪除漫畫",
"Delete @c comics?": "刪除 @c 本漫畫?",
"Add comic source": "添加漫畫源",
@@ -414,43 +433,43 @@
"Check updates": "檢查更新",
"Edit": "編輯",
"Update": "更新",
"Log in": "登",
"Log in": "登",
"Log out": "登出",
"Re-login": "重新登",
"Click if login expired": "點擊此處如果登已過期",
"Login": "登",
"Username": "用戶名",
"Re-login": "重新登",
"Click if login expired": "點擊此處如果登已過期",
"Login": "登",
"Username": "使用者名稱",
"Password": "密碼",
"Continue": "繼續",
"Create Account": "建帳戶",
"Create Account": "建帳戶",
"Next": "前進",
"Login with webview": "過網頁登",
"Login with webview": "過網頁登",
"Read": "閱讀",
"Download": "下載",
"Favorite": "收藏",
"Comments": "評論",
"Information": "信息",
"Information": "資訊",
"Uploader": "上傳者",
"Upload Time": "上傳時間",
"Preview": "預覽",
"Comment": "評論",
"Submit": "提交",
"Add": "添加",
"New Folder": "新建文件夾",
"New Folder": "建立資料夾",
"Reading": "閱讀中",
"Appearance": "外觀",
"Local Favorites": "本收藏",
"Local Favorites": "本收藏",
"APP": "應用",
"About": "關於",
"Display mode of comic tile": "漫畫縮圖的顯示模式",
"Display mode of comic tile": "漫畫縮圖的顯示模式",
"Detailed": "詳細",
"Brief": "簡潔",
"Size of comic tile": "漫畫縮圖的大小",
"Size of comic tile": "漫畫縮圖的大小",
"Explore Pages": "探索頁面",
"Category Pages": "分類頁面",
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
"Keyword blocking": "關鍵詞屏蔽",
"Show favorite status on comic tile": "在漫畫縮圖上顯示收藏狀態",
"Show history on comic tile": "在漫畫縮圖上顯示歷史記錄",
"Keyword blocking": "關鍵字封鎖",
"Tap to turn Pages": "點擊翻頁",
"Page animation": "頁面動畫",
"Reading mode": "閱讀模式",
@@ -461,10 +480,11 @@
"Continuous (Right to Left)": "連續(從右到左)",
"Continuous (Top to Bottom)": "連續(從上到下)",
"Auto page turning interval": "自動翻頁間隔",
"The number of pic in screen (Only Gallery Mode)": "同螢幕圖片數量(僅畫廊模式)",
"The number of pic in screen for landscape (Only Gallery Mode)": "橫向同螢幕圖片數量(僅畫廊模式)",
"The number of pic in screen for portrait (Only Gallery Mode)": "直向同螢幕圖片數量(僅畫廊模式)",
"Theme Mode": "主題模式",
"System": "系統",
"Light": "色",
"Light": "色",
"Dark": "深色",
"Theme Color": "主題顏色",
"Red": "紅色",
@@ -474,34 +494,34 @@
"Orange": "橙色",
"Blue": "藍色",
"App": "應用",
"Data": "數據",
"Storage Path for local comics": "本漫畫的儲路徑",
"Set New Storage Path": "設新的儲路徑",
"Set": "設",
"Cache Size": "緩存大小",
"Clear Cache": "清除緩存",
"Data": "資料",
"Storage Path for local comics": "本漫畫的儲路徑",
"Set New Storage Path": "設新的儲路徑",
"Set": "設",
"Cache Size": "快取大小",
"Clear Cache": "清除快取",
"Clear": "清除",
"Log": "日誌",
"Open Log": "打開日誌",
"Open": "打開",
"User": "用戶",
"User": "使用者",
"Language": "語言",
"Proxy": "代理",
"Venera is a free and open-source app for comic reading.": "Venera是一個免費的開源漫畫閱讀應用。",
"Check for updates": "檢查更新",
"Check": "檢查",
"Network Favorite Pages": "網路收藏頁面",
"Block": "屏蔽",
"Block": "封鎖",
"Add new favorite to": "添加新收藏到",
"Move favorite after reading": "閱讀後移動收藏",
"Delete folder?" : "刪除文件夾?",
"Delete folder '@f' ?" : "刪除文件夾 '@f' ",
"Delete folder?" : "刪除資料夾?",
"Delete folder '@f' ?" : "刪除資料夾 '@f' ",
"Import from file": "從文件匯入",
"Failed to import": "匯入失敗",
"Cache Limit": "緩存限制",
"Set Cache Limit": "設置緩存限制",
"Cache Limit": "快取限制",
"Set Cache Limit": "設定快取限制",
"Size in MB": "大小MB",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫文件夾的目錄",
"Select a directory which contains the comic directories." : "選擇一個包含漫畫資料夾的目錄",
"Help": "幫助",
"Export as cbz": "匯出為cbz",
"Select an archive file (cbz, zip, 7z, cb7)" : "選擇一個歸檔文件 (cbz, zip, 7z, cb7)",
@@ -515,15 +535,15 @@
"Date Desc": "日期降序",
"Start": "開始",
"Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據",
"Import App Data": "匯入應用數據",
"Export App Data": "匯出應用資料",
"Import App Data": "匯入應用資料",
"Export": "匯出",
"Download Threads": "下載線程數",
"Download Threads": "下載執行緒數",
"Update Time": "更新時間",
"Copy ID": "複製ID",
"Copy URL": "複製URL",
"Create": "建",
"Folder Name": "文件夾名稱",
"Create": "建",
"Folder Name": "資料夾名稱",
"Ranking": "排行",
"Download Selected": "下載選中",
"Download All": "下載全部",
@@ -534,9 +554,9 @@
"Updates Available": "更新可用",
"Unselected": "未選擇",
"Long press and drag to reorder.": "長按並拖動以重新排序。",
"Limit image width": "限圖片寬度",
"Limit image width": "限圖片寬度",
"When using Continuous(Top to Bottom) mode": "當使用連續(從上到下)模式",
"Open link": "打開鏈接",
"Open link": "打開連結",
"Open comic": "打開漫畫",
"Move To First": "移動到最前",
"Cancel": "取消",
@@ -544,15 +564,16 @@
"Pause": "暫停",
"Operation": "操作",
"Upload": "上傳",
"Saved": "已存",
"Sync Data": "同步數據",
"Syncing Data": "正在同步數據",
"Data Sync": "數據同步",
"Saved": "已存",
"Saved Failed": "儲存失敗",
"Sync Data": "同步資料",
"Syncing Data": "正在同步資料",
"Data Sync": "資料同步",
"Quick Favorite": "快速收藏",
"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": "已添加",
"Turn page by volume keys": "使用音量鍵翻頁",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量信息",
"Display time & battery info in reader": "在閱讀器中顯示時間和電量資訊",
"EhViewer downloads": "EhViewer下載",
"Select an EhViewer database and a download folder.": "選擇EhViewer的下載資料匯出的db檔案與存放下載內容的目錄",
"(EhViewer)Default": "(EhViewer)預設",
@@ -566,22 +587,22 @@
"Select in range": "區間選擇",
"Finished": "已完成",
"Updating": "更新中",
"Update Comics Info": "更新漫畫信息",
"Create Folder": "新建文件夾",
"Select an image on screen": "選擇幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載列",
"Update Comics Info": "更新漫畫資訊",
"Create Folder": "建立資料夾",
"Select an image on screen": "選擇幕上的圖片",
"Added @count comics to download queue.": "已添加 @count 本漫畫到下載列",
"Authorization Required": "需要身份驗證",
"Sync": "同步",
"The folder is Linked to @source": "文件夾已關聯到 @source",
"Source Folder": "源文件夾",
"Use a config file": "使用配置文件",
"The folder is Linked to @source": "資料夾已關聯到 @source",
"Source Folder": "來源資料夾",
"Use a config file": "使用設定檔",
"Comic Source list": "漫畫源列表",
"View": "查看",
"Copy": "複製",
"Copied": "已複製",
"Search History": "搜歷史",
"Clear Search History": "清除搜歷史",
"Search in": "搜於",
"Search History": "搜歷史",
"Clear Search History": "清除搜歷史",
"Search in": "搜於",
"Clear History": "清除歷史",
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
"No Explore Pages": "沒有探索頁面",
@@ -590,21 +611,21 @@
"No Category Pages": "沒有分類頁面",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁",
"Also remove files on disk": "同時刪除磁上的文件",
"Copy to app local path": "將漫畫複製到本儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本收藏",
"Also remove files on disk": "同時刪除磁上的文件",
"Copy to app local path": "將漫畫複製到本儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本收藏",
"Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf",
"Export as epub": "匯出為epub",
"Aggregated Search": "聚合搜",
"No search results found": "未找到搜結果",
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載列",
"Aggregated Search": "聚合搜",
"No search results found": "未找到搜結果",
"Added @c comics to download queue." : "已添加 @c 本漫畫到下載列",
"Download started": "下載已開始",
"Click favorite": "點擊收藏",
"Local comic collection is not supported at present": "本收藏暫不支",
"Local comic collection is not supported at present": "本收藏暫不支",
"The cover cannot be uncollected here": "封面不能在此取消收藏",
"Uncollected the image": "取消收藏圖片",
"Successfully collected": "收藏成功",
@@ -613,7 +634,7 @@
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode": "在圖片瀏覽頁面, 你可以根據你的閱讀模式橫向或者縱向滑動快速收藏圖片",
"Calculate your favorite from @a comics and @b images": "從 @a 本漫畫和 @b 張圖片中, 計算你最喜歡的",
"After the parentheses are the number of pictures or the number of pictures compared to the number of comic pages": "括號後是圖片數量或圖片數比漫畫頁數",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支收藏此章節",
"The chapter order of the comic may have changed, temporarily not supported for collection": "漫畫的章節順序可能發生了變化, 暫不支收藏此章節",
"Author: ": "作者: ",
"Tags: ": "標籤: ",
"Comics(number): ": "漫畫(數量): ",
@@ -621,7 +642,7 @@
"Time Filter": "時間篩選",
"Image Favorites Greater Than": "圖片收藏數大於",
"Collection time": "收藏時間",
"Not enable": "不用",
"Not enable": "不用",
"Double Tap": "雙擊",
"Swipe": "滑動",
"favoritesCompareComicPages": "收藏數與漫畫頁數比較",
@@ -632,7 +653,7 @@
"Favorite Num": "收藏數",
"Favorite Num Compare Comic Pages": "收藏數比漫畫頁數",
"All": "全部",
"Last Week": "上",
"Last Week": "上",
"Last Month": "上月",
"Last Half Year": "半年",
"Last Year": "一年",
@@ -653,16 +674,16 @@
"No valid comics found" : "未找到有效的漫畫",
"Enable DNS Overrides": "啟用DNS覆寫",
"DNS Overrides": "DNS覆寫",
"Custom Image Processing": "自定義圖片處理",
"Custom Image Processing": "自圖片處理",
"Enable": "啟用",
"Aggregated": "聚合",
"Default Search Target": "默認搜索目標",
"Default Search Target": "預設搜尋目標",
"Auto Language Filters": "自動語言篩選",
"Check for updates on startup": "啟動時檢查更新",
"Start Time": "開始時間",
"End Time": "結束時間",
"Custom": "自定義",
"Reset": "重",
"Custom": "自",
"Reset": "重",
"Tags": "標籤",
"Authors": "作者",
"Comics": "漫畫",
@@ -670,45 +691,62 @@
"New Version": "新版本",
"@c updates": "@c 項更新",
"No updates": "無更新",
"Set comic source list url": "設漫畫源列表URL",
"Set comic source list url": "設漫畫源列表URL",
"Deselect All": "取消全選",
"Add keyword": "添加關鍵",
"Keyword": "關鍵",
"Add keyword": "添加關鍵",
"Keyword": "關鍵",
"Manage": "管理",
"Verify": "驗證",
"Cloudflare verification required": "需要Cloudflare驗證",
"Success": "成功",
"Compressing": "壓縮中",
"Exporting": "匯出中",
"Search Sources": "搜源",
"Search Sources": "搜源",
"Removed": "已移除",
"Added to favorites": "已添加到收藏",
"Not added": "未添加",
"Create a folder": "建收藏夾",
"Created successfully": "建成功",
"Create a folder": "建收藏夾",
"Created successfully": "建成功",
"name": "名稱",
"Reverse tap to turn Pages": "反轉點擊翻頁",
"Show all": "顯示全部",
"Number of images preloaded": "預載圖片數量",
"Number of images preloaded": "預載圖片數量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading: Chapter @ep Page @page": "上次閱讀: 第 @ep 章 第 @page 頁",
"Last Reading: Page @page": "上次閱讀: 第 @page 頁",
"Last Reading": "上次閱讀",
"Replies": "回覆",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates." : "選擇一個文件夾以追更",
"Choose Folder": "選擇文件夾",
"No folders available": "沒有可用的文件夾",
"Choose a folder to follow updates." : "選擇一個資料夾以追更",
"Choose Folder": "選擇資料夾",
"No folders available": "沒有可用的資料夾",
"Updating comics...": "更新漫畫中...",
"Automatic update checking enabled." : "已啟用自動更新檢查",
"The app will check for updates at most once a day." : "APP將每天最多檢查一次更新",
"Change Folder": "更改文件夾",
"Change Folder": "更改資料夾",
"Check Now": "立即檢查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫畫",
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
"Disable": "用"
"Disable": "用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與伺服器同步資料",
"Cache cleared": "快取已清除",
"Disabled": "已停用",
"WebDAV Auto Sync": "WebDAV 自動同步",
"Mark all as read": "全部標記為已讀",
"Do you want to mark all as read?" : "您要全部標記為已讀嗎?",
"Swipe down for previous chapter": "向下滑動查看上一章",
"Swipe up for next chapter": "向上滑動查看下一章",
"Initial Page": "初始頁面",
"Home Page": "首頁",
"Favorites Page": "收藏頁面",
"Explore Page": "探索頁面",
"Categories Page": "分類頁面",
"Convert to local": "轉換為本地",
"Refresh": "刷新",
"Paging": "分頁",
"Continuous": "連續",
"Display mode of comic list": "漫畫列表的顯示模式"
}
}
}

View File

@@ -632,6 +632,7 @@ class _TabViewBodyState extends State<TabViewBody> {
void didChangeDependencies() {
super.didChangeDependencies();
_controller = widget.controller ?? DefaultTabController.of(context);
_currentIndex = _controller.index;
_controller.addListener(updateIndex);
}

View File

@@ -770,7 +770,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
@override
void didUpdateWidget(covariant SliverGridComics oldWidget) {
if (!oldWidget.comics.isEqualTo(widget.comics)) {
if (!comics.isEqualTo(widget.comics)) {
comics.clear();
for (var comic in widget.comics) {
if (isBlocked(comic) == null) {
@@ -879,6 +879,7 @@ class _SliverGridComics extends StatelessWidget {
return comic;
}
return AnimatedContainer(
key: ValueKey(comics[index].id),
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: isSelected
@@ -1140,7 +1141,7 @@ class ComicListState extends State<ComicList> {
setState(() {});
});
}
if (_loading[page] == true) {
if (_data[page] != null || _loading[page] == true) {
return;
}
_loading[page] = true;
@@ -1150,8 +1151,8 @@ class ComicListState extends State<ComicList> {
if (!mounted) return;
if (res.success) {
if (res.data.isEmpty) {
_data[page] = const [];
setState(() {
_data[page] = const [];
_maxPage = page;
});
} else {
@@ -1201,6 +1202,11 @@ class ComicListState extends State<ComicList> {
@override
Widget build(BuildContext context) {
var type = appdata.settings['comicListDisplayMode'];
return type == 'paging' ? buildPagingMode() : buildContinuousMode();
}
Widget buildPagingMode() {
if (_error != null) {
return Column(
children: [
@@ -1249,6 +1255,85 @@ class ComicListState extends State<ComicList> {
],
);
}
Widget buildContinuousMode() {
if (_error != null && _data.isEmpty) {
return Column(
children: [
if (widget.errorLeading != null) widget.errorLeading!,
_buildPageSelector(),
Expanded(
child: NetworkError(
withAppbar: false,
message: _error!,
retry: () {
setState(() {
_error = null;
});
},
),
),
],
);
}
if (_data[_page] == null) {
_loadPage(_page);
return Column(
children: [
if (widget.errorLeading != null) widget.errorLeading!,
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
),
],
);
}
return SmoothCustomScrollView(
key: enablePageStorage ? PageStorageKey('scroll$_page') : null,
controller: widget.controller,
slivers: [
if (widget.leadingSliver != null) widget.leadingSliver!,
SliverGridComics(
comics: _data.values.expand((element) => element).toList(),
menuBuilder: widget.menuBuilder,
onLastItemBuild: () {
if (_error == null && (_maxPage == null || _page < _maxPage!)) {
_loadPage(_data.length + 1);
}
},
),
if (_error != null)
SliverToBoxAdapter(
child: Column(
children: [
Row(
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Expanded(child: Text(_error!, maxLines: 3)),
],
),
const SizedBox(height: 8),
Center(
child: OutlinedButton(
onPressed: () {
setState(() {
_error = null;
});
},
child: Text("Retry".tl),
),
),
],
).paddingHorizontal(16).paddingVertical(8),
)
else if (_maxPage == null || _page < _maxPage!)
const SliverListLoadingIndicator(),
if (widget.trailingSliver != null) widget.trailingSliver!,
],
);
}
}
class StarRating extends StatelessWidget {
@@ -1535,17 +1620,20 @@ class _SMClipper extends CustomClipper<Rect> {
}
class SimpleComicTile extends StatelessWidget {
const SimpleComicTile({super.key, required this.comic, this.onTap});
const SimpleComicTile(
{super.key, required this.comic, this.onTap, this.withTitle = false});
final Comic comic;
final void Function()? onTap;
final bool withTitle;
@override
Widget build(BuildContext context) {
var image = _findImageProvider(comic);
var child = image == null
Widget child = image == null
? const SizedBox()
: AnimatedImage(
image: image,
@@ -1555,7 +1643,18 @@ class SimpleComicTile extends StatelessWidget {
filterQuality: FilterQuality.medium,
);
return AnimatedTapRegion(
child = Container(
width: 98,
height: 136,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
);
child = AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ??
() {
@@ -1566,16 +1665,29 @@ class SimpleComicTile extends StatelessWidget {
),
);
},
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
),
child: child,
);
if (withTitle) {
child = Column(
mainAxisSize: MainAxisSize.min,
children: [
child,
const SizedBox(height: 4),
SizedBox(
width: 92,
child: Center(
child: Text(
comic.title.replaceAll('\n', ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
return child;
}
}

View File

@@ -125,11 +125,11 @@ class OverlayWidgetState extends State<OverlayWidget> {
void showDialogMessage(BuildContext context, String title, String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
builder: (context) => ContentDialog(
title: title,
content: Text(message).paddingHorizontal(16),
actions: [
TextButton(
FilledButton(
onPressed: context.pop,
child: Text("OK".tl),
)

View File

@@ -99,11 +99,13 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
);
if (_futurePosition == old) return;
var target = _futurePosition!;
_controller.animateTo(
_controller
.animateTo(
_futurePosition!,
duration: _fastAnimationDuration,
curve: Curves.linear,
).then((_) {
)
.then((_) {
var current = _controller.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
@@ -144,3 +146,169 @@ class ScrollControllerProvider extends InheritedWidget {
return oldWidget.controller != controller;
}
}
class AppScrollBar extends StatefulWidget {
const AppScrollBar({
super.key,
required this.controller,
required this.child,
this.topPadding = 0,
});
final ScrollController controller;
final Widget child;
final double topPadding;
@override
State<AppScrollBar> createState() => _AppScrollBarState();
}
class _AppScrollBarState extends State<AppScrollBar> {
late final ScrollController _scrollController;
double minExtent = 0;
double maxExtent = 0;
double position = 0;
double viewHeight = 0;
final _scrollIndicatorSize = App.isDesktop ? 42.0 : 64.0;
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
@override
void initState() {
super.initState();
_scrollController = widget.controller;
_scrollController.addListener(onChanged);
Future.microtask(onChanged);
_dragGestureRecognizer = VerticalDragGestureRecognizer()
..onUpdate = onUpdate;
}
void onUpdate(DragUpdateDetails details) {
if (maxExtent - minExtent <= 0 ||
viewHeight == 0 ||
details.primaryDelta == null) {
return;
}
var offset = details.primaryDelta!;
var positionOffset =
offset / (viewHeight - _scrollIndicatorSize) * (maxExtent - minExtent);
_scrollController.jumpTo((position + positionOffset).clamp(
minExtent,
maxExtent,
));
}
void onChanged() {
if (_scrollController.positions.isEmpty) return;
var position = _scrollController.position;
if (position.minScrollExtent != minExtent ||
position.maxScrollExtent != maxExtent ||
position.pixels != this.position) {
setState(() {
minExtent = position.minScrollExtent;
maxExtent = position.maxScrollExtent;
this.position = position.pixels;
});
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constrains) {
var scrollHeight = (maxExtent - minExtent);
var height = constrains.maxHeight - widget.topPadding;
viewHeight = height;
var top = scrollHeight == 0
? 0.0
: (position - minExtent) /
scrollHeight *
(height - _scrollIndicatorSize);
return Stack(
children: [
Positioned.fill(
child: widget.child,
),
Positioned(
top: top + widget.topPadding,
right: 0,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
_dragGestureRecognizer.addPointer(event);
},
child: SizedBox(
width: _scrollIndicatorSize/2,
height: _scrollIndicatorSize,
child: CustomPaint(
painter: _ScrollIndicatorPainter(
backgroundColor: context.colorScheme.surface,
shadowColor: context.colorScheme.shadow,
),
child: Column(
children: [
const Spacer(),
Icon(Icons.arrow_drop_up, size: 18),
Icon(Icons.arrow_drop_down, size: 18),
const Spacer(),
],
).paddingLeft(4),
),
),
),
),
),
],
);
},
);
}
}
class _ScrollIndicatorPainter extends CustomPainter {
final Color backgroundColor;
final Color shadowColor;
const _ScrollIndicatorPainter({
required this.backgroundColor,
required this.shadowColor,
});
@override
void paint(Canvas canvas, Size size) {
var path = Path()
..moveTo(size.width, 0)
..lineTo(size.width, size.height)
..arcToPoint(
Offset(size.width, 0),
radius: Radius.circular(size.width),
);
canvas.drawShadow(path, shadowColor, 4, true);
var backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
path = Path()
..moveTo(size.width, 0)
..lineTo(size.width, size.height)
..arcToPoint(
Offset(size.width, 0),
radius: Radius.circular(size.width),
);
canvas.drawPath(path, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! _ScrollIndicatorPainter ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.shadowColor != shadowColor;
}
}

View File

@@ -1,15 +1,13 @@
part of 'components.dart';
class SideBarRoute<T> extends PopupRoute<T> {
SideBarRoute(this.title, this.widget,
SideBarRoute(this.widget,
{this.showBarrier = true,
this.useSurfaceTintColor = false,
required this.width,
this.addBottomPadding = true,
this.addTopPadding = true});
final String? title;
final Widget widget;
final bool showBarrier;
@@ -36,11 +34,7 @@ class SideBarRoute<T> extends PopupRoute<T> {
Animation<double> secondaryAnimation) {
bool showSideBar = MediaQuery.of(context).size.width > width;
Widget body = SidebarBody(
title: title,
widget: widget,
autoChangeTitleBarColor: !useSurfaceTintColor,
);
Widget body = widget;
if (addTopPadding) {
body = Padding(
@@ -129,97 +123,13 @@ class SideBarRoute<T> extends PopupRoute<T> {
}
}
class SidebarBody extends StatefulWidget {
const SidebarBody(
{required this.title,
required this.widget,
required this.autoChangeTitleBarColor,
super.key});
final String? title;
final Widget widget;
final bool autoChangeTitleBarColor;
@override
State<SidebarBody> createState() => _SidebarBodyState();
}
class _SidebarBodyState extends State<SidebarBody> {
bool top = true;
@override
Widget build(BuildContext context) {
Widget body = Expanded(child: widget.widget);
if (widget.autoChangeTitleBarColor) {
body = NotificationListener<ScrollNotification>(
onNotification: (notifications) {
if (notifications.metrics.pixels ==
notifications.metrics.minScrollExtent &&
!top) {
setState(() {
top = true;
});
} else if (notifications.metrics.pixels !=
notifications.metrics.minScrollExtent &&
top) {
setState(() {
top = false;
});
}
return false;
},
child: body,
);
}
return Column(
children: [
if (widget.title != null)
Container(
height: 60 + MediaQuery.of(context).padding.top,
color: top
? null
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: Row(
children: [
const SizedBox(
width: 8,
),
Tooltip(
message: "Back".tl,
child: IconButton(
iconSize: 25,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
),
const SizedBox(
width: 10,
),
Text(
widget.title!,
style: const TextStyle(fontSize: 22),
)
],
),
),
body
],
);
}
}
Future<void> showSideBar(BuildContext context, Widget widget,
{String? title,
bool showBarrier = true,
{bool showBarrier = true,
bool useSurfaceTintColor = false,
double width = 500,
bool addTopPadding = false}) {
return Navigator.of(context).push(
SideBarRoute(
title,
widget,
showBarrier: showBarrier,
useSurfaceTintColor: useSurfaceTintColor,

View File

@@ -10,6 +10,34 @@ import 'package:window_manager/window_manager.dart';
const _kTitleBarHeight = 36.0;
class WindowFrameController extends InheritedWidget {
/// Whether the window frame is hidden.
final bool isWindowFrameHidden;
/// Sets the visibility of the window frame.
final void Function(bool) setWindowFrame;
/// Adds a listener that will be called when close button is clicked.
/// The listener should return `true` to allow the window to be closed.
final void Function(WindowCloseListener listener) addCloseListener;
/// Removes a close listener.
final void Function(WindowCloseListener listener) removeCloseListener;
const WindowFrameController._create({
required this.isWindowFrameHidden,
required this.setWindowFrame,
required this.addCloseListener,
required this.removeCloseListener,
required super.child,
});
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
return false;
}
}
class WindowFrame extends StatefulWidget {
const WindowFrame(this.child, {super.key});
@@ -17,98 +45,145 @@ class WindowFrame extends StatefulWidget {
@override
State<WindowFrame> createState() => _WindowFrameState();
static WindowFrameController of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
}
}
typedef WindowCloseListener = bool Function();
class _WindowFrameState extends State<WindowFrame> {
bool isHideWindowFrame = false;
bool isWindowFrameHidden = false;
bool useDarkTheme = false;
var closeListeners = <WindowCloseListener>[];
/// Sets the visibility of the window frame.
void setWindowFrame(bool show) {
setState(() {
isWindowFrameHidden = !show;
});
}
/// Adds a listener that will be called when close button is clicked.
/// The listener should return `true` to allow the window to be closed.
void addCloseListener(WindowCloseListener listener) {
closeListeners.add(listener);
}
/// Removes a close listener.
void removeCloseListener(WindowCloseListener listener) {
closeListeners.remove(listener);
}
void _onClose() {
for (var listener in closeListeners) {
if (!listener()) {
return;
}
}
windowManager.close();
}
@override
Widget build(BuildContext context) {
if (App.isMobile) return widget.child;
if (isHideWindowFrame) return widget.child;
var body = Stack(
Widget body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
padding: isWindowFrameHidden
? null
: const EdgeInsets.only(top: _kTitleBarHeight),
),
child: widget.child,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
brightness: useDarkTheme ? Brightness.dark : null,
),
child: Builder(builder: (context) {
return SizedBox(
height: _kTitleBarHeight,
child: Row(
children: [
if (App.isMacOS)
const DragToMoveArea(
child: SizedBox(
height: double.infinity,
width: 16,
),
).paddingRight(52)
else
const SizedBox(width: 12),
Expanded(
child: DragToMoveArea(
child: Text(
'Venera',
style: TextStyle(
fontSize: 13,
color: (useDarkTheme ||
context.brightness == Brightness.dark)
? Colors.white
: Colors.black,
if (!isWindowFrameHidden)
Positioned(
top: 0,
left: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
brightness: useDarkTheme ? Brightness.dark : null,
),
child: Builder(builder: (context) {
return SizedBox(
height: _kTitleBarHeight,
child: Row(
children: [
if (App.isMacOS)
const DragToMoveArea(
child: SizedBox(
height: double.infinity,
width: 16,
),
)
.toAlign(Alignment.centerLeft)
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
).paddingRight(52)
else
const SizedBox(width: 12),
Expanded(
child: DragToMoveArea(
child: Text(
'Venera',
style: TextStyle(
fontSize: 13,
color: (useDarkTheme ||
context.brightness == Brightness.dark)
? Colors.white
: Colors.black,
),
)
.toAlign(Alignment.centerLeft)
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
),
),
),
if (kDebugMode)
const TextButton(
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) const WindowButtons()
],
),
);
}),
if (kDebugMode)
const TextButton(
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) _WindowButtons(
onClose: _onClose,
)
],
),
);
}),
),
),
),
)
)
],
);
if (App.isLinux) {
return VirtualWindowFrame(child: body);
} else {
return body;
body = VirtualWindowFrame(child: body);
}
return WindowFrameController._create(
isWindowFrameHidden: isWindowFrameHidden,
setWindowFrame: setWindowFrame,
addCloseListener: addCloseListener,
removeCloseListener: removeCloseListener,
child: body,
);
}
}
class WindowButtons extends StatefulWidget {
const WindowButtons({super.key});
class _WindowButtons extends StatefulWidget {
const _WindowButtons({required this.onClose});
final void Function() onClose;
@override
State<WindowButtons> createState() => _WindowButtonsState();
State<_WindowButtons> createState() => _WindowButtonsState();
}
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
bool isMaximized = false;
@override
@@ -197,9 +272,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
color: !dark ? Colors.white : Colors.black,
),
hoverColor: Colors.red,
onPressed: () {
windowManager.close();
},
onPressed: widget.onClose,
)
],
),
@@ -567,5 +640,5 @@ TransitionBuilder VirtualWindowFrameInit() {
}
void debug() {
ComicSource.reload();
ComicSourceManager().reload();
}

View File

@@ -3,14 +3,17 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/history.dart';
import 'appdata.dart';
import 'favorites.dart';
import 'local.dart';
export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.3.0";
final version = "1.3.3";
bool get isAndroid => Platform.isAndroid;
@@ -51,6 +54,14 @@ class _App {
BuildContext get rootContext => rootNavigatorKey.currentContext!;
final Appdata data = appdata;
final HistoryManager history = HistoryManager();
final LocalFavoritesManager favorites = LocalFavoritesManager();
final LocalManager local = LocalManager();
void rootPop() {
rootNavigatorKey.currentState?.maybePop();
}
@@ -68,6 +79,15 @@ class _App {
dataPath = (await getApplicationSupportDirectory()).path;
}
Future<void> initComponents() async {
await Future.wait([
data.init(),
history.init(),
favorites.init(),
local.init(),
]);
}
Function? _forceRebuildHandler;
void registerForceRebuild(Function handler) {

View File

@@ -4,10 +4,13 @@ import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart';
class _Appdata {
final _Settings settings = _Settings();
class Appdata with Init {
Appdata._create();
final Settings settings = Settings._create();
var searchHistory = <String>[];
@@ -51,28 +54,6 @@ class _Appdata {
saveData();
}
Future<void> init() async {
var dataPath = (await getApplicationSupportDirectory()).path;
var file = File(FilePath.join(
dataPath,
'appdata.json',
));
if (!await file.exists()) {
return;
}
var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
}
}
searchHistory = List.from(json['searchHistory']);
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
}
}
Map<String, dynamic> toJson() {
return {
'settings': settings._data,
@@ -108,12 +89,35 @@ class _Appdata {
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
file.writeAsString(jsonEncode(implicitData));
}
@override
Future<void> doInit() async {
var dataPath = (await getApplicationSupportDirectory()).path;
var file = File(FilePath.join(
dataPath,
'appdata.json',
));
if (!await file.exists()) {
return;
}
var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
}
}
searchHistory = List.from(json['searchHistory']);
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
}
}
}
final appdata = _Appdata();
final appdata = Appdata._create();
class _Settings with ChangeNotifier {
_Settings();
class Settings with ChangeNotifier {
Settings._create();
final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief
@@ -133,7 +137,8 @@ class _Settings with ChangeNotifier {
'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumber': 1, // 1 - 5
'readerScreenPicNumberForLandscape': 1, // 1 - 5
'readerScreenPicNumberForPortrait': 1, // 1 - 5
'enableTapToTurnPages': true,
'reverseTapToTurnPages': false,
'enablePageAnimation': true,
@@ -157,9 +162,12 @@ class _Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'comicSourceListUrl':
"https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'preloadImageCount': 4,
'followUpdatesFolder': null,
'initialPage': '0',
'comicListDisplayMode': 'paging', // paging, continuous
};
operator [](String key) {
@@ -188,9 +196,9 @@ const defaultCustomImageProcessing = '''
* @returns {Promise<ArrayBuffer> | {image: Promise<ArrayBuffer>, onCancel: () => void}} - The processed image
*/
function processImage(image, cid, eid, page, sourceKey) {
let image = new Promise((resolve, reject) => {
let futureImage = new Promise((resolve, reject) => {
resolve(image);
});
return image;
return futureImage;
}
''';

View File

@@ -145,7 +145,7 @@ class RandomCategoryPartWithRuntimeData extends BaseCategoryPart {
}
CategoryData getCategoryDataWithKey(String key) {
for (var source in ComicSource._sources) {
for (var source in ComicSource.all()) {
if (source.categoryData?.key == key) {
return source.categoryData!;
}

View File

@@ -13,6 +13,7 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
@@ -27,81 +28,29 @@ part 'parser.dart';
part 'models.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
part 'types.dart';
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
String? next);
class ComicSourceManager with ChangeNotifier, Init {
final List<ComicSource> _sources = [];
typedef LoginFunction = Future<Res<bool>> Function(String, String);
static ComicSourceManager? _instance;
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
ComicSourceManager._create();
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
String id, String? ep);
factory ComicSourceManager() => _instance ??= ComicSourceManager._create();
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
String id, String? subId, int page, String? replyTo);
List<ComicSource> all() => List.from(_sources);
typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo);
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
String comicId, String? next);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
String comicId, bool isLiking);
/// [isLiking] is true if the user is liking the comment, false if unliking.
/// return the new likes count or null.
typedef LikeCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isLiking);
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
/// return the new vote count or null.
typedef VoteCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef HandleClickTagEvent = Map<String, String> Function(
String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
class ComicSource {
static final List<ComicSource> _sources = [];
static final List<Function> _listeners = [];
static void addListener(Function listener) {
_listeners.add(listener);
}
static void removeListener(Function listener) {
_listeners.remove(listener);
}
static void notifyListeners() {
for (var listener in _listeners) {
listener();
}
}
static List<ComicSource> all() => List.from(_sources);
static ComicSource? find(String key) =>
ComicSource? find(String key) =>
_sources.firstWhereOrNull((element) => element.key == key);
static ComicSource? fromIntKey(int key) =>
ComicSource? fromIntKey(int key) =>
_sources.firstWhereOrNull((element) => element.key.hashCode == key);
static Future<void> init() async {
@override
@protected
Future<void> doInit() async {
await JsEngine().ensureInit();
final path = "${App.dataPath}/comic_source";
if (!(await Directory(path).exists())) {
Directory(path).create();
@@ -120,26 +69,49 @@ class ComicSource {
}
}
static Future reload() async {
Future reload() async {
_sources.clear();
JsEngine().runCode("ComicSource.sources = {};");
await init();
await doInit();
notifyListeners();
}
static void add(ComicSource source) {
void add(ComicSource source) {
_sources.add(source);
notifyListeners();
}
static void remove(String key) {
void remove(String key) {
_sources.removeWhere((element) => element.key == key);
notifyListeners();
}
static final availableUpdates = <String, String>{};
bool get isEmpty => _sources.isEmpty;
static bool get isEmpty => _sources.isEmpty;
/// Key is the source key, value is the version.
final _availableUpdates = <String, String>{};
void updateAvailableUpdates(Map<String, String> updates) {
_availableUpdates.addAll(updates);
notifyListeners();
}
Map<String, String> get availableUpdates => Map.from(_availableUpdates);
void notifyStateChange() {
notifyListeners();
}
}
class ComicSource {
static List<ComicSource> all() => ComicSourceManager().all();
static ComicSource? find(String key) => ComicSourceManager().find(key);
static ComicSource? fromIntKey(int key) =>
ComicSourceManager().fromIntKey(key);
static bool get isEmpty => ComicSourceManager().isEmpty;
/// Name of this source.
final String name;
@@ -321,7 +293,7 @@ class AccountConfig {
this.onLoginWithWebviewSuccess,
this.cookieFields,
this.validateCookies,
) : infoItems = const [];
) : infoItems = const [];
}
class AccountInfoItem {
@@ -478,4 +450,4 @@ class ArchiveDownloader {
final Future<Res<String>> Function(String cid, String aid) getDownloadUrl;
const ArchiveDownloader(this.getArchives, this.getDownloadUrl);
}
}

View File

@@ -111,6 +111,9 @@ class Comic {
@override
int get hashCode => id.hashCode ^ sourceKey.hashCode;
@override
toString() => "$sourceKey@$id";
}
class ComicDetails with HistoryMixin {
@@ -128,12 +131,7 @@ class ComicDetails with HistoryMixin {
final Map<String, List<String>> tags;
/// id-name
final Map<String, String>? chapters;
/// key is group name.
/// When this field is not null, [chapters] will be a merged map of all groups.
/// Only available in some sources.
final Map<String, Map<String, String>>? groupedChapters;
final ComicChapters? chapters;
final List<String>? thumbnails;
@@ -176,45 +174,13 @@ class ComicDetails with HistoryMixin {
return res;
}
static Map<String, String>? _getChapters(dynamic chapters) {
if (chapters == null) return null;
var result = <String, String>{};
if (chapters is Map) {
for (var entry in chapters.entries) {
var value = entry.value;
if (value is Map) {
result.addAll(Map.from(value));
} else {
result[entry.key.toString()] = value.toString();
}
}
}
return result;
}
static Map<String, Map<String, String>>? _getGroupedChapters(dynamic chapters) {
if (chapters == null) return null;
var result = <String, Map<String, String>>{};
if (chapters is Map) {
for (var entry in chapters.entries) {
var value = entry.value;
if (value is Map) {
result[entry.key.toString()] = Map.from(value);
}
}
}
if (result.isEmpty) return null;
return result;
}
ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"],
subTitle = json["subtitle"],
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
chapters = _getChapters(json["chapters"]),
groupedChapters = _getGroupedChapters(json["chapters"]),
chapters = ComicChapters.fromJsonOrNull(json["chapters"]),
sourceKey = json["sourceKey"],
comicId = json["comicId"],
thumbnails = ListOrNull.from(json["thumbnails"]),
@@ -342,3 +308,124 @@ class ArchiveInfo {
description = json["description"],
id = json["id"];
}
class ComicChapters {
final Map<String, String>? _chapters;
final Map<String, Map<String, String>>? _groupedChapters;
/// Create a ComicChapters object with a flat map
const ComicChapters(Map<String, String> this._chapters)
: _groupedChapters = null;
/// Create a ComicChapters object with a grouped map
const ComicChapters.grouped(
Map<String, Map<String, String>> this._groupedChapters)
: _chapters = null;
factory ComicChapters.fromJson(dynamic json) {
if (json is! Map) throw ArgumentError("Invalid json type");
var chapters = <String, String>{};
var groupedChapters = <String, Map<String, String>>{};
for (var entry in json.entries) {
var key = entry.key;
var value = entry.value;
if (key is! String) throw ArgumentError("Invalid key type");
if (value is Map) {
groupedChapters[key] = Map.from(value);
} else {
chapters[key] = value.toString();
}
}
if (chapters.isNotEmpty) {
return ComicChapters(chapters);
} else if (groupedChapters.isNotEmpty) {
return ComicChapters.grouped(groupedChapters);
} else {
throw ArgumentError("Empty chapter list");
}
}
static fromJsonOrNull(dynamic json) {
if (json == null) return null;
return ComicChapters.fromJson(json);
}
Map<String, dynamic> toJson() {
if (_chapters != null) {
return _chapters;
} else {
return _groupedChapters!;
}
}
/// Whether the chapters are grouped
bool get isGrouped => _groupedChapters != null;
/// All group names
Iterable<String> get groups => _groupedChapters?.keys ?? [];
/// All chapters.
/// If the chapters are grouped, all groups will be merged.
Map<String, String> get allChapters {
if (_chapters != null) return _chapters;
var res = <String, String>{};
for (var entry in _groupedChapters!.values) {
res.addAll(entry);
}
return res;
}
/// Get a group of chapters by name
Map<String, String> getGroup(String group) {
return _groupedChapters![group] ?? {};
}
/// Get a group of chapters by index(0-based)
Map<String, String> getGroupByIndex(int index) {
return _groupedChapters!.values.elementAt(index);
}
/// Get total number of chapters
int get length {
return isGrouped
? _groupedChapters!.values.map((e) => e.length).reduce((a, b) => a + b)
: _chapters!.length;
}
/// Get the number of groups
int get groupCount => _groupedChapters?.length ?? 0;
/// Iterate all chapter ids
Iterable<String> get ids sync* {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
yield* entry.keys;
}
} else {
yield* _chapters!.keys;
}
}
/// Iterate all chapter titles
Iterable<String> get titles sync* {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
yield* entry.values;
}
} else {
yield* _chapters!.values;
}
}
String? operator [](String key) {
if (isGrouped) {
for (var entry in _groupedChapters!.values) {
if (entry.containsKey(key)) return entry[key];
}
return null;
} else {
return _chapters![key];
}
}
}

View File

@@ -0,0 +1,48 @@
part of 'comic_source.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
String? next);
typedef LoginFunction = Future<Res<bool>> Function(String, String);
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
String id, String? ep);
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
String id, String? subId, int page, String? replyTo);
typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo);
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
String comicId, String? next);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
String comicId, bool isLiking);
/// [isLiking] is true if the user is liking the comment, false if unliking.
/// return the new likes count or null.
typedef LikeCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isLiking);
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
/// return the new vote count or null.
typedef VoteCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef HandleClickTagEvent = Map<String, String> Function(
String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);

View File

@@ -185,6 +185,18 @@ class FavoriteItemWithUpdateInfo extends FavoriteItem {
var sourceName = type.comicSource?.name ?? "Unknown";
return "$updateTime | $sourceName";
}
@override
operator ==(Object other) {
return other is FavoriteItemWithUpdateInfo &&
other.updateTime == updateTime &&
other.hasNewUpdate == hasNewUpdate &&
super == other;
}
@override
int get hashCode =>
super.hashCode ^ updateTime.hashCode ^ hasNewUpdate.hashCode;
}
class LocalFavoritesManager with ChangeNotifier {
@@ -212,7 +224,8 @@ class LocalFavoritesManager with ChangeNotifier {
source_folder text
);
""");
for (var folder in _getFolderNamesWithDB()) {
var folderNames = _getFolderNamesWithDB();
for (var folder in folderNames) {
var columns = _db.select("""
pragma table_info("$folder");
""");
@@ -234,6 +247,15 @@ class LocalFavoritesManager with ChangeNotifier {
break;
}
}
await appdata.ensureInit();
// Make sure the follow updates folder is ready
var followUpdateFolder = appdata.settings['followUpdatesFolder'];
if (followUpdateFolder is String &&
folderNames.contains(followUpdateFolder)) {
prepareTableForFollowUpdates(followUpdateFolder, false);
} else {
appdata.settings['followUpdatesFolder'] = null;
}
}
List<String> find(String id, ComicType type) {
@@ -785,7 +807,7 @@ class LocalFavoritesManager with ChangeNotifier {
}
}
void updateInfo(String folder, FavoriteItem comic) {
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
_db.execute("""
update "$folder"
set name = ?, author = ?, cover_path = ?, tags = ?
@@ -798,7 +820,9 @@ class LocalFavoritesManager with ChangeNotifier {
comic.id,
comic.type.value
]);
notifyListeners();
if (notify) {
notifyListeners();
}
}
String folderToJson(String folder) {
@@ -835,7 +859,7 @@ class LocalFavoritesManager with ChangeNotifier {
}
}
void prepareTableForFollowUpdates(String table) {
void prepareTableForFollowUpdates(String table, [bool clearData = true]) {
// check if the table has the column "last_update_time" "has_new_update" "last_check_time"
var columns = _db.select("""
pragma table_info("$table");
@@ -852,10 +876,12 @@ class LocalFavoritesManager with ChangeNotifier {
add column has_new_update int;
""");
}
_db.execute("""
if (clearData) {
_db.execute("""
update "$table"
set has_new_update = 0;
""");
}
if (!columns.any((element) => element["name"] == "last_check_time")) {
_db.execute("""
alter table "$table"
@@ -888,6 +914,18 @@ class LocalFavoritesManager with ChangeNotifier {
]);
}
void updateCheckTime(
String folder,
String id,
ComicType type,
) {
_db.execute("""
update "$folder"
set last_check_time = ?
where id == ? and type == ?;
""", [DateTime.now().millisecondsSinceEpoch, id, type.value]);
}
int countUpdates(String folder) {
return _db.select("""
select count(*) as c from "$folder"
@@ -949,4 +987,8 @@ class LocalFavoritesManager with ChangeNotifier {
void close() {
_db.dispose();
}
void notifyChanges() {
notifyListeners();
}
}

View File

@@ -7,7 +7,6 @@ import 'dart:ffi' as ffi;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/common.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
@@ -51,17 +50,24 @@ class History implements Comic {
@override
String cover;
/// index of chapters. 1-based.
int ep;
/// index of pages. 1-based.
int page;
/// index of chapter groups. 1-based.
/// If [group] is not null, [ep] is the index of chapter in the group.
int? group;
@override
String id;
/// readEpisode is a set of episode numbers that have been read.
///
/// The number of episodes is 1-based.
Set<int> readEpisode;
/// For normal chapters, it is a set of chapter numbers.
/// For grouped chapters, it is a set of strings in the format of "group_number-chapter_number".
/// 1-based.
Set<String> readEpisode;
@override
int? maxPage;
@@ -70,29 +76,17 @@ class History implements Comic {
{required HistoryMixin model,
required this.ep,
required this.page,
Set<int>? readChapters,
this.group,
Set<String>? readChapters,
DateTime? time})
: type = model.historyType,
title = model.title,
subtitle = model.subTitle ?? '',
cover = model.cover,
id = model.id,
readEpisode = readChapters ?? <int>{},
readEpisode = readChapters ?? <String>{},
time = time ?? DateTime.now();
Map<String, dynamic> toMap() => {
"type": type.value,
"time": time.millisecondsSinceEpoch,
"title": title,
"subtitle": subtitle,
"cover": cover,
"ep": ep,
"page": page,
"id": id,
"readEpisode": readEpisode.toList(),
"max_page": maxPage
};
History.fromMap(Map<String, dynamic> map)
: type = HistoryType(map["type"]),
time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
@@ -102,8 +96,9 @@ class History implements Comic {
ep = map["ep"],
page = map["page"],
id = map["id"],
readEpisode = Set<int>.from(
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}),
readEpisode = Set<String>.from(
(map["readEpisode"] as List<dynamic>?)?.toSet() ??
const <String>{}),
maxPage = map["max_page"];
@override
@@ -120,11 +115,11 @@ class History implements Comic {
ep = row["ep"],
page = row["page"],
id = row["id"],
readEpisode = Set<int>.from((row["readEpisode"] as String)
readEpisode = Set<String>.from((row["readEpisode"] as String)
.split(',')
.where((element) => element != "")
.map((e) => int.parse(e))),
maxPage = row["max_page"];
.where((element) => element != "")),
maxPage = row["max_page"],
group = row["chapter_group"];
@override
bool operator ==(Object other) {
@@ -213,18 +208,24 @@ class HistoryManager with ChangeNotifier {
ep int,
page int,
readEpisode text,
max_page int
max_page int,
chapter_group int
);
""");
var columns = _db.select("PRAGMA table_info(history);");
if (!columns.any((element) => element["name"] == "chapter_group")) {
_db.execute("alter table history add column chapter_group int;");
}
notifyListeners();
ImageFavoriteManager().init();
isInitialized = true;
}
static const _insertHistorySql = """
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
static const _insertHistorySql = """
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page, chapter_group)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""";
static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
@@ -240,7 +241,8 @@ class HistoryManager with ChangeNotifier {
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage
newItem.maxPage,
newItem.group
]);
});
}
@@ -282,7 +284,8 @@ class HistoryManager with ChangeNotifier {
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage
newItem.maxPage,
newItem.group
]);
if (_cachedHistoryIds == null) {
updateCache();
@@ -319,7 +322,7 @@ class HistoryManager with ChangeNotifier {
for (var element in res) {
_cachedHistoryIds![element["id"] as String] = true;
}
for (var key in cachedHistories.keys) {
for (var key in cachedHistories.keys.toList()) {
if (!_cachedHistoryIds!.containsKey(key)) {
cachedHistories.remove(key);
}

View File

@@ -97,7 +97,7 @@ class ImageFavoritesProvider
if (localComic == null) {
return null;
}
var epIndex = localComic.chapters?.keys.toList().indexOf(eid) ?? -1;
var epIndex = localComic.chapters?.ids.toList().indexOf(eid) ?? -1;
if (epIndex == -1 && localComic.hasChapters) {
return null;
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html;
import 'package:html/dom.dart' as dom;
@@ -24,6 +25,7 @@ import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/init.dart';
import 'comic_source/comic_source.dart';
import 'consts.dart';
@@ -40,7 +42,7 @@ class JavaScriptRuntimeException implements Exception {
}
}
class JsEngine with _JSEngineApi, JsUiApi {
class JsEngine with _JSEngineApi, JsUiApi, Init {
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache;
@@ -64,7 +66,9 @@ class JsEngine with _JSEngineApi, JsUiApi {
responseType: ResponseType.plain, validateStatus: (status) => true));
}
Future<void> init() async {
@override
@protected
Future<void> doInit() async {
if (!_closed) {
return;
}

View File

@@ -9,7 +9,6 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'app.dart';
@@ -34,7 +33,7 @@ class LocalComic with HistoryMixin implements Comic {
/// key: chapter id, value: chapter title
///
/// chapter id is the name of the directory in `LocalManager.path/$directory`
final Map<String, String>? chapters;
final ComicChapters? chapters;
bool get hasChapters => chapters != null;
@@ -67,7 +66,7 @@ class LocalComic with HistoryMixin implements Comic {
subtitle = row[2] as String,
tags = List.from(jsonDecode(row[3] as String)),
directory = row[4] as String,
chapters = MapOrNull.from(jsonDecode(row[5] as String)),
chapters = ComicChapters.fromJsonOrNull(jsonDecode(row[5] as String)),
cover = row[6] as String,
comicType = ComicType(row[7] as int),
downloadedChapters = List.from(jsonDecode(row[8] as String)),
@@ -99,6 +98,7 @@ class LocalComic with HistoryMixin implements Comic {
"tags": tags,
"description": description,
"sourceKey": sourceKey,
"chapters": chapters?.toJson(),
};
}
@@ -115,6 +115,7 @@ class LocalComic with HistoryMixin implements Comic {
chapters: chapters,
initialChapter: history?.ep,
initialPage: history?.page,
initialChapterGroup: history?.group,
history: history ??
History.fromModel(
model: this,
@@ -264,6 +265,7 @@ class LocalManager with ChangeNotifier {
}
_checkPathValidation();
_checkNoMedia();
await ComicSourceManager().ensureInit();
restoreDownloadingTasks();
}
@@ -391,7 +393,7 @@ class LocalManager with ChangeNotifier {
var directory = Directory(comic.baseDir);
if (comic.hasChapters) {
var cid =
ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String);
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
directory = Directory(FilePath.join(directory.path, cid));
}
var files = <File>[];
@@ -420,12 +422,30 @@ class LocalManager with ChangeNotifier {
return files.map((e) => "file://${e.path}").toList();
}
bool isDownloaded(String id, ComicType type, [int? ep]) {
bool isDownloaded(String id, ComicType type,
[int? ep, ComicChapters? chapters]) {
var comic = find(id, type);
if (comic == null) return false;
if (comic.chapters == null || ep == null) return true;
if (chapters != null) {
if (comic.chapters?.length != chapters.length) {
// update
add(LocalComic(
id: comic.id,
title: comic.title,
subtitle: comic.subtitle,
tags: comic.tags,
directory: comic.directory,
chapters: chapters,
cover: comic.cover,
comicType: comic.comicType,
downloadedChapters: comic.downloadedChapters,
createdAt: comic.createdAt,
));
}
}
return comic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(ep - 1));
.contains((chapters ?? comic.chapters)!.ids.elementAtOrNull(ep - 1));
}
List<DownloadTask> downloadingTasks = [];
@@ -509,7 +529,7 @@ class LocalManager with ChangeNotifier {
var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true);
}
// Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
// Deleting a local comic means that it's no longer available, thus both favorite and history should be deleted.
if (c.comicType == ComicType.local) {
if (HistoryManager().find(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType);

View File

@@ -4,16 +4,14 @@ import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/handle_text_share.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart';
@@ -32,31 +30,48 @@ extension _FutureInit<T> on Future<T> {
}
Future<void> init() async {
await Rhttp.init();
await SAFTaskWorker().init().wait();
await AppTranslation.init().wait();
await appdata.init().wait();
await App.init().wait();
await HistoryManager().init().wait();
await TagsTranslation.readData().wait();
await LocalFavoritesManager().init().wait();
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
await JsEngine().init().wait();
await ComicSource.init().wait();
await LocalManager().init().wait();
await SingleInstanceCookieJar.createInstance();
var futures = [
Rhttp.init(),
App.initComponents(),
SAFTaskWorker().init().wait(),
AppTranslation.init().wait(),
TagsTranslation.readData().wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
];
await Future.wait(futures);
CacheManager().setLimitSize(appdata.settings['cacheSize']);
_checkOldConfigs();
if (App.isAndroid) {
handleLinks();
handleTextShare();
}
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
}
void _checkOldConfigs() {
if (appdata.settings['searchSources'] == null) {
appdata.settings['searchSources'] = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
}
if (App.isAndroid) {
handleLinks();
if (appdata.implicitData['webdavAutoSync'] == null) {
var webdavConfig = appdata.settings['webdav'];
if (webdavConfig is List &&
webdavConfig.length == 3 &&
webdavConfig.whereType<String>().length == 3) {
appdata.implicitData['webdavAutoSync'] = true;
} else {
appdata.implicitData['webdavAutoSync'] = false;
}
appdata.writeImplicitData();
}
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
}
Future<void> _checkAppUpdates() async {

View File

@@ -141,13 +141,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
) {
String? font;
List<String>? fallback;
if (App.isWindows) {
font = 'Segoe UI';
if (App.isLinux || App.isWindows) {
font = 'Noto Sans CJK';
fallback = [
'Segoe UI',
'Noto Sans SC',
'Noto Sans TC',
'Noto Sans',
'Microsoft YaHei',
'PingFang SC',
'Noto Sans CJK',
'Arial',
'sans-serif'
];

View File

@@ -257,18 +257,7 @@ class RHttpAdapter implements HttpClientAdapter {
Future<void>? cancelFuture,
) async {
var res = await rhttp.Rhttp.request(
method: switch (options.method) {
'GET' => rhttp.HttpMethod.get,
'POST' => rhttp.HttpMethod.post,
'PUT' => rhttp.HttpMethod.put,
'PATCH' => rhttp.HttpMethod.patch,
'DELETE' => rhttp.HttpMethod.delete,
'HEAD' => rhttp.HttpMethod.head,
'OPTIONS' => rhttp.HttpMethod.options,
'TRACE' => rhttp.HttpMethod.trace,
'CONNECT' => rhttp.HttpMethod.connect,
_ => throw ArgumentError('Unsupported method: ${options.method}'),
},
method: rhttp.HttpMethod(options.method),
url: options.uri.toString(),
settings: settings,
expectBody: rhttp.HttpExpectBody.stream,
@@ -293,9 +282,27 @@ class RHttpAdapter implements HttpClientAdapter {
return ResponseBody(
res.body,
res.statusCode,
statusMessage: null,
statusMessage: _getStatusMessage(res.statusCode),
isRedirect: false,
headers: headers,
);
}
static String _getStatusMessage(int statusCode) {
return switch(statusCode) {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
206 => "Partial Content",
301 => "Moved Permanently",
302 => "Found",
400 => "Invalid Status Code 400: The Request is invalid.",
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.",
404 => "Invalid Status Code 404: Not found.",
429 => "Invalid Status Code 429: Too many requests. Please try again later.",
_ => "Invalid Status Code $statusCode",
};
}
}

View File

@@ -14,7 +14,7 @@ import 'cookie_jar.dart';
class CloudflareException implements DioException {
final String url;
const CloudflareException(this.url);
CloudflareException(this.url);
@override
String toString() {
@@ -55,6 +55,9 @@ class CloudflareException implements DioException {
@override
DioExceptionType get type => DioExceptionType.badResponse;
@override
DioExceptionReadableStringBuilder? stringBuilder;
}
class CloudflareInterceptor extends Interceptor {

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart';
@@ -200,6 +201,11 @@ class SingleInstanceCookieJar extends CookieJarSql {
SingleInstanceCookieJar._create(super.path);
static SingleInstanceCookieJar? instance;
static Future<void> createInstance() async {
var dataPath = (await getApplicationSupportDirectory()).path;
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
}
}
class CookieManagerSql extends Interceptor {

View File

@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
@@ -328,8 +330,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_images = {};
_totalCount = 0;
int cpCount = 0;
int totalCpCount = chapters?.length ?? comic!.chapters!.length;
for (var i in comic!.chapters!.keys) {
int totalCpCount =
chapters?.length ?? comic!.chapters!.allChapters.length;
for (var i in comic!.chapters!.allChapters.keys) {
if (chapters != null && !chapters!.contains(i)) {
continue;
}
@@ -422,7 +425,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
"comic": comic?.toJson(),
"chapters": chapters,
"path": path,
"cover": cover,
"cover": _cover,
"images": _images,
"downloadedCount": _downloadedCount,
"totalCount": _totalCount,
@@ -738,11 +741,12 @@ class ArchiveDownloadTask extends DownloadTask {
path = dir.path;
}
var resultFile = File(FilePath.join(path!, "archive.zip"));
var archiveFile =
File(FilePath.join(App.dataPath, "archive_downloading.zip"));
Log.info("Download", "Downloading $archiveUrl");
_downloader = FileDownloader(archiveUrl, resultFile.path);
_downloader = FileDownloader(archiveUrl, archiveFile.path);
bool isDownloaded = false;
@@ -771,22 +775,33 @@ class ArchiveDownloadTask extends DownloadTask {
}
try {
await extractArchive(path!);
await _extractArchive(archiveFile.path, path!);
} catch (e) {
_setError("Failed to extract archive: $e");
return;
}
await resultFile.deleteIgnoreError();
await archiveFile.deleteIgnoreError();
LocalManager().completeTask(this);
}
static Future<void> extractArchive(String path) async {
var resultFile = FilePath.join(path, "archive.zip");
await Isolate.run(() {
ZipFile.openAndExtract(resultFile, path);
});
static Future<void> _extractArchive(String archive, String outDir) async {
var out = Directory(outDir);
if (out is AndroidDirectory) {
// Saf directory can't be accessed by native code.
var cacheDir = FilePath.join(App.cachePath, "archive_downloading");
Directory(cacheDir).forceCreateSync();
await Isolate.run(() {
ZipFile.openAndExtract(archive, cacheDir);
});
await copyDirectoryIsolate(Directory(cacheDir), Directory(outDir));
await Directory(cacheDir).deleteIgnoreError(recursive: true);
} else {
await Isolate.run(() {
ZipFile.openAndExtract(archive, outDir);
});
}
}
@override

View File

@@ -139,12 +139,10 @@ class ImageDownloader {
var buffer = <int>[];
await for (var data in stream) {
buffer.addAll(data);
if (expectedBytes != null) {
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: expectedBytes,
);
}
yield ImageDownloadProgress(
currentBytes: buffer.length,
totalBytes: expectedBytes,
);
}
if (configs['onResponse'] is JSInvokable) {
@@ -194,7 +192,7 @@ class ImageDownloader {
class ImageDownloadProgress {
final int currentBytes;
final int totalBytes;
final int? totalBytes;
final Uint8List? imageBytes;

View File

@@ -90,7 +90,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
with AutomaticKeepAliveClientMixin {
bool isLoading = true;
static const _kComicHeight = 132.0;
static const _kComicHeight = 162.0;
get _comicWidth => _kComicHeight * 0.7;
@@ -152,7 +152,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
}
Widget buildComic(Comic c) {
return SimpleComicTile(comic: c)
return SimpleComicTile(comic: c, withTitle: true)
.paddingLeft(_kLeftPadding)
.paddingBottom(2);
}

View File

@@ -95,16 +95,19 @@ abstract mixin class _ComicPageActions {
/// [ep] the episode number, start from 1
///
/// [page] the page number, start from 1
void read([int? ep, int? page]) {
///
/// [group] the chapter group number, start from 1
void read([int? ep, int? page, int? group]) {
App.rootContext
.to(
() => Reader(
() => Reader(
type: comic.comicType,
cid: comic.id,
name: comic.title,
chapters: comic.chapters,
initialChapter: ep,
initialPage: page,
initialChapterGroup: group,
history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '',
tags: comic.plainTags,
@@ -118,7 +121,8 @@ abstract mixin class _ComicPageActions {
void continueRead() {
var ep = history?.ep ?? 1;
var page = history?.page ?? 1;
read(ep, page);
var group = history?.group ?? 1;
read(ep, page, group);
}
void onReadEnd();
@@ -219,7 +223,7 @@ abstract mixin class _ComicPageActions {
isGettingLink = true;
});
var res =
await comicSource.archiveDownloader!.getDownloadUrl(
await comicSource.archiveDownloader!.getDownloadUrl(
comic.id,
archives![selected].id,
);
@@ -262,7 +266,7 @@ abstract mixin class _ComicPageActions {
if (localComic != null) {
for (int i = 0; i < comic.chapters!.length; i++) {
if (localComic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(i))) {
.contains(comic.chapters!.ids.elementAt(i))) {
downloaded.add(i);
}
}
@@ -270,8 +274,8 @@ abstract mixin class _ComicPageActions {
await showSideBar(
App.rootContext,
_SelectDownloadChapter(
comic.chapters!.values.toList(),
(v) => selected = v,
comic.chapters!.titles.toList(),
(v) => selected = v,
downloaded,
),
);
@@ -281,7 +285,7 @@ abstract mixin class _ComicPageActions {
comicId: comic.id,
comic: comic,
chapters: selected!.map((i) {
return comic.chapters!.keys.elementAt(i);
return comic.chapters!.ids.elementAt(i);
}).toList(),
));
}
@@ -298,13 +302,13 @@ abstract mixin class _ComicPageActions {
var context = App.mainNavigatorKey!.currentContext!;
if (config['action'] == 'search') {
context.to(() => SearchResultPage(
text: config['keyword'] ?? '',
sourceKey: comicSource.key,
options: const [],
));
text: config['keyword'] ?? '',
sourceKey: comicSource.key,
options: const [],
));
} else if (config['action'] == 'category') {
context.to(
() => CategoryComicsPage(
() => CategoryComicsPage(
category: config['keyword'] ?? '',
categoryKey: comicSource.categoryData!.key,
param: config['param'],
@@ -432,4 +436,4 @@ abstract mixin class _ComicPageActions {
),
);
}
}
}

View File

@@ -33,7 +33,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
late History? history;
late Map<String, String> chapters;
late ComicChapters chapters;
@override
void initState() {
@@ -101,7 +101,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
if (reverse) {
i = chapters.length - i - 1;
}
var key = chapters.keys.elementAt(i);
var key = chapters.ids.elementAt(i);
var value = chapters[key]!;
bool visited = (history?.readEpisode ?? {}).contains(i + 1);
return Padding(
@@ -182,24 +182,30 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
late History? history;
late Map<String, Map<String, String>> chapters;
late ComicChapters chapters;
late TabController tabController;
int index = 0;
late int index;
@override
void initState() {
super.initState();
history = widget.history;
if (history?.group != null) {
index = history!.group! - 1;
} else {
index = 0;
}
}
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.groupedChapters!;
chapters = state.comic.chapters!;
tabController = TabController(
length: chapters.keys.length,
initialIndex: index,
length: chapters.ids.length,
vsync: this,
);
tabController.addListener(onTabChange);
@@ -226,7 +232,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constrains) {
var group = chapters.values.elementAt(index);
var group = chapters.getGroupByIndex(index);
int length = group.length;
bool canShowAll = showAll;
if (!showAll) {
@@ -265,7 +271,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
child: AppTabBar(
withUnderLine: false,
controller: tabController,
tabs: chapters.keys.map((e) => Tab(text: e)).toList(),
tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
),
),
SliverPadding(padding: const EdgeInsets.only(top: 8)),
@@ -279,15 +285,20 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
var key = group.keys.elementAt(i);
var value = group[key]!;
var chapterIndex = 0;
for (var j = 0; j < chapters.length; j++) {
for (var j = 0; j < chapters.groupCount; j++) {
if (j == index) {
chapterIndex += i;
break;
}
chapterIndex += chapters.values.elementAt(j).length;
chapterIndex += chapters.getGroupByIndex(j).length;
}
String rawIndex = (chapterIndex + 1).toString();
String groupedIndex = "${index + 1}-${i + 1}";
bool visited = false;
if (history != null) {
visited = history!.readEpisode.contains(groupedIndex) ||
history!.readEpisode.contains(rawIndex);
}
bool visited =
(history?.readEpisode ?? {}).contains(chapterIndex + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
child: Material(

View File

@@ -165,6 +165,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
cid: widget.id,
name: localComic.title,
chapters: localComic.chapters,
initialPage: history?.page,
initialChapter: history?.ep,
initialChapterGroup: history?.group,
history: history ??
History.fromModel(
model: localComic,
@@ -369,7 +372,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -381,16 +384,20 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
bool haveChapter = comic.chapters != null;
var page = history!.page;
var ep = history!.ep;
var group = history!.group;
String text;
if (haveChapter) {
text = "Last Reading: Chapter @ep Page @page".tlParams({
'ep': ep,
'page': page,
});
var epName = group == null
? comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
)
: comic.chapters!
.getGroupByIndex(group - 1)
.values
.elementAt(ep - 1);
text = "${"Last Reading".tl}: $epName P$page";
} else {
text = "Last Reading: Page @page".tlParams({
'page': page,
});
text = "${"Last Reading".tl}: P$page";
}
return Text(text);
},
@@ -607,7 +614,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
}
return _ComicChapters(
history: history,
groupedMode: comic.groupedChapters != null,
groupedMode: comic.chapters!.isGrouped,
);
}

View File

@@ -651,10 +651,16 @@ class _CommentImage {
}
class RichCommentContent extends StatefulWidget {
const RichCommentContent({super.key, required this.text});
const RichCommentContent({
super.key,
required this.text,
this.showImages = true,
});
final String text;
final bool showImages;
@override
State<RichCommentContent> createState() => _RichCommentContentState();
}
@@ -808,7 +814,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
children: textSpan,
),
);
if (images.isNotEmpty) {
if (images.isNotEmpty && widget.showImages) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [

View File

@@ -138,7 +138,10 @@ class _CommentWidget extends StatelessWidget {
),
const SizedBox(height: 4),
Expanded(
child: RichCommentContent(text: comment.content).fixWidth(324),
child: RichCommentContent(
text: comment.content,
showImages: false,
).fixWidth(324),
),
const SizedBox(height: 4),
if (comment.time != null)
@@ -147,4 +150,4 @@ class _CommentWidget extends StatelessWidget {
),
);
}
}
}

View File

@@ -40,10 +40,11 @@ class ComicSourcePage extends StatelessWidget {
}
}
if (shouldUpdate.isNotEmpty) {
var updates = <String, String>{};
for (var key in shouldUpdate) {
ComicSource.availableUpdates[key] = versions[key]!;
updates[key] = versions[key]!;
}
ComicSource.notifyListeners();
ComicSourceManager().updateAvailableUpdates(updates);
}
return shouldUpdate.length;
}
@@ -73,13 +74,13 @@ class _BodyState extends State<_Body> {
@override
void initState() {
super.initState();
ComicSource.addListener(updateUI);
ComicSourceManager().addListener(updateUI);
}
@override
void dispose() {
super.dispose();
ComicSource.removeListener(updateUI);
ComicSourceManager().removeListener(updateUI);
}
@override
@@ -115,7 +116,7 @@ class _BodyState extends State<_Body> {
onConfirm: () {
var file = File(source.filePath);
file.delete();
ComicSource.remove(source.key);
ComicSourceManager().remove(source.key);
_validatePages();
App.forceRebuild();
},
@@ -136,7 +137,7 @@ class _BodyState extends State<_Body> {
child: const Text("cancel")),
TextButton(
onPressed: () async {
await ComicSource.reload();
await ComicSourceManager().reload();
App.forceRebuild();
},
child: const Text("continue")),
@@ -150,7 +151,7 @@ class _BodyState extends State<_Body> {
}
context.to(
() => _EditFilePage(source.filePath, () async {
await ComicSource.reload();
await ComicSourceManager().reload();
setState(() {});
}),
);
@@ -162,7 +163,7 @@ class _BodyState extends State<_Body> {
App.rootContext.showMessage(message: "Invalid url config");
return;
}
ComicSource.remove(source.key);
ComicSourceManager().remove(source.key);
bool cancel = false;
LoadingDialogController? controller;
if (showLoading) {
@@ -179,14 +180,14 @@ class _BodyState extends State<_Body> {
controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!);
if (ComicSource.availableUpdates.containsKey(source.key)) {
ComicSource.availableUpdates.remove(source.key);
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
ComicSourceManager().availableUpdates.remove(source.key);
}
} catch (e) {
if (cancel) return;
App.rootContext.showMessage(message: e.toString());
}
await ComicSource.reload();
await ComicSourceManager().reload();
App.forceRebuild();
}
@@ -304,7 +305,7 @@ class _BodyState extends State<_Body> {
Future<void> addSource(String js, String fileName) async {
var comicSource = await ComicSourceParser().createAndParse(js, fileName);
ComicSource.add(comicSource);
ComicSourceManager().add(comicSource);
_addAllPagesWithComicSource(comicSource);
appdata.saveData();
App.forceRebuild();
@@ -563,7 +564,7 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
}
void showUpdateDialog() async {
var text = ComicSource.availableUpdates.entries.map((e) {
var text = ComicSourceManager().availableUpdates.entries.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}";
}).join("\n");
bool doUpdate = false;
@@ -592,9 +593,9 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
withProgress: true,
);
int current = 0;
int total = ComicSource.availableUpdates.length;
int total = ComicSourceManager().availableUpdates.length;
try {
var shouldUpdate = ComicSource.availableUpdates.keys.toList();
var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
for (var key in shouldUpdate) {
var source = ComicSource.find(key)!;
await _BodyState.update(source, false);
@@ -692,7 +693,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
@override
Widget build(BuildContext context) {
var newVersion = ComicSource.availableUpdates[source.key];
var newVersion = ComicSourceManager().availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
@@ -960,7 +961,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
source.data["account"] = null;
source.account?.logout();
source.saveData();
ComicSource.notifyListeners();
ComicSourceManager().notifyStateChange();
setState(() {});
},
trailing: const Icon(Icons.logout),

View File

@@ -15,6 +15,15 @@ class DownloadingPage extends StatefulWidget {
}
class _DownloadingPageState extends State<DownloadingPage> {
DownloadTask? firstTask;
@override
void didChangeDependencies() {
super.didChangeDependencies();
firstTask = LocalManager().downloadingTasks.firstOrNull;
firstTask?.addListener(update);
}
@override
void initState() {
LocalManager().addListener(update);
@@ -24,10 +33,17 @@ class _DownloadingPageState extends State<DownloadingPage> {
@override
void dispose() {
LocalManager().removeListener(update);
firstTask?.removeListener(update);
super.dispose();
}
void update() {
var currentFirstTask = LocalManager().downloadingTasks.firstOrNull;
if (currentFirstTask != firstTask) {
firstTask?.removeListener(update);
firstTask = currentFirstTask;
firstTask?.addListener(update);
}
if(mounted) {
setState(() {});
}

View File

@@ -518,11 +518,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
),
],
);
body = Scrollbar(
body = AppScrollBar(
topPadding: 48,
controller: scrollController,
thickness: App.isDesktop ? 8 : 12,
radius: const Radius.circular(8),
interactive: true,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: body,

View File

@@ -110,6 +110,15 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
child: Text(widget.data.title),
),
actions: [
Tooltip(
message: "Refresh".tl,
child: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
comicListKey.currentState!.refresh();
},
),
),
MenuButton(entries: [
MenuEntry(
icon: Icons.sync,

View File

@@ -6,6 +6,7 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/translations.dart';
import '../foundation/global_state.dart';
@@ -133,7 +134,18 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
} else if (b.updateTime == null) {
return 1;
}
return b.updateTime!.compareTo(a.updateTime!);
try {
var aNums = a.updateTime!.split('-').map(int.parse).toList();
var bNums = b.updateTime!.split('-').map(int.parse).toList();
for (int i = 0; i < aNums.length; i++) {
if (aNums[i] != bNums[i]) {
return bNums[i] - aNums[i];
}
}
return 0;
} catch (_) {
return 0;
}
});
}
@@ -270,6 +282,27 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
"Updates".tl,
style: ts.s18,
),
const Spacer(),
if (updatedComics.isNotEmpty)
IconButton(
icon: Icon(Icons.clear_all),
onPressed: () {
showConfirmDialog(
context: App.rootContext,
title: "Mark all as read".tl,
content: "Do you want to mark all as read?".tl,
onConfirm: () {
for (var comic in updatedComics) {
LocalFavoritesManager().markAsRead(
comic.id,
comic.type,
);
}
updateFollowUpdatesUI();
},
);
},
),
],
),
),
@@ -408,7 +441,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
}
void setFolder(String folder) async {
FollowUpdatesService.cancelChecking?.call();
FollowUpdatesService._cancelChecking?.call();
LocalFavoritesManager().prepareTableForFollowUpdates(folder);
var count = LocalFavoritesManager().count(folder);
@@ -447,7 +480,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
}
void checkNow() async {
FollowUpdatesService.cancelChecking?.call();
FollowUpdatesService._cancelChecking?.call();
bool isCanceled = false;
void onCancel() {
@@ -570,7 +603,7 @@ void _updateFolderBase(
tags: newTags,
);
LocalFavoritesManager().updateInfo(folder, item);
LocalFavoritesManager().updateInfo(folder, item, false);
var updateTime = newInfo.findUpdateTime();
if (updateTime != null && updateTime != c.updateTime) {
@@ -580,6 +613,8 @@ void _updateFolderBase(
c.type,
updateTime,
);
} else {
LocalFavoritesManager().updateCheckTime(folder, c.id, c.type);
}
updated++;
return;
@@ -606,6 +641,10 @@ void _updateFolderBase(
await Future.wait(futures);
if (updated > 0) {
LocalFavoritesManager().notifyChanges();
}
stream.close();
}
@@ -617,12 +656,14 @@ Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) {
/// Background service for checking updates
abstract class FollowUpdatesService {
static bool isChecking = false;
static bool _isChecking = false;
static void Function()? cancelChecking;
static void Function()? _cancelChecking;
static void check() async {
if (isChecking) {
static bool _isInitialized = false;
static void _check() async {
if (_isChecking) {
return;
}
var folder = appdata.settings["followUpdatesFolder"];
@@ -630,11 +671,16 @@ abstract class FollowUpdatesService {
return;
}
bool isCanceled = false;
cancelChecking = () {
_cancelChecking = () {
isCanceled = true;
};
isChecking = true;
_isChecking = true;
while (DataSync().isDownloading) {
await Future.delayed(const Duration(milliseconds: 100));
}
int updated = 0;
try {
await for (var progress in _updateFolder(folder, false)) {
@@ -644,21 +690,28 @@ abstract class FollowUpdatesService {
updated = progress.updated;
}
} finally {
cancelChecking = null;
isChecking = false;
_cancelChecking = null;
_isChecking = false;
if (updated > 0) {
updateFollowUpdatesUI();
}
}
}
/// Initialize the checker.
static void initChecker() {
Timer.periodic(const Duration(hours: 1), (timer) {
check();
if (_isInitialized) return;
_isInitialized = true;
_check();
DataSync().addListener(updateFollowUpdatesUI);
// A short interval will not affect the performance since every comic has a check time.
Timer.periodic(const Duration(minutes: 10), (timer) {
_check();
});
}
}
/// Update the UI of follow updates.
void updateFollowUpdatesUI() {
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics();

View File

@@ -29,86 +29,211 @@ class _HistoryPageState extends State<HistoryPage> {
void onUpdate() {
setState(() {
comics = HistoryManager().getAll();
if (multiSelectMode) {
selectedComics.removeWhere((comic, _) => !comics.contains(comic));
if (selectedComics.isEmpty) {
multiSelectMode = false;
}
}
});
}
var comics = HistoryManager().getAll();
var controller = FlyoutController();
bool multiSelectMode = false;
Map<History, bool> selectedComics = {};
void selectAll() {
setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
});
}
void deSelect() {
setState(() {
selectedComics.clear();
});
}
void invertSelection() {
setState(() {
comics.asMap().forEach((k, v) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
});
selectedComics.removeWhere((k, v) => !v);
});
}
void _removeHistory(History comic) {
if (comic.sourceKey.startsWith("Unknown")) {
HistoryManager().remove(
comic.id,
ComicType(int.parse(comic.sourceKey.split(':')[1])),
);
} else if (comic.sourceKey == 'local') {
HistoryManager().remove(
comic.id,
ComicType.local,
);
} else {
HistoryManager().remove(
comic.id,
ComicType(comic.sourceKey.hashCode),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text('History'.tl),
actions: [
Tooltip(
message: 'Clear History'.tl,
child: Flyout(
controller: controller,
flyoutBuilder: (context) {
return FlyoutContent(
title: 'Clear History'.tl,
content: Text(
'Are you sure you want to clear your history?'.tl),
actions: [
Button.filled(
color: context.colorScheme.error,
onPressed: () {
HistoryManager().clearHistory();
context.pop();
},
child: Text('Clear'.tl),
),
],
);
},
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
controller.show();
},
),
),
)
],
),
SliverGridComics(
comics: comics,
badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name;
},
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.remove,
text: 'Remove'.tl,
List<Widget> selectActions = [
IconButton(
icon: const Icon(Icons.select_all),
tooltip: "Select All".tl,
onPressed: selectAll
),
IconButton(
icon: const Icon(Icons.deselect),
tooltip: "Deselect".tl,
onPressed: deSelect
),
IconButton(
icon: const Icon(Icons.flip),
tooltip: "Invert Selection".tl,
onPressed: invertSelection
),
IconButton(
icon: const Icon(Icons.delete),
tooltip: "Delete".tl,
onPressed: selectedComics.isEmpty
? null
: () {
final comicsToDelete = List<History>.from(selectedComics.keys);
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
for (final comic in comicsToDelete) {
_removeHistory(comic);
}
},
),
];
List<Widget> normalActions = [
IconButton(
icon: const Icon(Icons.checklist),
tooltip: multiSelectMode ? "Exit Multi-Select".tl : "Multi-Select".tl,
onPressed: () {
setState(() {
multiSelectMode = !multiSelectMode;
});
},
),
Tooltip(
message: 'Clear History'.tl,
child: Flyout(
controller: controller,
flyoutBuilder: (context) {
return FlyoutContent(
title: 'Clear History'.tl,
content: Text('Are you sure you want to clear your history?'.tl),
actions: [
Button.filled(
color: context.colorScheme.error,
onClick: () {
if (c.sourceKey.startsWith("Unknown")) {
HistoryManager().remove(
c.id,
ComicType(int.parse(c.sourceKey.split(':')[1])),
);
} else if (c.sourceKey == 'local') {
HistoryManager().remove(
c.id,
ComicType.local,
);
onPressed: () {
HistoryManager().clearHistory();
context.pop();
},
child: Text('Clear'.tl),
),
],
);
},
child: IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () {
controller.show();
},
),
),
)
];
return PopScope(
canPop: !multiSelectMode,
onPopInvokedWithResult: (didPop, result) {
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
}
},
child: Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
leading: Tooltip(
message: multiSelectMode ? "Cancel".tl : "Back".tl,
child: IconButton(
onPressed: () {
if (multiSelectMode) {
setState(() {
multiSelectMode = false;
selectedComics.clear();
});
} else {
HistoryManager().remove(
c.id,
ComicType(c.sourceKey.hashCode),
);
context.pop();
}
},
icon: multiSelectMode
? const Icon(Icons.close)
: const Icon(Icons.arrow_back),
),
];
},
),
],
),
title: multiSelectMode
? Text(selectedComics.length.toString())
: Text('History'.tl),
actions: multiSelectMode ? selectActions : normalActions,
),
SliverGridComics(
comics: comics,
selections: selectedComics,
onLongPressed: null,
onTap: multiSelectMode
? (c) {
setState(() {
if (selectedComics.containsKey(c as History)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
if (selectedComics.isEmpty) {
multiSelectMode = false;
}
});
}
: null,
badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name;
},
menuBuilder: (c) {
return [
MenuEntry(
icon: Icons.remove,
text: 'Remove'.tl,
color: context.colorScheme.error,
onClick: () {
_removeHistory(c as History);
},
),
];
},
),
],
),
),
);
}

View File

@@ -162,16 +162,50 @@ class _SyncDataWidgetState extends State<_SyncDataWidget>
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (DataSync().lastError != null)
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
showDialogMessage(
App.rootContext,
"Error".tl,
DataSync().lastError!,
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Colors.red,
size: 18,
),
const SizedBox(width: 4),
Text('Error'.tl, style: ts.s12),
],
),
),
).paddingRight(4),
IconButton(
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}),
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
},
),
IconButton(
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}),
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
},
),
],
),
),
@@ -197,10 +231,12 @@ class _HistoryState extends State<_History> {
late int count;
void onHistoryChange() {
setState(() {
history = HistoryManager().getRecent();
count = HistoryManager().count();
});
if (mounted) {
setState(() {
history = HistoryManager().getRecent();
count = HistoryManager().count();
});
}
}
@override
@@ -261,7 +297,7 @@ class _HistoryState extends State<_History> {
).paddingHorizontal(16),
if (history.isNotEmpty)
SizedBox(
height: 128,
height: 136,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: history.length,
@@ -364,13 +400,14 @@ class _LocalState extends State<_Local> {
).paddingHorizontal(16),
if (local.isNotEmpty)
SizedBox(
height: 128,
height: 136,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: local.length,
itemBuilder: (context, index) {
return SimpleComicTile(comic: local[index])
.paddingHorizontal(8);
.paddingHorizontal(8)
.paddingVertical(2);
},
),
).paddingHorizontal(8),
@@ -536,7 +573,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
],
),
onPressed: () {
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
},
).fixWidth(90).paddingRight(8),
Button.filled(
@@ -593,16 +631,29 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
@override
void initState() {
comicSources = ComicSource.all().map((e) => e.name).toList();
ComicSource.addListener(onComicSourceChange);
ComicSourceManager().addListener(onComicSourceChange);
super.initState();
}
@override
void dispose() {
ComicSource.removeListener(onComicSourceChange);
ComicSourceManager().removeListener(onComicSourceChange);
super.dispose();
}
int get _availableUpdates {
int c = 0;
ComicSourceManager().availableUpdates.forEach((key, version) {
var source = ComicSource.find(key);
if (source != null) {
if (compareSemVer(version, source.version)) {
c++;
}
}
});
return c;
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
@@ -666,7 +717,7 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
}).toList(),
).paddingHorizontal(16).paddingBottom(16),
),
if (ComicSource.availableUpdates.isNotEmpty)
if (_availableUpdates > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
@@ -682,14 +733,24 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.update, color: context.colorScheme.primary, size: 20,),
Icon(
Icons.update,
color: context.colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text("@c updates".tlParams({
'c': ComicSource.availableUpdates.length,
}), style: ts.withColor(context.colorScheme.primary),),
Text(
"@c updates".tlParams({
'c': _availableUpdates,
}),
style: ts.withColor(context.colorScheme.primary),
),
],
),
).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8),
)
.toAlign(Alignment.centerLeft)
.paddingHorizontal(16)
.paddingBottom(8),
],
),
),
@@ -829,7 +890,8 @@ class _ImageFavoritesState extends State<ImageFavorites> {
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
color:
Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
@@ -304,7 +305,9 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
}
});
} else {
(c as LocalComic).read();
// prevent dirty data
var comic = LocalManager().find(c.id, ComicType(c.sourceKey.hashCode))!;
comic.read();
}
},
menuBuilder: (c) {

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/pages/categories_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
@@ -39,6 +40,7 @@ class _MainPageState extends State<MainPage> {
_observer = NaviObserver();
_navigatorKey = GlobalKey();
App.mainNavigatorKey = _navigatorKey;
index = int.tryParse(appdata.settings['initialPage'].toString()) ?? 0;
super.initState();
}
@@ -60,6 +62,7 @@ class _MainPageState extends State<MainPage> {
@override
Widget build(BuildContext context) {
return NaviPane(
initialPage: index,
observer: _observer,
navigatorKey: _navigatorKey!,
paneItems: [

View File

@@ -0,0 +1,242 @@
part of 'reader.dart';
class _ChaptersView extends StatefulWidget {
const _ChaptersView(this.reader);
final _ReaderState reader;
@override
State<_ChaptersView> createState() => _ChaptersViewState();
}
class _ChaptersViewState extends State<_ChaptersView> {
bool desc = false;
late final ScrollController _scrollController;
var downloaded = <String>[];
@override
void initState() {
super.initState();
int epIndex = widget.reader.chapter - 2;
_scrollController = ScrollController(
initialScrollOffset: (epIndex * 48.0 + 52).clamp(0, double.infinity),
);
var local = LocalManager().find(widget.reader.cid, widget.reader.type);
if (local != null) {
downloaded = local.downloadedChapters;
}
}
@override
Widget build(BuildContext context) {
var chapters = widget.reader.widget.chapters!;
var current = widget.reader.chapter - 1;
return Scaffold(
body: SmoothCustomScrollView(
controller: _scrollController,
slivers: [
SliverAppbar(
style: AppbarStyle.shadow,
title: Text("Chapters".tl),
actions: [
Tooltip(
message: "Click to change the order".tl,
child: TextButton.icon(
icon: Icon(
!desc ? Icons.arrow_upward : Icons.arrow_downward,
size: 18,
),
label: Text(!desc ? "Ascending".tl : "Descending".tl),
onPressed: () {
setState(() {
desc = !desc;
});
},
),
),
],
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (desc) {
index = chapters.length - 1 - index;
}
var chapter = chapters.titles.elementAt(index);
return _ChapterListTile(
onTap: () {
widget.reader.toChapter(index + 1);
Navigator.of(context).pop();
},
title: chapter,
isActive: current == index,
isDownloaded:
downloaded.contains(chapters.ids.elementAt(index)),
);
},
childCount: chapters.length,
),
),
],
),
);
}
}
class _GroupedChaptersView extends StatefulWidget {
const _GroupedChaptersView(this.reader);
final _ReaderState reader;
@override
State<_GroupedChaptersView> createState() => _GroupedChaptersViewState();
}
class _GroupedChaptersViewState extends State<_GroupedChaptersView>
with SingleTickerProviderStateMixin {
ComicChapters get chapters => widget.reader.widget.chapters!;
late final TabController tabController;
late final ScrollController _scrollController;
late final String initialGroupName;
var downloaded = <String>[];
@override
void initState() {
super.initState();
int index = 0;
int epIndex = widget.reader.chapter - 1;
while (epIndex >= 0) {
epIndex -= chapters.getGroupByIndex(index).length;
index++;
}
tabController = TabController(
length: chapters.groups.length,
vsync: this,
initialIndex: index - 1,
);
initialGroupName = chapters.groups.elementAt(index - 1);
var epIndexAtGroup = widget.reader.chapter - 1;
for (var i = 0; i < index - 1; i++) {
epIndexAtGroup -= chapters.getGroupByIndex(i).length;
}
_scrollController = ScrollController(
initialScrollOffset: (epIndexAtGroup * 48.0).clamp(0, double.infinity),
);
var local = LocalManager().find(widget.reader.cid, widget.reader.type);
if (local != null) {
downloaded = local.downloadedChapters;
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Appbar(title: Text("Chapters".tl)),
AppTabBar(
controller: tabController,
tabs: chapters.groups.map((e) => Tab(text: e)).toList(),
),
Expanded(
child: TabViewBody(
controller: tabController,
children: chapters.groups.map(buildGroup).toList(),
),
),
],
);
}
Widget buildGroup(String groupName) {
var group = chapters.getGroup(groupName);
return SmoothCustomScrollView(
controller: initialGroupName == groupName ? _scrollController : null,
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
var name = group.values.elementAt(index);
var i = 0;
for (var g in chapters.groups) {
if (g == groupName) {
break;
}
i += chapters.getGroup(g).length;
}
i += index + 1;
return _ChapterListTile(
onTap: () {
widget.reader.toChapter(i);
context.pop();
},
title: name,
isActive: widget.reader.chapter == i,
isDownloaded: downloaded.contains(group.keys.elementAt(index)),
);
},
childCount: group.length,
),
),
],
);
}
}
class _ChapterListTile extends StatelessWidget {
const _ChapterListTile({
required this.title,
required this.isActive,
required this.isDownloaded,
required this.onTap,
});
final String title;
final bool isActive;
final bool isDownloaded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color:
isActive ? context.colorScheme.primary : Colors.transparent,
width: 4,
),
),
),
child: Row(
children: [
Text(
title,
style: isActive
? ts.withColor(context.colorScheme.primary).bold.s16
: ts.s16,
),
const Spacer(),
if (isDownloaded)
Icon(
Icons.download_done_rounded,
color: context.colorScheme.secondary,
),
],
),
),
);
}
}

View File

@@ -131,11 +131,11 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
}
if (context.reader.mode.key.startsWith('gallery')) {
if (forward) {
if (!context.reader.toNextPage()) {
if (!context.reader.toNextPage() && !context.reader.isLastChapterOfGroup) {
context.reader.toNextChapter();
}
} else {
if (!context.reader.toPrevPage()) {
if (!context.reader.toPrevPage() && !context.reader.isFirstChapterOfGroup) {
context.reader.toPrevChapter();
}
}

View File

@@ -26,7 +26,7 @@ class _ReaderImagesState extends State<_ReaderImages> {
inProgress = true;
if (reader.type == ComicType.local ||
(LocalManager()
.isDownloaded(reader.cid, reader.type, reader.chapter))) {
.isDownloaded(reader.cid, reader.type, reader.chapter, reader.widget.chapters))) {
try {
var images = await LocalManager()
.getImages(reader.cid, reader.type, reader.chapter);
@@ -45,7 +45,7 @@ class _ReaderImagesState extends State<_ReaderImages> {
} else {
var res = await reader.type.comicSource!.loadComicPages!(
reader.widget.cid,
reader.widget.chapters?.keys.elementAt(reader.chapter - 1),
reader.widget.chapters?.ids.elementAt(reader.chapter - 1),
);
if (res.error) {
setState(() {
@@ -154,7 +154,6 @@ class _GalleryModeState extends State<_GalleryMode>
builder: (BuildContext context, int index) {
if (index == 0 || index == totalPages + 1) {
return PhotoViewGalleryPageOptions.customChild(
scaleStateController: PhotoViewScaleStateController(),
child: const SizedBox(),
);
} else {
@@ -168,7 +167,7 @@ class _GalleryModeState extends State<_GalleryMode>
cached[index] = true;
cache(index);
photoViewControllers[index] = PhotoViewController();
photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage == 1) {
return PhotoViewGalleryPageOptions(
@@ -206,11 +205,11 @@ class _GalleryModeState extends State<_GalleryMode>
),
onPageChanged: (i) {
if (i == 0) {
if (!reader.toPrevChapter()) {
if (reader.isFirstChapterOfGroup || !reader.toPrevChapter()) {
reader.toPage(1);
}
} else if (i == totalPages + 1) {
if (!reader.toNextChapter()) {
if (reader.isLastChapterOfGroup || !reader.toNextChapter()) {
reader.toPage(totalPages);
}
} else {
@@ -232,7 +231,7 @@ class _GalleryModeState extends State<_GalleryMode>
ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context);
return Expanded(
child: Image(
child: ComicImage(
image: imageProvider,
fit: BoxFit.contain,
),
@@ -350,6 +349,8 @@ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
PointerDeviceKind.unknown
};
const double _kChangeChapterOffset = 160;
class _ContinuousMode extends StatefulWidget {
const _ContinuousMode({super.key});
@@ -364,7 +365,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
var itemScrollController = ItemScrollController();
var itemPositionsListener = ItemPositionsListener.create();
var photoViewController = PhotoViewController();
late ScrollController scrollController;
ScrollController? _scrollController;
ScrollController get scrollController => _scrollController!;
var isCTRLPressed = false;
static var _isMouseScrolling = false;
@@ -372,6 +375,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
bool disableScroll = false;
late List<bool> cached;
int get preCacheCount => appdata.settings["preloadImageCount"];
/// Whether the user was scrolling the page.
@@ -386,6 +390,11 @@ class _ContinuousModeState extends State<_ContinuousMode>
);
}
bool prepareToPrevChapter = false;
bool prepareToNextChapter = false;
bool jumpToNextChapter = false;
bool jumpToPrevChapter = false;
@override
void initState() {
reader = context.reader;
@@ -406,6 +415,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
}
void onPositionChanged() {
if (itemPositionsListener.itemPositions.value.isEmpty) {
return;
}
var page = itemPositionsListener.itemPositions.value.first.index;
page = page.clamp(1, reader.maxPage);
if (page != reader.page) {
@@ -461,6 +473,18 @@ class _ContinuousModeState extends State<_ContinuousMode>
}
}
void onScroll() {
if (prepareToPrevChapter) {
jumpToNextChapter = false;
jumpToPrevChapter = scrollController.offset <
scrollController.position.minScrollExtent - _kChangeChapterOffset;
} else if (prepareToNextChapter) {
jumpToNextChapter = scrollController.offset >
scrollController.position.maxScrollExtent + _kChangeChapterOffset;
jumpToPrevChapter = false;
}
}
@override
Widget build(BuildContext context) {
Widget widget = ScrollablePositionedList.builder(
@@ -468,7 +492,11 @@ class _ContinuousModeState extends State<_ContinuousMode>
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
scrollControllerCallback: (scrollController) {
this.scrollController = scrollController;
if (_scrollController != null) {
_scrollController!.removeListener(onScroll);
}
_scrollController = scrollController;
_scrollController!.addListener(onScroll);
},
itemCount: reader.maxPage + 2,
addSemanticIndexes: false,
@@ -478,7 +506,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
reverse: reader.mode == ReaderMode.continuousRightToLeft,
physics: isCTRLPressed || _isMouseScrolling || disableScroll
? const NeverScrollableScrollPhysics()
: const ClampingScrollPhysics(),
: const BouncingScrollPhysics(),
itemBuilder: (context, index) {
if (index == 0 || index == reader.maxPage + 1) {
return const SizedBox();
@@ -493,18 +521,28 @@ class _ContinuousModeState extends State<_ContinuousMode>
ImageProvider image = _createImageProvider(index, context);
return ComicImage(
filterQuality: FilterQuality.medium,
image: image,
width: width,
height: height,
fit: BoxFit.contain,
return ColoredBox(
color: context.colorScheme.surface,
child: ComicImage(
filterQuality: FilterQuality.medium,
image: image,
width: width,
height: height,
fit: BoxFit.contain,
),
);
},
scrollBehavior: const MaterialScrollBehavior()
.copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes),
);
widget = Stack(
children: [
Positioned.fill(child: buildBackground(context)),
Positioned.fill(child: widget),
],
);
widget = Listener(
onPointerDown: (event) {
fingers++;
@@ -527,6 +565,15 @@ class _ContinuousModeState extends State<_ContinuousMode>
disableScroll = false;
});
}
if (fingers == 0) {
if (jumpToPrevChapter) {
context.readerScaffold.setFloatingButton(0);
reader.toPrevChapter();
} else if (jumpToNextChapter) {
context.readerScaffold.setFloatingButton(0);
reader.toNextChapter();
}
}
},
onPointerCancel: (event) {
fingers--;
@@ -572,18 +619,39 @@ class _ContinuousModeState extends State<_ContinuousMode>
}
if (notification is ScrollUpdateNotification) {
var length = reader.maxChapter;
if (!scrollController.hasClients) return false;
if (scrollController.position.pixels <=
scrollController.position.minScrollExtent &&
reader.chapter != 1) {
context.readerScaffold.setFloatingButton(-1);
scrollController.position.minScrollExtent &&
!reader.isFirstChapterOfGroup) {
if (!prepareToPrevChapter) {
jumpToPrevChapter = false;
jumpToNextChapter = false;
context.readerScaffold.setFloatingButton(-1);
setState(() {
prepareToPrevChapter = true;
});
}
} else if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent &&
reader.chapter < length) {
context.readerScaffold.setFloatingButton(1);
scrollController.position.maxScrollExtent &&
!reader.isLastChapterOfGroup) {
if (!prepareToNextChapter) {
jumpToPrevChapter = false;
jumpToNextChapter = false;
context.readerScaffold.setFloatingButton(1);
setState(() {
prepareToNextChapter = true;
});
}
} else {
context.readerScaffold.setFloatingButton(0);
if (prepareToPrevChapter || prepareToNextChapter) {
jumpToPrevChapter = false;
jumpToNextChapter = false;
setState(() {
prepareToPrevChapter = false;
prepareToNextChapter = false;
});
}
}
}
@@ -616,6 +684,26 @@ class _ContinuousModeState extends State<_ContinuousMode>
);
}
Widget buildBackground(BuildContext context) {
return Column(
children: [
SizedBox(height: context.padding.top + 16),
if (prepareToPrevChapter)
_SwipeChangeChapterProgress(
controller: scrollController,
isPrev: true,
),
const Spacer(),
if (prepareToNextChapter)
_SwipeChangeChapterProgress(
controller: scrollController,
isPrev: false,
),
SizedBox(height: 36),
],
);
}
@override
Future<void> animateToPage(int page) {
return itemScrollController.scrollTo(
@@ -756,3 +844,138 @@ void _precacheImage(int page, BuildContext context) {
context,
);
}
class _SwipeChangeChapterProgress extends StatefulWidget {
const _SwipeChangeChapterProgress({
this.controller,
required this.isPrev,
});
final ScrollController? controller;
final bool isPrev;
@override
State<_SwipeChangeChapterProgress> createState() =>
_SwipeChangeChapterProgressState();
}
class _SwipeChangeChapterProgressState
extends State<_SwipeChangeChapterProgress> {
double value = 0;
late final isPrev = widget.isPrev;
ScrollController? controller;
@override
void initState() {
super.initState();
if (widget.controller != null) {
controller = widget.controller;
controller!.addListener(onScroll);
}
}
@override
void didUpdateWidget(covariant _SwipeChangeChapterProgress oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
controller?.removeListener(onScroll);
controller = widget.controller;
controller?.addListener(onScroll);
if (value != 0) {
setState(() {
value = 0;
});
}
}
}
@override
void dispose() {
super.dispose();
controller?.removeListener(onScroll);
}
void onScroll() {
var position = controller!.position.pixels;
var offset = isPrev
? controller!.position.minScrollExtent - position
: position - controller!.position.maxScrollExtent;
var newValue = offset / _kChangeChapterOffset;
newValue = newValue.clamp(0.0, 1.0);
if (newValue != value) {
setState(() {
value = newValue;
});
}
}
@override
Widget build(BuildContext context) {
final msg = widget.isPrev
? "Swipe down for previous chapter".tl
: "Swipe up for next chapter".tl;
return CustomPaint(
painter: _ProgressPainter(
value: value,
backgroundColor: context.colorScheme.surfaceContainerLow,
color: context.colorScheme.surfaceContainerHighest,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
widget.isPrev ? Icons.arrow_downward : Icons.arrow_upward,
color: context.colorScheme.onSurface,
size: 16,
),
const SizedBox(width: 4),
Text(msg),
],
).paddingVertical(6).paddingHorizontal(16),
);
}
}
class _ProgressPainter extends CustomPainter {
final double value;
final Color backgroundColor;
final Color color;
const _ProgressPainter({
required this.value,
required this.backgroundColor,
required this.color,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromLTRBR(0, 0, size.width, size.height, Radius.circular(16)),
paint,
);
paint.color = color;
canvas.drawRRect(
RRect.fromLTRBR(
0, 0, size.width * value, size.height, Radius.circular(16)),
paint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! _ProgressPainter ||
oldDelegate.value != value ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.color != color;
}
}

View File

@@ -33,6 +33,7 @@ class _ReaderWithLoadingState
history: data.history,
initialChapter: widget.initialEp ?? data.history.ep,
initialPage: widget.initialPage ?? data.history.page,
initialChapterGroup: data.history.group,
author: data.author,
tags: data.tags,
);
@@ -101,7 +102,7 @@ class ReaderProps {
final String name;
final Map<String, String>? chapters;
final ComicChapters? chapters;
final History history;

View File

@@ -15,6 +15,7 @@ import 'package:photo_view/photo_view_gallery.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:venera/components/components.dart';
import 'package:venera/components/custom_slider.dart';
import 'package:venera/components/window_frame.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart';
@@ -40,11 +41,17 @@ import 'package:window_manager/window_manager.dart';
import 'package:battery_plus/battery_plus.dart';
part 'scaffold.dart';
part 'images.dart';
part 'gesture.dart';
part 'comic_image.dart';
part 'loading.dart';
part 'chapters.dart';
extension _ReaderContext on BuildContext {
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
@@ -62,6 +69,7 @@ class Reader extends StatefulWidget {
required this.history,
this.initialPage,
this.initialChapter,
this.initialChapterGroup,
required this.author,
required this.tags,
});
@@ -76,9 +84,7 @@ class Reader extends StatefulWidget {
final String name;
/// key: Chapter ID, value: Chapter Name
/// null if the comic is a gallery
final Map<String, String>? chapters;
final ComicChapters? chapters;
/// Starts from 1, invalid values equal to 1
final int? initialPage;
@@ -86,13 +92,17 @@ class Reader extends StatefulWidget {
/// Starts from 1, invalid values equal to 1
final int? initialChapter;
/// Starts from 1, invalid values equal to 1
final int? initialChapterGroup;
final History history;
@override
State<Reader> createState() => _ReaderState();
}
class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
class _ReaderState extends State<Reader>
with _ReaderLocation, _ReaderWindow, _VolumeListener, _ImagePerPageHandler {
@override
void update() {
setState(() {});
@@ -105,37 +115,15 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
String get cid => widget.cid;
String get eid => widget.chapters?.keys.elementAt(chapter - 1) ?? '0';
String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
List<String>? images;
@override
late ReaderMode mode;
int get imagesPerPage => appdata.settings['readerScreenPicNumber'] ?? 1;
int _lastImagesPerPage = appdata.settings['readerScreenPicNumber'] ?? 1;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_checkImagesPerPageChange();
}
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
}
}
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
}
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait;
History? history;
@@ -144,18 +132,24 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
var focusNode = FocusNode();
VolumeListener? volumeListener;
@override
void initState() {
page = widget.initialPage ?? 1;
chapter = widget.initialChapter ?? 1;
if (page < 1) {
page = 1;
}
chapter = widget.initialChapter ?? 1;
if (chapter < 1) {
chapter = 1;
}
if (widget.initialChapterGroup != null) {
for (int i = 0; i < (widget.initialChapterGroup! - 1); i++) {
chapter += widget.chapters!.getGroupByIndex(i).length;
}
}
if (widget.initialPage != null) {
page = widget.initialPage!;
}
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
history = widget.history;
Future.microtask(() {
@@ -172,6 +166,13 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
initImagesPerPage(widget.initialPage ?? 1);
initReaderWindow();
}
void setImageCacheSize() async {
var availableRAM = await MemoryInfo.getFreePhysicalMemorySize();
if (availableRAM == null) return;
@@ -192,6 +193,9 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
@override
void dispose() {
if (isFullscreen) {
fullscreen();
}
autoPageTurningTimer?.cancel();
focusNode.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -200,6 +204,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
DataSync().onDataChanged();
});
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20;
disposeReaderWindow();
super.dispose();
}
@@ -219,6 +224,9 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
}
void onKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.f12 && event is KeyUpEvent) {
fullscreen();
}
_imageViewController?.handleKeyEvent(event);
}
@@ -236,12 +244,28 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
void updateHistory() {
if (history != null) {
history!.page = page;
history!.ep = chapter;
if (maxPage > 1) {
history!.maxPage = maxPage;
if (page == maxPage) {
/// Record the last image of chapter
history!.page = images?.length ?? 1;
} else {
/// Record the first image of the page
history!.page = (page - 1) * imagesPerPage + 1;
}
history!.maxPage = images?.length ?? 1;
if (widget.chapters?.isGrouped ?? false) {
int g = 0;
int c = chapter;
while (c > widget.chapters!.getGroupByIndex(g).length) {
c -= widget.chapters!.getGroupByIndex(g).length;
g++;
}
history!.readEpisode.add('${g + 1}-$c');
history!.ep = c;
history!.group = g + 1;
} else {
history!.readEpisode.add(chapter.toString());
history!.ep = chapter;
}
history!.readEpisode.add(chapter);
history!.time = DateTime.now();
_updateHistoryTimer?.cancel();
_updateHistoryTimer = Timer(const Duration(seconds: 1), () {
@@ -251,6 +275,95 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
}
}
bool get isFirstChapterOfGroup {
if (widget.chapters?.isGrouped ?? false) {
int c = chapter - 1;
int g = 1;
while (c > 0) {
c -= widget.chapters!.getGroupByIndex(g - 1).length;
g++;
}
if (c == 0) {
return true;
} else {
return false;
}
}
return chapter == 1;
}
bool get isLastChapterOfGroup {
if (widget.chapters?.isGrouped ?? false) {
int c = chapter;
int g = 1;
while (c > 0) {
c -= widget.chapters!.getGroupByIndex(g - 1).length;
g++;
}
if (c == 0) {
return true;
} else {
return false;
}
}
return chapter == maxChapter;
}
}
abstract mixin class _ImagePerPageHandler {
late int _lastImagesPerPage;
bool get isPortrait;
int get page;
set page(int value);
ReaderMode get mode;
void initImagesPerPage(int initialPage) {
_lastImagesPerPage = imagesPerPage;
if (imagesPerPage != 1) {
page = (initialPage / imagesPerPage).ceil();
}
}
/// The number of images displayed on one screen
int get imagesPerPage {
if (mode.isContinuous) return 1;
if (isPortrait) {
return appdata.settings['readerScreenPicNumberForPortrait'] ?? 1;
} else {
return appdata.settings['readerScreenPicNumberForLandscape'] ?? 1;
}
}
/// Check if the number of images per page has changed
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
}
}
/// Adjust the page number when the number of images per page changes
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
}
}
abstract mixin class _VolumeListener {
bool toNextPage();
bool toPrevPage();
VolumeListener? volumeListener;
void handleVolumeEvent() {
if (!App.isAndroid) {
// Currently only support Android
@@ -260,12 +373,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
volumeListener?.cancel();
}
volumeListener = VolumeListener(
onDown: () {
toNextPage();
},
onUp: () {
toPrevPage();
},
onDown: toNextPage,
onUp: toPrevPage,
)..listen();
}
@@ -329,11 +438,8 @@ abstract mixin class _ReaderLocation {
bool toPage(int page) {
if (_validatePage(page)) {
if (page == this.page) {
if (!(chapter == 1 && page == 1) &&
!(chapter == maxChapter && page == maxPage)) {
return false;
}
if (page == this.page && page != 1 && page != maxPage) {
return false;
}
this.page = page;
update();
@@ -395,9 +501,38 @@ abstract mixin class _ReaderLocation {
mixin class _ReaderWindow {
bool isFullscreen = false;
void fullscreen() {
windowManager.setFullScreen(!isFullscreen);
late WindowFrameController windowFrame;
bool _isInit = false;
void initReaderWindow() {
if (!App.isDesktop || _isInit) return;
windowFrame = WindowFrame.of(App.rootContext);
windowFrame.addCloseListener(onWindowClose);
_isInit = true;
}
void fullscreen() async {
if (!App.isDesktop) return;
await windowManager.hide();
await windowManager.setFullScreen(!isFullscreen);
await windowManager.show();
isFullscreen = !isFullscreen;
WindowFrame.of(App.rootContext).setWindowFrame(!isFullscreen);
}
bool onWindowClose() {
if (Navigator.of(App.rootContext).canPop()) {
Navigator.of(App.rootContext).pop();
return false;
} else {
return true;
}
}
void disposeReaderWindow() {
if (!App.isDesktop) return;
windowFrame.removeCloseListener(onWindowClose);
}
}

View File

@@ -26,73 +26,21 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
var lastValue = 0;
var fABValue = ValueNotifier<double>(0);
_ReaderGestureDetectorState? _gestureDetectorState;
_DragListener? _floatingButtonDragListener;
void setFloatingButton(int value) {
lastValue = showFloatingButtonValue;
if (value == 0) {
if (showFloatingButtonValue != 0) {
showFloatingButtonValue = 0;
fABValue.value = 0;
update();
}
if (_floatingButtonDragListener != null) {
_gestureDetectorState!.removeDragListener(_floatingButtonDragListener!);
_floatingButtonDragListener = null;
}
}
var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1;
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy;
} else if (readerMode == ReaderMode.continuousLeftToRight) {
fABValue.value -= offset.dx;
} else if (readerMode == ReaderMode.continuousRightToLeft) {
fABValue.value += offset.dx;
}
},
onEnd: () {
if (fABValue.value.abs() > 58 * 3) {
setState(() {
showFloatingButtonValue = 0;
});
context.reader.toNextChapter();
}
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update();
} else if (value == -1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = -1;
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value += offset.dy;
} else if (readerMode == ReaderMode.continuousLeftToRight) {
fABValue.value += offset.dx;
} else if (readerMode == ReaderMode.continuousRightToLeft) {
fABValue.value -= offset.dx;
}
},
onEnd: () {
if (fABValue.value.abs() > 58 * 3) {
setState(() {
showFloatingButtonValue = 0;
});
context.reader.toPrevChapter();
}
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update();
}
}
@@ -279,7 +227,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
List<String> tags = context.reader.widget.tags;
String author = context.reader.widget.author;
var epName = context.reader.widget.chapters?.values
var epName = context.reader.widget.chapters?.titles
.elementAtOrNull(context.reader.chapter - 1) ??
"E${context.reader.chapter}";
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
@@ -561,7 +509,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
}
Widget buildPageInfoText() {
var epName = context.reader.widget.chapters?.values
var epName = context.reader.widget.chapters?.titles
.elementAtOrNull(context.reader.chapter - 1) ??
"E${context.reader.chapter}";
if (epName.length > 8) {
@@ -614,7 +562,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
void openChapterDrawer() {
showSideBar(
context,
_ChaptersView(context.reader),
context.reader.widget.chapters!.isGrouped
? _GroupedChaptersView(context.reader)
: _ChaptersView(context.reader),
width: 400,
);
}
@@ -776,62 +726,35 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
case -1:
case 1:
return Container(
return SizedBox(
width: 58,
height: 58,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
child: Material(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: ValueListenableBuilder(
valueListenable: fABValue,
builder: (context, value, child) {
return Stack(
children: [
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
if (showFloatingButtonValue == 1) {
context.reader.toNextChapter();
} else if (showFloatingButtonValue == -1) {
context.reader.toPrevChapter();
}
setFloatingButton(0);
},
borderRadius: BorderRadius.circular(16),
child: Center(
child: Icon(
showFloatingButtonValue == 1
? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined,
size: 24,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
height: value.clamp(0, 58 * 3) / 3,
child: ColoredBox(
color: Theme.of(context)
.colorScheme
.surfaceTint
.toOpacity(0.2),
child: const SizedBox.expand(),
),
),
],
);
},
elevation: 2,
child: InkWell(
onTap: () {
if (showFloatingButtonValue == 1) {
context.reader.toNextChapter();
} else if (showFloatingButtonValue == -1) {
context.reader.toPrevChapter();
}
setFloatingButton(0);
},
borderRadius: BorderRadius.circular(16),
child: Center(
child: Icon(
showFloatingButtonValue == 1
? Icons.arrow_forward_ios
: Icons.arrow_back_ios_outlined,
size: 24,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer,
),
),
),
),
);
}
@@ -1017,79 +940,3 @@ class _ClockWidgetState extends State<_ClockWidget> {
);
}
}
class _ChaptersView extends StatefulWidget {
const _ChaptersView(this.reader);
final _ReaderState reader;
@override
State<_ChaptersView> createState() => _ChaptersViewState();
}
class _ChaptersViewState extends State<_ChaptersView> {
bool desc = false;
@override
Widget build(BuildContext context) {
var chapters = widget.reader.widget.chapters!;
var current = widget.reader.chapter - 1;
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text("Chapters".tl),
actions: [
Tooltip(
message: "Click to change the order".tl,
child: TextButton.icon(
icon: Icon(
!desc ? Icons.arrow_upward : Icons.arrow_downward,
size: 18,
),
label: Text(!desc ? "Ascending".tl : "Descending".tl),
onPressed: () {
setState(() {
desc = !desc;
});
},
),
),
],
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (desc) {
index = chapters.length - 1 - index;
}
var chapter = chapters.values.elementAt(index);
return ListTile(
shape: Border(
left: BorderSide(
color: current == index
? context.colorScheme.primary
: Colors.transparent,
width: 4,
),
),
title: Text(
chapter,
style: current == index
? ts.withColor(context.colorScheme.primary).bold
: null,
),
onTap: () {
widget.reader.toChapter(index + 1);
Navigator.of(context).pop();
},
);
},
childCount: chapters.length,
),
),
],
),
);
}
}

View File

@@ -330,10 +330,11 @@ class _WebdavSettingState extends State<_WebdavSetting> {
String url = "";
String user = "";
String pass = "";
bool autoSync = false;
bool isTesting = false;
bool upload = true;
bool isEnabled = false;
@override
void initState() {
@@ -348,6 +349,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
url = configs[0];
user = configs[1];
pass = configs[2];
isEnabled = true;
autoSync = appdata.implicitData['webdavAutoSync'] ?? false;
}
void onAutoSyncChanged(bool value) {
setState(() {
autoSync = value;
appdata.implicitData['webdavAutoSync'] = value;
appdata.writeImplicitData();
});
}
@override
@@ -357,6 +368,12 @@ class _WebdavSettingState extends State<_WebdavSetting> {
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 12),
SwitchListTile(
title: Text("WebDAV Auto Sync".tl),
value: autoSync,
onChanged: onAutoSyncChanged,
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
@@ -411,12 +428,53 @@ class _WebdavSettingState extends State<_WebdavSetting> {
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text("Once the operation is successful, app will automatically sync data with the server.".tl),
),
],
),
),
const SizedBox(height: 16),
Center(
child: Button.filled(
isLoading: isTesting,
onPressed: () async {
var oldConfig = appdata.settings['webdav'];
var oldAutoSync = appdata.implicitData['webdavAutoSync'];
if (url.trim().isEmpty &&
user.trim().isEmpty &&
pass.trim().isEmpty) {
appdata.settings['webdav'] = [];
appdata.implicitData['webdavAutoSync'] = false;
appdata.writeImplicitData();
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
return;
}
appdata.settings['webdav'] = [url, user, pass];
appdata.implicitData['webdavAutoSync'] = autoSync;
appdata.writeImplicitData();
if (!autoSync) {
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
return;
}
setState(() {
isTesting = true;
});
@@ -428,12 +486,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
isTesting = false;
});
appdata.settings['webdav'] = oldConfig;
appdata.implicitData['webdavAutoSync'] = oldAutoSync;
appdata.writeImplicitData();
appdata.saveData();
context.showMessage(message: testResult.errorMessage!);
return;
context.showMessage(message: "Saved Failed".tl);
} else {
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
}
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
},
child: Text("Continue".tl),
),

View File

@@ -80,6 +80,24 @@ class _ExploreSettingsState extends State<ExploreSettings> {
'japanese': "Japanese",
},
).toSliver(),
SelectSetting(
title: "Initial Page".tl,
settingKey: "initialPage",
optionTranslation: {
'0': "Home Page".tl,
'1': "Favorites Page".tl,
'2': "Explore Page".tl,
'3': "Categories Page".tl,
},
).toSliver(),
SelectSetting(
title: "Display mode of comic list".tl,
settingKey: "comicListDisplayMode",
optionTranslation: {
"paging": "Paging".tl,
"Continuous": "Continuous".tl,
},
).toSliver(),
],
);
}

View File

@@ -50,8 +50,10 @@ class _ReaderSettingsState extends State<ReaderSettings> {
onChanged: () {
var readerMode = appdata.settings['readerMode'];
if (readerMode?.toLowerCase().startsWith('continuous') ?? false) {
appdata.settings['readerScreenPicNumber'] = 1;
widget.onChanged?.call('readerScreenPicNumber');
appdata.settings['readerScreenPicNumberForLandscape'] = 1;
widget.onChanged?.call('readerScreenPicNumberForLandscape');
appdata.settings['readerScreenPicNumberForPortrait'] = 1;
widget.onChanged?.call('readerScreenPicNumberForPortrait');
}
widget.onChanged?.call("readerMode");
},
@@ -81,13 +83,40 @@ class _ReaderSettingsState extends State<ReaderSettings> {
: 1.0,
duration: Duration(milliseconds: 300),
child: _SliderSetting(
title: "The number of pic in screen (Only Gallery Mode)".tl,
settingsIndex: "readerScreenPicNumber",
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("readerScreenPicNumber");
widget.onChanged?.call("readerScreenPicNumberForLandscape");
},
),
),
),
),
SliverToBoxAdapter(
child: AbsorbPointer(
absorbing: (appdata.settings['readerMode']
?.toLowerCase()
.startsWith('continuous') ??
false),
child: AnimatedOpacity(
opacity: (appdata.settings['readerMode']
?.toLowerCase()
.startsWith('continuous') ??
false)
? 0.5
: 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");
},
),
),

View File

@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter_7zip/flutter_7zip.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/ext.dart';
@@ -176,7 +177,7 @@ abstract class CBZ {
tags: metaData.tags,
comicType: ComicType.local,
directory: dest.name,
chapters: cpMap,
chapters: ComicChapters.fromJsonOrNull(cpMap),
downloadedChapters: cpMap?.keys.toList() ?? [],
cover: 'cover.${coverFile.extension}',
createdAt: DateTime.now(),

View File

@@ -95,15 +95,17 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
}
var comicSourceDir = FilePath.join(cacheDirPath, "comic_source");
if (Directory(comicSourceDir).existsSync()) {
Directory(FilePath.join(App.dataPath, "comic_source"))
.deleteIfExistsSync(recursive: true);
Directory(FilePath.join(App.dataPath, "comic_source")).createSync();
for (var file in Directory(comicSourceDir).listSync()) {
if (file is File) {
var targetFile =
FilePath.join(App.dataPath, "comic_source", file.name);
File(targetFile).deleteIfExistsSync();
await file.copy(targetFile);
}
}
await ComicSource.reload();
await ComicSourceManager().reload();
}
} finally {
cacheDir.deleteIgnoreError(recursive: true);

View File

@@ -1,4 +1,3 @@
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
@@ -10,6 +9,7 @@ import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/data.dart';
import 'package:venera/utils/ext.dart';
import 'package:webdav_client/webdav_client.dart' hide File;
import 'package:rhttp/rhttp.dart' as rhttp;
import 'io.dart';
@@ -19,7 +19,7 @@ class DataSync with ChangeNotifier {
downloadData();
}
LocalFavoritesManager().addListener(onDataChanged);
ComicSource.addListener(onDataChanged);
ComicSourceManager().addListener(onDataChanged);
}
void onDataChanged() {
@@ -32,23 +32,35 @@ class DataSync with ChangeNotifier {
factory DataSync() => instance ?? (instance = DataSync._());
bool isDownloading = false;
bool _isDownloading = false;
bool isUploading = false;
bool get isDownloading => _isDownloading;
bool haveWaitingTask = false;
bool _isUploading = false;
bool get isUploading => _isUploading;
bool _haveWaitingTask = false;
String? _lastError;
String? get lastError => _lastError;
bool get isEnabled {
var config = appdata.settings['webdav'];
return config is List && config.isNotEmpty;
var autoSync = appdata.implicitData['webdavAutoSync'] ?? false;
return autoSync && config is List && config.isNotEmpty;
}
List<String>? _validateConfig() {
var config = appdata.settings['webdav'];
if (config is! List || (config.isNotEmpty && config.length != 3)) {
if (config is! List) {
return null;
}
if (config.whereType<String>().length != 3) {
if (config.isEmpty) {
return [];
}
if (config.length != 3 || config.whereType<String>().length != 3) {
return null;
}
return List.from(config);
@@ -56,17 +68,19 @@ class DataSync with ChangeNotifier {
Future<Res<bool>> uploadData() async {
if (isDownloading) return const Res(true);
if (haveWaitingTask) return const Res(true);
if (_haveWaitingTask) return const Res(true);
while (isUploading) {
haveWaitingTask = true;
_haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
isUploading = true;
_haveWaitingTask = false;
_isUploading = true;
_lastError = null;
notifyListeners();
try {
var config = _validateConfig();
if (config == null) {
_lastError = 'Invalid WebDAV configuration';
return const Res.error('Invalid WebDAV configuration');
}
if (config.isEmpty) {
@@ -82,21 +96,14 @@ class DataSync with ChangeNotifier {
url,
user: user,
password: pass,
adapter: IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
adapter: RHttpAdapter(
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
),
),
);
try {
await client.ping();
} catch (e) {
Log.error("Upload Data", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try {
appdata.settings['dataVersion']++;
await appdata.saveData(false);
@@ -123,26 +130,29 @@ class DataSync with ChangeNotifier {
return const Res(true);
} catch (e, s) {
Log.error("Upload Data", e, s);
_lastError = e.toString();
return Res.error(e.toString());
}
} finally {
isUploading = false;
_isUploading = false;
notifyListeners();
}
}
Future<Res<bool>> downloadData() async {
if (haveWaitingTask) return const Res(true);
if (_haveWaitingTask) return const Res(true);
while (isDownloading || isUploading) {
haveWaitingTask = true;
_haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
isDownloading = true;
_haveWaitingTask = false;
_isDownloading = true;
_lastError = null;
notifyListeners();
try {
var config = _validateConfig();
if (config == null) {
_lastError = 'Invalid WebDAV configuration';
return const Res.error('Invalid WebDAV configuration');
}
if (config.isEmpty) {
@@ -158,21 +168,14 @@ class DataSync with ChangeNotifier {
url,
user: user,
password: pass,
adapter: IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
adapter: RHttpAdapter(
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
),
),
);
try {
await client.ping();
} catch (e) {
Log.error("Data Sync", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try {
var files = await client.readDir('/');
files.sort((a, b) => b.name!.compareTo(a.name!));
@@ -198,10 +201,11 @@ class DataSync with ChangeNotifier {
return const Res(true);
} catch (e, s) {
Log.error("Data Sync", e, s);
_lastError = e.toString();
return Res.error(e.toString());
}
} finally {
isDownloading = false;
_isDownloading = false;
notifyListeners();
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/services.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/pages/aggregated_search_page.dart';
bool _isHandling = false;
/// Handle text share event.
/// App will navigate to [AggregatedSearchPage] with the shared text as keyword.
void handleTextShare() async {
if (_isHandling) return;
_isHandling = true;
var channel = EventChannel('venera/text_share');
await for (var event in channel.receiveBroadcastStream()) {
if (App.mainNavigatorKey == null) {
await Future.delayed(const Duration(milliseconds: 200));
}
if (event is String) {
App.rootContext.to(() => AggregatedSearchPage(keyword: event));
}
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart';
@@ -262,7 +263,9 @@ class ImportComic {
subtitle: subtitle ?? '',
tags: tags ?? [],
directory: directory.path,
chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null,
chapters: hasChapters
? ComicChapters(Map.fromIterables(chapters, chapters))
: null,
cover: coverPath,
comicType: ComicType.local,
downloadedChapters: chapters,

40
lib/utils/init.dart Normal file
View File

@@ -0,0 +1,40 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// A mixin class that provides a way to ensure the class is initialized.
abstract mixin class Init {
bool _isInit = false;
final _initCompleter = <Completer<void>>[];
/// Ensure the class is initialized.
Future<void> ensureInit() async {
if (_isInit) {
return;
}
var completer = Completer<void>();
_initCompleter.add(completer);
return completer.future;
}
Future<void> _markInit() async {
_isInit = true;
for (var completer in _initCompleter) {
completer.complete();
}
_initCompleter.clear();
}
@protected
Future<void> doInit();
/// Initialize the class.
Future<void> init() async {
if (_isInit) {
return;
}
await doInit();
await _markInit();
}
}

View File

@@ -40,6 +40,7 @@ extension FileSystemEntityExt on FileSystemEntity {
return p.basename(path);
}
/// Delete the file or directory and ignore errors.
Future<void> deleteIgnoreError({bool recursive = false}) async {
try {
await delete(recursive: recursive);
@@ -48,12 +49,14 @@ extension FileSystemEntityExt on FileSystemEntity {
}
}
/// Delete the file or directory if it exists.
Future<void> deleteIfExists({bool recursive = false}) async {
if (existsSync()) {
await delete(recursive: recursive);
}
}
/// Delete the file or directory if it exists.
void deleteIfExistsSync({bool recursive = false}) {
if (existsSync()) {
deleteSync(recursive: recursive);
@@ -74,12 +77,14 @@ extension FileExtension on File {
await newFile.writeAsBytes(await readAsBytes());
}
/// Get the base name of the file without the extension.
String get basenameWithoutExt {
return p.basenameWithoutExtension(path);
}
}
extension DirectoryExtension on Directory {
/// Calculate the size of the directory.
Future<int> get size async {
if (!existsSync()) return 0;
int total = 0;
@@ -91,6 +96,7 @@ extension DirectoryExtension on Directory {
return total;
}
/// Change the base name of the directory.
Directory renameX(String newName) {
newName = sanitizeFileName(newName);
return renameSync(path.replaceLast(name, newName));
@@ -100,6 +106,7 @@ extension DirectoryExtension on Directory {
return File(FilePath.join(path, name));
}
/// Delete the contents of the directory.
void deleteContentsSync({recursive = true}) {
if (!existsSync()) return;
for (var f in listSync()) {
@@ -107,14 +114,24 @@ extension DirectoryExtension on Directory {
}
}
/// Delete the contents of the directory.
Future<void> deleteContents({recursive = true}) async {
if (!existsSync()) return;
for (var f in listSync()) {
await f.deleteIfExists(recursive: recursive);
}
}
/// Create the directory. If the directory already exists, delete it first.
void forceCreateSync() {
if (existsSync()) {
deleteSync(recursive: true);
}
createSync(recursive: true);
}
}
/// Sanitize the file name. Remove invalid characters and trim the file name.
String sanitizeFileName(String fileName) {
if (fileName.endsWith('.')) {
fileName = fileName.substring(0, fileName.length - 1);
@@ -157,6 +174,8 @@ Future<void> copyDirectory(Directory source, Directory destination) async {
}
}
/// Copy the **contents** of the source directory to the destination directory.
/// This function is executed in an isolate to prevent the UI from freezing.
Future<void> copyDirectoryIsolate(
Directory source, Directory destination) async {
await Isolate.run(() => overrideIO(() => copyDirectory(source, destination)));

View File

@@ -5,10 +5,10 @@ packages:
dependency: "direct main"
description:
name: app_links
sha256: "433df2e61b10519407475d7f69e470789d23d593f28224c38ba1068597be7950"
sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba"
url: "https://pub.dev"
source: hosted
version: "6.3.3"
version: "6.4.0"
app_links_linux:
dependency: transitive
description:
@@ -141,10 +141,10 @@ packages:
dependency: transitive
description:
name: dbus
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
desktop_webview_window:
dependency: "direct main"
description:
@@ -158,18 +158,18 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
url: "https://pub.dev"
source: hosted
version: "5.7.0"
version: "5.8.0+1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
sha256: e485c7a39ff2b384fa1d7e09b4e25f755804de8384358049124830b04fc4f93a
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "2.1.0"
dynamic_color:
dependency: "direct main"
description:
@@ -190,10 +190,10 @@ packages:
dependency: transitive
description:
name: ffi
sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6"
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.4"
file:
dependency: transitive
description:
@@ -262,10 +262,10 @@ packages:
dependency: transitive
description:
name: file_selector_windows
sha256: "8f5d2f6590d51ecd9179ba39c64f722edc15226cc93dcc8698466ad36a4a85a4"
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
url: "https://pub.dev"
source: hosted
version: "0.9.3+3"
version: "0.9.3+4"
fixnum:
dependency: transitive
description:
@@ -400,16 +400,16 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "9b78450b89f059e96c9ebb355fa6b3df1d6b330436e0b885fb49594c41721398"
sha256: "615a505aef59b151b46bbeef55b36ce2b6ed299d160c51d84281946f0aa0ce0e"
url: "https://pub.dev"
source: hosted
version: "2.0.23"
version: "2.0.24"
flutter_qjs:
dependency: "direct main"
description:
path: "."
ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
resolved-ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
ref: "8feae95df7fb00455df129ad7a0dfec1d0e8d8e4"
resolved-ref: "8feae95df7fb00455df129ad7a0dfec1d0e8d8e4"
url: "https://github.com/wgh136/flutter_qjs"
source: git
version: "0.3.7"
@@ -417,25 +417,25 @@ packages:
dependency: "direct main"
description:
name: flutter_reorderable_grid_view
sha256: "732bcb1b29d5130c11a70e6acec512941fafe241f0e80bffd93ca6e415819915"
sha256: a7e0f9d5ba12fd232eb07fbb7f570ae35491045a6bba1858f6eb50c675526dfe
url: "https://pub.dev"
source: hosted
version: "5.4.0"
version: "5.4.1"
flutter_rust_bridge:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: "35c257fc7f98e34c1314d6c145e5ed54e7c94e8a9f469947e31c9298177d546f"
sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
version: "2.9.0"
flutter_saf:
dependency: "direct main"
description:
path: "."
ref: "7637b8b67d0a831f3cd7e702b8173e300880d32e"
resolved-ref: "7637b8b67d0a831f3cd7e702b8173e300880d32e"
url: "https://github.com/pkuislm/flutter_saf.git"
ref: "690a03a954f1603e0149cfd479c8961b88f21336"
resolved-ref: "690a03a954f1603e0149cfd479c8961b88f21336"
url: "https://github.com/venera-app/flutter_saf"
source: git
version: "0.0.1"
flutter_test:
@@ -492,18 +492,18 @@ packages:
dependency: transitive
description:
name: http
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f
url: "https://pub.dev"
source: hosted
version: "1.2.2"
version: "1.3.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360"
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
version: "4.1.2"
http_profile:
dependency: transitive
description:
@@ -528,14 +528,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
url: "https://pub.dev"
source: hosted
version: "0.7.1"
json_annotation:
dependency: transitive
description:
@@ -572,10 +564,10 @@ packages:
dependency: transitive
description:
name: lints
sha256: "4a16b3f03741e1252fda5de3ce712666d010ba2122f8e912c94f9f7b90e1a4c3"
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.0"
version: "5.1.1"
local_auth:
dependency: "direct main"
description:
@@ -596,10 +588,10 @@ packages:
dependency: transitive
description:
name: local_auth_darwin
sha256: "6d2950da311d26d492a89aeb247c72b4653ddc93601ea36a84924a396806d49c"
sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.3"
local_auth_platform_interface:
dependency: transitive
description:
@@ -620,8 +612,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "9a784b193af5d55b2a35e58fa390bda3e4f35d00"
resolved-ref: "9a784b193af5d55b2a35e58fa390bda3e4f35d00"
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
resolved-ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
url: "https://github.com/venera-app/lodepng_flutter"
source: git
version: "0.0.1"
@@ -725,16 +717,16 @@ packages:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://pub.dev"
source: hosted
version: "6.0.2"
version: "6.1.0"
photo_view:
dependency: "direct main"
description:
path: "."
ref: "94724a0b"
resolved-ref: "94724a0b7f94167fd1ae061f84e14ae04cae5c39"
ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
resolved-ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
url: "https://github.com/wgh136/photo_view"
source: git
version: "0.14.0"
@@ -758,18 +750,18 @@ packages:
dependency: "direct main"
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
version: "4.0.0"
rhttp:
dependency: "direct main"
description:
name: rhttp
sha256: "8212cbc816cc3e761eecb8d4dbbaa1eca95de715428320a198a4e7a89acdcd2e"
sha256: "037e9b59a68bb4ba664db1cbb4601e878cf5a2fc1cb3d0a9c58e3776609dec4d"
url: "https://pub.dev"
source: hosted
version: "0.9.8"
version: "0.11.0"
screen_retriever:
dependency: transitive
description:
@@ -823,10 +815,10 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: "6327c3f233729374d0abaafd61f6846115b2a481b4feddd8534211dc10659400"
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.3"
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
@@ -876,18 +868,18 @@ packages:
dependency: "direct main"
description:
name: sqlite3
sha256: cb7f4e9dc1b52b1fa350f7b3d41c662e75fc3d399555fa4e5efcf267e9a4fbb5
sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
version: "2.7.4"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "73016db8419f019e807b7a5e5fbf2a7bd45c165fed403b8e7681230f3a102785"
sha256: "57fafacd815c981735406215966ff7caaa8eab984b094f52e692accefcbd9233"
url: "https://pub.dev"
source: hosted
version: "0.5.28"
version: "0.5.30"
stack_trace:
dependency: transitive
description:
@@ -1004,18 +996,18 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e"
sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9"
url: "https://pub.dev"
source: hosted
version: "2.3.3"
version: "2.4.0"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4"
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
version: "3.1.4"
uuid:
dependency: "direct main"
description:
@@ -1052,8 +1044,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
resolved-ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
ref: "2f669c98fb81cff1c64fee93466a1475c77e4273"
resolved-ref: "2f669c98fb81cff1c64fee93466a1475c77e4273"
url: "https://github.com/wgh136/webdav_client"
source: git
version: "1.2.2"
@@ -1061,10 +1053,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69"
sha256: b89e6e24d1454e149ab20fbb225af58660f0c0bf4475544650700d8e2da54aef
url: "https://pub.dev"
source: hosted
version: "5.9.0"
version: "5.11.0"
window_manager:
dependency: "direct main"
description:
@@ -1101,10 +1093,10 @@ packages:
dependency: "direct main"
description:
name: zip_flutter
sha256: bbf3160062610a43901b7ebbc6f6dd46519540f03a84027dc7b1fff399dda1ac
sha256: c4d5a34c5803def866bc550926bb16fe89717c9b7304695d5b2ede30964eb8a8
url: "https://pub.dev"
source: hosted
version: "0.0.10"
version: "0.0.12"
sdks:
dart: ">=3.7.0-0 <4.0.0"
flutter: ">=3.29.0"
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.2"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.3.0+130
version: 1.3.3+133
environment:
sdk: '>=3.6.0 <4.0.0'
flutter: 3.29.0
flutter: 3.29.2
dependencies:
flutter:
@@ -14,24 +14,24 @@ dependencies:
path_provider: any
intl: any
window_manager: ^0.4.3
sqlite3: ^2.4.7
sqlite3_flutter_libs: ^0.5.28
sqlite3: ^2.7.4
sqlite3_flutter_libs: ^0.5.30
flutter_qjs:
git:
url: https://github.com/wgh136/flutter_qjs
ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3
ref: 8feae95df7fb00455df129ad7a0dfec1d0e8d8e4
crypto: ^3.0.6
dio: ^5.7.0
dio: ^5.8.0+1
html: ^0.15.5
pointycastle: ^3.9.1
pointycastle: ^4.0.0
url_launcher: ^6.3.0
path: ^1.9.0
photo_view:
git:
url: https://github.com/wgh136/photo_view
ref: 94724a0b
ref: d71faf3c75e059d013b0ce9ddaf5ecc1680e2eb6
mime: ^2.0.0
share_plus: ^10.1.3
share_plus: ^10.1.4
scrollable_positioned_list:
git:
url: https://github.com/venera-app/flutter.widgets
@@ -48,26 +48,26 @@ dependencies:
url: https://github.com/pichillilorenzo/flutter_inappwebview
path: flutter_inappwebview
ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676
app_links: ^6.3.3
app_links: ^6.4.0
sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2
file_selector: ^1.0.3
zip_flutter: ^0.0.10
zip_flutter: ^0.0.12
lodepng_flutter:
git:
url: https://github.com/venera-app/lodepng_flutter
ref: 9a784b193af5d55b2a35e58fa390bda3e4f35d00
rhttp: 0.9.8
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
rhttp: ^0.11.0
webdav_client:
git:
url: https://github.com/wgh136/webdav_client
ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1
ref: 2f669c98fb81cff1c64fee93466a1475c77e4273
battery_plus: ^6.2.1
local_auth: ^2.3.0
flutter_saf:
git:
url: https://github.com/pkuislm/flutter_saf.git
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
url: https://github.com/venera-app/flutter_saf
ref: 690a03a954f1603e0149cfd479c8961b88f21336
dynamic_color: ^1.7.0
shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1