mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Compare commits
98 Commits
v1.4.0
...
ce0d10aeb2
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ce0d10aeb2 | ||
![]() |
0ac857ef9a | ||
3928f5afe7 | |||
8a61a4750b | |||
![]() |
1bc3fef47b | ||
![]() |
4dac132bee | ||
![]() |
7c60c00962 | ||
9d8ade6fe0 | |||
6245399810 | |||
c074e7f9d1 | |||
f822e198ea | |||
7035f11eb5 | |||
f2f5a4f573 | |||
2acf234f7d | |||
9ed8f351c7 | |||
7c35dc7cf7 | |||
![]() |
17b8b9ea8f | ||
ccb03343f4 | |||
![]() |
951bcae603 | ||
![]() |
0b9de68c86 | ||
![]() |
81b27fd941 | ||
![]() |
b9817ec030 | ||
![]() |
5ebb554e54 | ||
![]() |
d5d72911ed | ||
![]() |
838d5c9c3e | ||
23ee79fe9d | |||
![]() |
85baac657a | ||
![]() |
cceca6b96f | ||
![]() |
b5b0dc85e3 | ||
![]() |
50044c4372 | ||
![]() |
5fd7f1b880 | ||
![]() |
058fde3f5a | ||
![]() |
a2d46123dd | ||
![]() |
01acc4f9de | ||
![]() |
856aae0769 | ||
![]() |
8eda8adcc8 | ||
defd4b8624 | |||
b2a164e066 | |||
a46ceebf19 | |||
cc08445f13 | |||
93f7f72d07 | |||
20f7ab4866 | |||
54363919cd | |||
182a821fc5 | |||
8868c6edb3 | |||
![]() |
fffbb4ed23 | ||
![]() |
b057be0311 | ||
![]() |
fc5fed1707 | ||
![]() |
8525f5318f | ||
![]() |
d58cafc4a0 | ||
23afafd1d6 | |||
![]() |
3b6e0adbbb | ||
20a57c7a36 | |||
665f50ed2a | |||
55733ef505 | |||
0c46214619 | |||
749a1a47fb | |||
76e9ef87d4 | |||
dcd6466547 | |||
ed70fdba93 | |||
ded0068ea6 | |||
![]() |
7dc6be622a | ||
![]() |
88f093f7e5 | ||
8f357b3e6c | |||
9ee82975e8 | |||
![]() |
9f048685e4 | ||
![]() |
bc1f5e11b5 | ||
1f2147ef72 | |||
fba365fd93 | |||
a5e3fbaee5 | |||
190e645a12 | |||
![]() |
8a83ff5367 | ||
6e14942dab | |||
146fc70143 | |||
b37ea01aca | |||
bf7b90313a | |||
929c1a9d91 | |||
9ff68d0701 | |||
dfd15ed34a | |||
![]() |
dfe2a0db6a | ||
c6714f79b6 | |||
552a42fb27 | |||
af456c52f1 | |||
f38129133a | |||
17e2696ca4 | |||
9d6999af33 | |||
ae5548918c | |||
92d22c977c | |||
8cc3702e1a | |||
3131ce52a7 | |||
62e4056f4a | |||
a29a7cbaf3 | |||
7bdab7ade7 | |||
ea99e87afb | |||
0d3fde9457 | |||
aa9f4dae82 | |||
6877aa120f | |||
d25d72a5f7 |
4
.github/workflows/fastlane.yml
vendored
4
.github/workflows/fastlane.yml
vendored
@@ -4,8 +4,12 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ "master" ]
|
branches: [ "master" ]
|
||||||
|
paths:
|
||||||
|
- 'fastlane/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "master" ]
|
branches: [ "master" ]
|
||||||
|
paths:
|
||||||
|
- 'fastlane/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
go:
|
go:
|
||||||
|
86
.github/workflows/main.yml
vendored
86
.github/workflows/main.yml
vendored
@@ -149,45 +149,6 @@ jobs:
|
|||||||
sudo rm -rf build/linux/arch/pkg
|
sudo rm -rf build/linux/arch/pkg
|
||||||
sudo rm -rf build/linux/arch/src
|
sudo rm -rf build/linux/arch/src
|
||||||
sudo rm -rf build/linux/arch/PKGBUILD
|
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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: deb_build
|
name: deb_build
|
||||||
@@ -210,45 +171,6 @@ jobs:
|
|||||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||||
dart pub global activate flutter_to_debian
|
dart pub global activate flutter_to_debian
|
||||||
- run: python3 debian/build.py arm64
|
- 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
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: deb_arm64_build
|
name: deb_arm64_build
|
||||||
@@ -287,14 +209,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: deb_arm64_build
|
name: deb_arm64_build
|
||||||
path: outputs
|
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
|
- uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
|
76
.github/workflows/update_alt_store.yml
vendored
Normal file
76
.github/workflows/update_alt_store.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
name: Update AltStore Source
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build ALL"]
|
||||||
|
types: [completed]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-source:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install requests
|
||||||
|
|
||||||
|
- name: Record job start time
|
||||||
|
id: job_start_time
|
||||||
|
run: echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Update AltStore source
|
||||||
|
id: update_source
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
python update_alt_store.py
|
||||||
|
git config --global user.name 'GitHub Action'
|
||||||
|
git config --global user.email 'action@github.com'
|
||||||
|
git add alt_store.json
|
||||||
|
if git diff --staged --quiet; then
|
||||||
|
echo "changes=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
git commit -m "Updated source with latest release"
|
||||||
|
git push
|
||||||
|
echo "changes=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Calculate job duration
|
||||||
|
id: duration
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
end_time=$(date +%s)
|
||||||
|
duration=$((end_time - ${{ steps.job_start_time.outputs.start_time }}))
|
||||||
|
echo "duration=$duration seconds" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create job summary
|
||||||
|
run: |
|
||||||
|
if [[ "${{ steps.update_source.outputs.changes }}" == "true" ]]; then
|
||||||
|
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "✅ Changes Detected and Applied" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "The alt_store.json file has been updated with the latest release information." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "## Update Altstore Source Summary 🚀" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "🔍 No Changes Detected" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "The alt_store.json file is up to date. No changes were necessary." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "🕐 Execution Time" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "This job took ${{ steps.duration.outputs.duration }} to complete." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "📆 Next Scheduled Run" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "The next scheduled run will be tomorrow at midnight UTC." >> $GITHUB_STEP_SUMMARY
|
@@ -3,7 +3,7 @@
|
|||||||
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
||||||
[](https://github.com/venera-app/venera/releases)
|
[](https://github.com/venera-app/venera/releases)
|
||||||
[](https://github.com/venera-app/venera/stargazers)
|
[](https://github.com/venera-app/venera/stargazers)
|
||||||
[](https://t.me/+Ws-IpmUutzkxMjhl)
|
[](https://t.me/venera_release)
|
||||||
|
|
||||||
A comic reader that support reading local and network comics.
|
A comic reader that support reading local and network comics.
|
||||||
|
|
||||||
|
64
alt_store.json
Normal file
64
alt_store.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "Venera",
|
||||||
|
"identifier": "com.github.wgh136.venera.source",
|
||||||
|
"website": "https://github.com/venera-app/venera",
|
||||||
|
"subtitle": "Venera official AltStore Source.",
|
||||||
|
"description": "This is the official AltStore Source for Venera.\n\n A comic reader that supports reading local and network comics",
|
||||||
|
"tintColor": "#0784FC",
|
||||||
|
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"beta": false,
|
||||||
|
"name": "Venera",
|
||||||
|
"bundleIdentifier": "com.github.wgh136.venera",
|
||||||
|
"developerName": "wgh136",
|
||||||
|
"subtitle": "A comic reader that supports reading local and network comics",
|
||||||
|
"version": "1.4.5",
|
||||||
|
"versionDate": "2025-06-18",
|
||||||
|
"versionDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||||
|
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||||
|
"localizedDescription": "A comic reader that supports reading local and network comics",
|
||||||
|
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||||
|
"tintColor": "#0784FC",
|
||||||
|
"category": "utilities",
|
||||||
|
"size": 14960268,
|
||||||
|
"appPermissions": {
|
||||||
|
"entitlements": [
|
||||||
|
"application-identifier",
|
||||||
|
"com.apple.security.application-groups",
|
||||||
|
"get-task-allow",
|
||||||
|
"keychain-access-groups",
|
||||||
|
"com.apple.developer.kernel.extended-virtual-addressing",
|
||||||
|
"com.apple.developer.kernel.increased-memory-limit",
|
||||||
|
"com.apple.developer.healthkit.background-delivery"
|
||||||
|
],
|
||||||
|
"privacy": {
|
||||||
|
"NSFaceIDUsageDescription": "Face ID or Touch ID is used to protect your privacy when opening the app, ensuring secure access to your reading content.",
|
||||||
|
"NSPhotoLibraryAddUsageDescription": "Used to save comic images you've favorited or downloaded to your photo library for easy access and sharing.",
|
||||||
|
"NSPhotoLibraryUsageDescription": "Used to select images from your photo library when needed, and to save comic images you've collected to your device."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.4.5",
|
||||||
|
"date": "2025-06-18",
|
||||||
|
"localizedDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||||
|
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||||
|
"size": 14960268
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"news": [
|
||||||
|
{
|
||||||
|
"appID": "com.github.wgh136.venera",
|
||||||
|
"caption": "Update of Venera just got released!",
|
||||||
|
"date": "2025-06-18T09:02:01Z",
|
||||||
|
"identifier": "release-v1.4.5",
|
||||||
|
"notify": true,
|
||||||
|
"tintColor": "#0784FC",
|
||||||
|
"title": "v1.4.5 - Venera 18/06/25",
|
||||||
|
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
@@ -39,6 +39,32 @@ let Convert = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param str {string}
|
||||||
|
* @returns {ArrayBuffer}
|
||||||
|
*/
|
||||||
|
encodeGbk: (str) => {
|
||||||
|
return sendMessage({
|
||||||
|
method: "convert",
|
||||||
|
type: "gbk",
|
||||||
|
value: str,
|
||||||
|
isEncode: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param value {ArrayBuffer}
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
decodeGbk: (value) => {
|
||||||
|
return sendMessage({
|
||||||
|
method: "convert",
|
||||||
|
type: "gbk",
|
||||||
|
value: value,
|
||||||
|
isEncode: false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ArrayBuffer} value
|
* @param {ArrayBuffer} value
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
@@ -176,7 +202,7 @@ let Convert = {
|
|||||||
decryptAesCbc: (value, key, iv) => {
|
decryptAesCbc: (value, key, iv) => {
|
||||||
return sendMessage({
|
return sendMessage({
|
||||||
method: "convert",
|
method: "convert",
|
||||||
type: "aes-ecb",
|
type: "aes-cbc",
|
||||||
value: value,
|
value: value,
|
||||||
key: key,
|
key: key,
|
||||||
iv: iv,
|
iv: iv,
|
||||||
@@ -1296,13 +1322,15 @@ let UI = {
|
|||||||
* Show an input dialog
|
* Show an input dialog
|
||||||
* @param title {string}
|
* @param title {string}
|
||||||
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
|
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
|
||||||
|
* @param image {string?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
|
||||||
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
|
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
|
||||||
*/
|
*/
|
||||||
showInputDialog: (title, validator) => {
|
showInputDialog: (title, validator, image) => {
|
||||||
return sendMessage({
|
return sendMessage({
|
||||||
method: 'UI',
|
method: 'UI',
|
||||||
function: 'showInputDialog',
|
function: 'showInputDialog',
|
||||||
title: title,
|
title: title,
|
||||||
|
image: image,
|
||||||
validator: validator
|
validator: validator
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
3982
assets/opencc.txt
Normal file
3982
assets/opencc.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -234,8 +234,10 @@
|
|||||||
"Please add some sources": "请添加一些源",
|
"Please add some sources": "请添加一些源",
|
||||||
"Please check your settings": "请检查您的设置",
|
"Please check your settings": "请检查您的设置",
|
||||||
"No Category Pages": "没有分类页面",
|
"No Category Pages": "没有分类页面",
|
||||||
|
"Group @group": "第 @group 组",
|
||||||
"Chapter @ep": "第 @ep 章",
|
"Chapter @ep": "第 @ep 章",
|
||||||
"Page @page": "第 @page 页",
|
"Page @page": "第 @page 页",
|
||||||
|
"Remove local favorite and history": "删除本地收藏和历史记录",
|
||||||
"Also remove files on disk": "同时删除磁盘上的文件",
|
"Also remove files on disk": "同时删除磁盘上的文件",
|
||||||
"Copy to app local path": "将漫画复制到本地存储目录中",
|
"Copy to app local path": "将漫画复制到本地存储目录中",
|
||||||
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
|
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
|
||||||
@@ -387,7 +389,27 @@
|
|||||||
"Screen center": "屏幕中心",
|
"Screen center": "屏幕中心",
|
||||||
"Suggestions": "建议",
|
"Suggestions": "建议",
|
||||||
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
|
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
|
||||||
"Click the setting icon to change the source list url.": "点击设置图标更改源列表URL"
|
"Show single image on first page": "在首页显示单张图片",
|
||||||
|
"Show system status bar": "显示系统状态栏",
|
||||||
|
"Click to select an image": "点击选择一张图片",
|
||||||
|
"Repo URL": "仓库地址",
|
||||||
|
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
|
||||||
|
"Double tap to zoom": "双击缩放",
|
||||||
|
"Clear Unfavorited": "清除未收藏",
|
||||||
|
"Reverse": "反转",
|
||||||
|
"Delete Chapters": "删除章节",
|
||||||
|
"Open Folder": "打开文件夹",
|
||||||
|
"Path copied to clipboard": "路径已复制到剪贴板",
|
||||||
|
"Reverse default chapter order": "反转默认章节顺序",
|
||||||
|
"Reload Configs": "重新加载配置文件",
|
||||||
|
"Reload": "重载",
|
||||||
|
"Disable Length Limitation": "禁用长度限制",
|
||||||
|
"Only valid for this run": "仅对本次运行有效",
|
||||||
|
"Logs": "日志",
|
||||||
|
"Export logs": "导出日志",
|
||||||
|
"Clear specific reader settings for all comics": "清除所有漫画的特殊阅读设置",
|
||||||
|
"Clear specific reader settings for this comic": "清除该漫画的特殊阅读设置",
|
||||||
|
"Enable comic specific settings": "为每本漫画保存特定设置"
|
||||||
},
|
},
|
||||||
"zh_TW": {
|
"zh_TW": {
|
||||||
"Home": "首頁",
|
"Home": "首頁",
|
||||||
@@ -624,8 +646,10 @@
|
|||||||
"Please add some sources": "請添加一些源",
|
"Please add some sources": "請添加一些源",
|
||||||
"Please check your settings": "請檢查您的設定",
|
"Please check your settings": "請檢查您的設定",
|
||||||
"No Category Pages": "沒有分類頁面",
|
"No Category Pages": "沒有分類頁面",
|
||||||
|
"Group @group": "第 @group 組",
|
||||||
"Chapter @ep": "第 @ep 章",
|
"Chapter @ep": "第 @ep 章",
|
||||||
"Page @page": "第 @page 頁",
|
"Page @page": "第 @page 頁",
|
||||||
|
"Remove local favorite and history": "刪除本機收藏和歷史記錄",
|
||||||
"Also remove files on disk": "同時刪除磁碟上的文件",
|
"Also remove files on disk": "同時刪除磁碟上的文件",
|
||||||
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
|
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
|
||||||
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
|
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
|
||||||
@@ -777,6 +801,26 @@
|
|||||||
"Screen center": "螢幕中心",
|
"Screen center": "螢幕中心",
|
||||||
"Suggestions": "建議",
|
"Suggestions": "建議",
|
||||||
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
|
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
|
||||||
"Click the setting icon to change the source list url.": "點擊設定圖示更改源列表URL"
|
"Show single image on first page": "在首頁顯示單張圖片",
|
||||||
|
"Show system status bar": "顯示系統狀態欄",
|
||||||
|
"Click to select an image": "點擊選擇一張圖片",
|
||||||
|
"Repo URL": "倉庫地址",
|
||||||
|
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
|
||||||
|
"Double tap to zoom": "雙擊縮放",
|
||||||
|
"Clear Unfavorited": "清除未收藏",
|
||||||
|
"Reverse": "反轉",
|
||||||
|
"Delete Chapters": "刪除章節",
|
||||||
|
"Open Folder": "打開資料夾",
|
||||||
|
"Path copied to clipboard": "路徑已複製到剪貼簿",
|
||||||
|
"Reverse default chapter order": "反轉預設章節順序",
|
||||||
|
"Reload Configs": "重新載入設定檔",
|
||||||
|
"Reload": "重載",
|
||||||
|
"Disable Length Limitation": "禁用長度限制",
|
||||||
|
"Only valid for this run": "僅對本次運行有效",
|
||||||
|
"Logs": "日誌",
|
||||||
|
"Export logs": "匯出日誌",
|
||||||
|
"Clear specific reader settings for all comics": "清除所有漫畫的特殊閱讀設定",
|
||||||
|
"Clear specific reader settings for this comic": "清除該漫畫的特殊閱讀設定",
|
||||||
|
"Enable comic specific settings": "為每本漫畫保存特定設定"
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -9,13 +9,47 @@ Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine wh
|
|||||||
|
|
||||||
This document will describe how to write a comic source for Venera.
|
This document will describe how to write a comic source for Venera.
|
||||||
|
|
||||||
## Preparation
|
## Comic Source List
|
||||||
|
|
||||||
|
Venera can display a list of comic sources in the app.
|
||||||
|
|
||||||
|
You can use the following repo url:
|
||||||
|
```
|
||||||
|
https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json
|
||||||
|
```
|
||||||
|
The repo is maintained by the Venera team.
|
||||||
|
|
||||||
|
> The link is a mirror of the original repo. To contribute your comic source, please visit the [original repo](https://github.com/venera-app/venera-configs)
|
||||||
|
|
||||||
|
You should provide a repository url to let the app load the comic source list.
|
||||||
|
The url should point to a JSON file that contains the list of comic sources.
|
||||||
|
|
||||||
|
The JSON file should have the following format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Source Name",
|
||||||
|
"url": "https://example.com/source.js",
|
||||||
|
"filename": "Relative path to the source file",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A brief description of the source"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one of `url` and `filename` should be provided.
|
||||||
|
The description field is optional.
|
||||||
|
|
||||||
|
## Create a Comic Source
|
||||||
|
|
||||||
|
### Preparation
|
||||||
|
|
||||||
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
|
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
|
||||||
- An editor that supports javascript.
|
- An editor that supports javascript.
|
||||||
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
|
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
|
||||||
|
|
||||||
## Start Writing
|
### Start Writing
|
||||||
|
|
||||||
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
|
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
|
||||||
|
|
||||||
@@ -23,7 +57,7 @@ Here is a brief introduction to the template:
|
|||||||
|
|
||||||
> Note: Javascript api document is [here](js_api.md).
|
> Note: Javascript api document is [here](js_api.md).
|
||||||
|
|
||||||
### Write basic information
|
#### Write basic information
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
class NewComicSource extends ComicSource {
|
class NewComicSource extends ComicSource {
|
||||||
@@ -49,7 +83,7 @@ In this part, you need to do the following:
|
|||||||
- Change the class name to your source name.
|
- Change the class name to your source name.
|
||||||
- Fill in the name, key, version, minAppVersion, and url fields.
|
- Fill in the name, key, version, minAppVersion, and url fields.
|
||||||
|
|
||||||
### init function
|
#### init function
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
/**
|
/**
|
||||||
@@ -64,7 +98,7 @@ The function will be called when the source is initialized. You can do some init
|
|||||||
|
|
||||||
Remove this function if not used.
|
Remove this function if not used.
|
||||||
|
|
||||||
### Account
|
#### Account
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// [Optional] account related
|
// [Optional] account related
|
||||||
@@ -140,7 +174,7 @@ In this part, you can implement login, logout, and register functions.
|
|||||||
|
|
||||||
Remove this part if not used.
|
Remove this part if not used.
|
||||||
|
|
||||||
### Explore page
|
#### Explore page
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// explore page list
|
// explore page list
|
||||||
@@ -185,7 +219,7 @@ There are three types of explore pages:
|
|||||||
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
|
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
|
||||||
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
|
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
|
||||||
|
|
||||||
### Category Page
|
#### Category Page
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// categories
|
// categories
|
||||||
@@ -227,7 +261,7 @@ Category page is a static page that contains multiple parts, each part contains
|
|||||||
|
|
||||||
A comic source can only have one category page.
|
A comic source can only have one category page.
|
||||||
|
|
||||||
### Category Comics Page
|
#### Category Comics Page
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
/// category comic loading related
|
/// category comic loading related
|
||||||
@@ -280,7 +314,7 @@ When user clicks on a category, the category comics page will be displayed.
|
|||||||
|
|
||||||
This part is used to load comics of a category.
|
This part is used to load comics of a category.
|
||||||
|
|
||||||
### Search
|
#### Search
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
/// search related
|
/// search related
|
||||||
@@ -331,6 +365,11 @@ This part is used to load comics of a category.
|
|||||||
|
|
||||||
// enable tags suggestions
|
// enable tags suggestions
|
||||||
enableTagsSuggestions: false,
|
enableTagsSuggestions: false,
|
||||||
|
// [Optional] handle tag suggestion click
|
||||||
|
onTagSuggestionSelected: (namespace, tag) => {
|
||||||
|
// return the text to insert into search box
|
||||||
|
return `${namespace}:${tag}`
|
||||||
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -339,7 +378,7 @@ This part is used to load search results.
|
|||||||
`load` and `loadNext` functions are used to load search results.
|
`load` and `loadNext` functions are used to load search results.
|
||||||
If `load` function is implemented, `loadNext` function will be ignored.
|
If `load` function is implemented, `loadNext` function will be ignored.
|
||||||
|
|
||||||
### Favorites
|
#### Favorites
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// favorite related
|
// favorite related
|
||||||
@@ -411,7 +450,7 @@ This part is used to manage network favorites of the source.
|
|||||||
`load` and `loadNext` functions are used to load search results.
|
`load` and `loadNext` functions are used to load search results.
|
||||||
If `load` function is implemented, `loadNext` function will be ignored.
|
If `load` function is implemented, `loadNext` function will be ignored.
|
||||||
|
|
||||||
### Comic Details
|
#### Comic Details
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
/// single comic related
|
/// single comic related
|
||||||
@@ -576,7 +615,7 @@ If `load` function is implemented, `loadNext` function will be ignored.
|
|||||||
|
|
||||||
This part is used to load comic details.
|
This part is used to load comic details.
|
||||||
|
|
||||||
### Settings
|
#### Settings
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
/*
|
/*
|
||||||
@@ -635,7 +674,7 @@ This part is used to load comic details.
|
|||||||
This part is used to provide settings for the source.
|
This part is used to provide settings for the source.
|
||||||
|
|
||||||
|
|
||||||
### Translations
|
#### Translations
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// [Optional] translations for the strings in this config
|
// [Optional] translations for the strings in this config
|
||||||
|
@@ -46,12 +46,14 @@
|
|||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Choose images</string>
|
<string>Choose images</string>
|
||||||
<key>UIFileSharingEnabled</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>Ensure that the operation is being performed by the user themselves.</string>
|
<string>Ensure that the operation is being performed by the user themselves.</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.books</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
@@ -21,11 +22,13 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
|
|||||||
import 'package:venera/foundation/image_provider/history_image_provider.dart';
|
import 'package:venera/foundation/image_provider/history_image_provider.dart';
|
||||||
import 'package:venera/foundation/image_provider/local_comic_image.dart';
|
import 'package:venera/foundation/image_provider/local_comic_image.dart';
|
||||||
import 'package:venera/foundation/local.dart';
|
import 'package:venera/foundation/local.dart';
|
||||||
|
import 'package:venera/foundation/log.dart';
|
||||||
import 'package:venera/foundation/res.dart';
|
import 'package:venera/foundation/res.dart';
|
||||||
import 'package:venera/network/cloudflare.dart';
|
import 'package:venera/network/cloudflare.dart';
|
||||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/utils/ext.dart';
|
||||||
|
import 'package:venera/utils/io.dart';
|
||||||
import 'package:venera/utils/tags_translation.dart';
|
import 'package:venera/utils/tags_translation.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
|
|
||||||
|
@@ -37,9 +37,11 @@ mixin class JsUiApi {
|
|||||||
case 'showInputDialog':
|
case 'showInputDialog':
|
||||||
var title = message['title'];
|
var title = message['title'];
|
||||||
var validator = message['validator'];
|
var validator = message['validator'];
|
||||||
|
var image = message['image'];
|
||||||
if (title is! String) return;
|
if (title is! String) return;
|
||||||
if (validator != null && validator is! JSInvokable) return;
|
if (validator != null && validator is! JSInvokable) return;
|
||||||
return _showInputDialog(title, validator);
|
if (image != null && image is! String) return;
|
||||||
|
return _showInputDialog(title, validator, image);
|
||||||
case 'showSelectDialog':
|
case 'showSelectDialog':
|
||||||
var title = message['title'];
|
var title = message['title'];
|
||||||
var options = message['options'];
|
var options = message['options'];
|
||||||
@@ -124,12 +126,13 @@ mixin class JsUiApi {
|
|||||||
controller?.close();
|
controller?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _showInputDialog(String title, JSInvokable? validator) async {
|
Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async {
|
||||||
String? result;
|
String? result;
|
||||||
var func = validator == null ? null : JSAutoFreeFunction(validator);
|
var func = validator == null ? null : JSAutoFreeFunction(validator);
|
||||||
await showInputDialog(
|
await showInputDialog(
|
||||||
context: App.rootContext,
|
context: App.rootContext,
|
||||||
title: title,
|
title: title,
|
||||||
|
image: image,
|
||||||
onConfirm: (v) {
|
onConfirm: (v) {
|
||||||
if (func != null) {
|
if (func != null) {
|
||||||
var res = func.call([v]);
|
var res = func.call([v]);
|
||||||
|
@@ -41,18 +41,22 @@ class NetworkError extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
Text(
|
Text(
|
||||||
cfe == null ? message : "Cloudflare verification required".tl,
|
cfe == null ? message : "Cloudflare verification required".tl,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
),
|
),
|
||||||
if (retry != null)
|
TextButton(
|
||||||
const SizedBox(
|
onPressed: () {
|
||||||
height: 12,
|
saveFile(
|
||||||
),
|
data: utf8.encode(Log().toString()),
|
||||||
|
filename: 'log.txt',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text("Export logs".tl),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
if (retry != null)
|
if (retry != null)
|
||||||
if (cfe != null)
|
if (cfe != null)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
@@ -74,15 +78,11 @@ class NetworkError extends StatelessWidget {
|
|||||||
body = Column(
|
body = Column(
|
||||||
children: [
|
children: [
|
||||||
const Appbar(title: Text("")),
|
const Appbar(title: Text("")),
|
||||||
Expanded(
|
Expanded(child: body),
|
||||||
child: body,
|
|
||||||
)
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Material(
|
return Material(child: body);
|
||||||
child: body,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +94,7 @@ class ListLoadingIndicator extends StatelessWidget {
|
|||||||
return const SizedBox(
|
return const SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 80,
|
height: 80,
|
||||||
child: Center(
|
child: Center(child: FiveDotLoadingAnimation()),
|
||||||
child: FiveDotLoadingAnimation(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,10 +106,9 @@ class SliverListLoadingIndicator extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// SliverToBoxAdapter can not been lazy loaded.
|
// SliverToBoxAdapter can not been lazy loaded.
|
||||||
// Use SliverList to make sure the animation can be lazy loaded.
|
// Use SliverList to make sure the animation can be lazy loaded.
|
||||||
return SliverList.list(children: const [
|
return SliverList.list(
|
||||||
SizedBox(),
|
children: const [SizedBox(), ListLoadingIndicator()],
|
||||||
ListLoadingIndicator(),
|
);
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,10 +175,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget buildError() {
|
Widget buildError() {
|
||||||
return NetworkError(
|
return NetworkError(message: error!, retry: retry);
|
||||||
message: error!,
|
|
||||||
retry: retry,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -323,11 +317,7 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget buildError(BuildContext context, String error) {
|
Widget buildError(BuildContext context, String error) {
|
||||||
return NetworkError(
|
return NetworkError(withAppbar: false, message: error, retry: reset);
|
||||||
withAppbar: false,
|
|
||||||
message: error,
|
|
||||||
retry: reset,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -388,7 +378,7 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
|
|||||||
Colors.green,
|
Colors.green,
|
||||||
Colors.blue,
|
Colors.blue,
|
||||||
Colors.yellow,
|
Colors.yellow,
|
||||||
Colors.purple
|
Colors.purple,
|
||||||
];
|
];
|
||||||
|
|
||||||
static const _padding = 12.0;
|
static const _padding = 12.0;
|
||||||
@@ -400,16 +390,15 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _controller,
|
animation: _controller,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: _dotSize * 5 + _padding * 6,
|
width: _dotSize * 5 + _padding * 6,
|
||||||
height: _height,
|
height: _height,
|
||||||
child: Stack(
|
child: Stack(children: List.generate(5, (index) => buildDot(index))),
|
||||||
children: List.generate(5, (index) => buildDot(index)),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildDot(int index) {
|
Widget buildDot(int index) {
|
||||||
@@ -417,7 +406,8 @@ class _FiveDotLoadingAnimationState extends State<FiveDotLoadingAnimation>
|
|||||||
var startValue = index * 0.8;
|
var startValue = index * 0.8;
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: index * _dotSize + (index + 1) * _padding,
|
left: index * _dotSize + (index + 1) * _padding,
|
||||||
bottom: (math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
|
bottom:
|
||||||
|
(math.sin(math.pi / 2 * (value - startValue).clamp(0, 2))) *
|
||||||
(_height - _dotSize),
|
(_height - _dotSize),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: _dotSize,
|
width: _dotSize,
|
||||||
|
@@ -290,28 +290,30 @@ class ContentDialog extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var content = Column(
|
var content = SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
title != null
|
children: [
|
||||||
? Appbar(
|
title != null
|
||||||
leading: IconButton(
|
? Appbar(
|
||||||
icon: const Icon(Icons.close),
|
leading: IconButton(
|
||||||
onPressed: dismissible ? context.pop : null,
|
icon: const Icon(Icons.close),
|
||||||
),
|
onPressed: dismissible ? context.pop : null,
|
||||||
title: Text(title!),
|
),
|
||||||
backgroundColor: Colors.transparent,
|
title: Text(title!),
|
||||||
)
|
backgroundColor: Colors.transparent,
|
||||||
: const SizedBox.shrink(),
|
)
|
||||||
this.content,
|
: const SizedBox.shrink(),
|
||||||
const SizedBox(height: 16),
|
this.content,
|
||||||
Row(
|
const SizedBox(height: 16),
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
Row(
|
||||||
children: actions,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
).paddingRight(12),
|
children: actions,
|
||||||
const SizedBox(height: 16),
|
).paddingRight(12),
|
||||||
],
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return Dialog(
|
return Dialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -357,6 +359,7 @@ Future<void> showInputDialog({
|
|||||||
String confirmText = "Confirm",
|
String confirmText = "Confirm",
|
||||||
String cancelText = "Cancel",
|
String cancelText = "Cancel",
|
||||||
RegExp? inputValidator,
|
RegExp? inputValidator,
|
||||||
|
String? image,
|
||||||
}) {
|
}) {
|
||||||
var controller = TextEditingController(text: initialValue);
|
var controller = TextEditingController(text: initialValue);
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
@@ -369,14 +372,23 @@ Future<void> showInputDialog({
|
|||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
return ContentDialog(
|
return ContentDialog(
|
||||||
title: title,
|
title: title,
|
||||||
content: TextField(
|
content: Column(
|
||||||
controller: controller,
|
children: [
|
||||||
decoration: InputDecoration(
|
if (image != null)
|
||||||
hintText: hintText,
|
SizedBox(
|
||||||
border: const OutlineInputBorder(),
|
height: 108,
|
||||||
errorText: error,
|
child: Image.network(image, fit: BoxFit.none),
|
||||||
),
|
).paddingBottom(8),
|
||||||
).paddingHorizontal(12),
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hintText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: error,
|
||||||
|
),
|
||||||
|
).paddingHorizontal(12),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
Button.filled(
|
Button.filled(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
|
@@ -117,16 +117,25 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
|||||||
_futurePosition ??= currentLocation;
|
_futurePosition ??= currentLocation;
|
||||||
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
|
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
|
||||||
_futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k;
|
_futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k;
|
||||||
|
var beforeOffset = (_futurePosition! - currentLocation).abs();
|
||||||
_futurePosition = _futurePosition!.clamp(
|
_futurePosition = _futurePosition!.clamp(
|
||||||
_controller.position.minScrollExtent,
|
_controller.position.minScrollExtent,
|
||||||
_controller.position.maxScrollExtent,
|
_controller.position.maxScrollExtent,
|
||||||
);
|
);
|
||||||
|
var afterOffset = (_futurePosition! - currentLocation).abs();
|
||||||
if (_futurePosition == old) return;
|
if (_futurePosition == old) return;
|
||||||
var target = _futurePosition!;
|
var target = _futurePosition!;
|
||||||
|
var duration = _fastAnimationDuration;
|
||||||
|
if (afterOffset < beforeOffset) {
|
||||||
|
duration = duration * (afterOffset / beforeOffset);
|
||||||
|
if (duration < Duration(milliseconds: 10)) {
|
||||||
|
duration = Duration(milliseconds: 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
_controller
|
_controller
|
||||||
.animateTo(
|
.animateTo(
|
||||||
_futurePosition!,
|
_futurePosition!,
|
||||||
duration: _fastAnimationDuration,
|
duration: duration,
|
||||||
curve: Curves.linear,
|
curve: Curves.linear,
|
||||||
)
|
)
|
||||||
.then((_) {
|
.then((_) {
|
||||||
|
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
|||||||
export "context.dart";
|
export "context.dart";
|
||||||
|
|
||||||
class _App {
|
class _App {
|
||||||
final version = "1.4.0";
|
final version = "1.4.6";
|
||||||
|
|
||||||
bool get isAndroid => Platform.isAndroid;
|
bool get isAndroid => Platform.isAndroid;
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:venera/foundation/app.dart';
|
import 'package:venera/foundation/app.dart';
|
||||||
|
import 'package:venera/foundation/log.dart';
|
||||||
import 'package:venera/utils/data_sync.dart';
|
import 'package:venera/utils/data_sync.dart';
|
||||||
import 'package:venera/utils/init.dart';
|
import 'package:venera/utils/init.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
@@ -25,8 +26,7 @@ class Appdata with Init {
|
|||||||
var data = jsonEncode(toJson());
|
var data = jsonEncode(toJson());
|
||||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||||
await file.writeAsString(data);
|
await file.writeAsString(data);
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
_isSavingData = false;
|
_isSavingData = false;
|
||||||
}
|
}
|
||||||
if (sync) {
|
if (sync) {
|
||||||
@@ -56,10 +56,7 @@ class Appdata with Init {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {'settings': settings._data, 'searchHistory': searchHistory};
|
||||||
'settings': settings._data,
|
|
||||||
'searchHistory': searchHistory,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Following fields are related to device-specific data and should not be synced.
|
/// Following fields are related to device-specific data and should not be synced.
|
||||||
@@ -94,8 +91,7 @@ class Appdata with Init {
|
|||||||
try {
|
try {
|
||||||
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
|
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
|
||||||
await file.writeAsString(jsonEncode(implicitData));
|
await file.writeAsString(jsonEncode(implicitData));
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
_isSavingData = false;
|
_isSavingData = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,28 +99,33 @@ class Appdata with Init {
|
|||||||
@override
|
@override
|
||||||
Future<void> doInit() async {
|
Future<void> doInit() async {
|
||||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||||
var file = File(FilePath.join(
|
var file = File(FilePath.join(dataPath, 'appdata.json'));
|
||||||
dataPath,
|
|
||||||
'appdata.json',
|
|
||||||
));
|
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var json = jsonDecode(await file.readAsString());
|
try {
|
||||||
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
|
var json = jsonDecode(await file.readAsString());
|
||||||
if (json['settings'][key] != null) {
|
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
|
||||||
settings[key] = json['settings'][key];
|
if (json['settings'][key] != null) {
|
||||||
|
settings[key] = json['settings'][key];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
searchHistory = List.from(json['searchHistory']);
|
||||||
|
} catch (e) {
|
||||||
|
Log.error("Appdata", "Failed to load appdata", e);
|
||||||
|
Log.info("Appdata", "Resetting appdata");
|
||||||
|
file.deleteIgnoreError();
|
||||||
}
|
}
|
||||||
searchHistory = List.from(json['searchHistory']);
|
try {
|
||||||
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||||
if (await implicitDataFile.exists()) {
|
if (await implicitDataFile.exists()) {
|
||||||
try {
|
|
||||||
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
implicitData = jsonDecode(await implicitDataFile.readAsString());
|
||||||
}
|
}
|
||||||
catch(_) {
|
} catch (e) {
|
||||||
// ignore
|
Log.error("Appdata", "Failed to load implicit data", e);
|
||||||
}
|
Log.info("Appdata", "Resetting implicit data");
|
||||||
|
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
|
||||||
|
implicitDataFile.deleteIgnoreError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +152,7 @@ class Settings with ChangeNotifier {
|
|||||||
'blockedWords': [],
|
'blockedWords': [],
|
||||||
'defaultSearchTarget': null,
|
'defaultSearchTarget': null,
|
||||||
'autoPageTurningInterval': 5, // in seconds
|
'autoPageTurningInterval': 5, // in seconds
|
||||||
|
'enableComicSpecificSettings': false,
|
||||||
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
|
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
|
||||||
'readerScreenPicNumberForLandscape': 1, // 1 - 5
|
'readerScreenPicNumberForLandscape': 1, // 1 - 5
|
||||||
'readerScreenPicNumberForPortrait': 1, // 1 - 5
|
'readerScreenPicNumberForPortrait': 1, // 1 - 5
|
||||||
@@ -178,13 +180,17 @@ class Settings with ChangeNotifier {
|
|||||||
'customImageProcessing': defaultCustomImageProcessing,
|
'customImageProcessing': defaultCustomImageProcessing,
|
||||||
'sni': true,
|
'sni': true,
|
||||||
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
|
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
|
||||||
'comicSourceListUrl':
|
'comicSourceListUrl': _defaultSourceListUrl,
|
||||||
"https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
|
|
||||||
'preloadImageCount': 4,
|
'preloadImageCount': 4,
|
||||||
'followUpdatesFolder': null,
|
'followUpdatesFolder': null,
|
||||||
'initialPage': '0',
|
'initialPage': '0',
|
||||||
'comicListDisplayMode': 'paging', // paging, continuous
|
'comicListDisplayMode': 'paging', // paging, continuous
|
||||||
'showPageNumberInReader': true,
|
'showPageNumberInReader': true,
|
||||||
|
'showSingleImageOnFirstPage': false,
|
||||||
|
'enableDoubleTapToZoom': true,
|
||||||
|
'reverseChapterOrder': false,
|
||||||
|
'showSystemStatusBar': false,
|
||||||
|
'comicSpecificSettings': <String, Map<String, dynamic>>{},
|
||||||
};
|
};
|
||||||
|
|
||||||
operator [](String key) {
|
operator [](String key) {
|
||||||
@@ -193,6 +199,62 @@ class Settings with ChangeNotifier {
|
|||||||
|
|
||||||
operator []=(String key, dynamic value) {
|
operator []=(String key, dynamic value) {
|
||||||
_data[key] = value;
|
_data[key] = value;
|
||||||
|
if (key != "dataVersion") {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool haveComicSpecificSettings(String comicId, String sourceKey, String key) {
|
||||||
|
return _data['comicSpecificSettings']?["$comicId@$sourceKey"]?.containsKey(
|
||||||
|
key,
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic getReaderSetting(String comicId, String sourceKey, String key) {
|
||||||
|
if (key == 'enableComicSpecificSettings') {
|
||||||
|
return _data['enableComicSpecificSettings'];
|
||||||
|
}
|
||||||
|
if (_data['enableComicSpecificSettings'] == false) {
|
||||||
|
return _data[key];
|
||||||
|
}
|
||||||
|
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?[key] ??
|
||||||
|
_data[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
void setReaderSetting(
|
||||||
|
String comicId,
|
||||||
|
String sourceKey,
|
||||||
|
String key,
|
||||||
|
dynamic value,
|
||||||
|
) {
|
||||||
|
if (key == 'enableComicSpecificSettings') {
|
||||||
|
_data['enableComicSpecificSettings'] = value;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_data['enableComicSpecificSettings'] == false) {
|
||||||
|
_data[key] = value;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(_data['comicSpecificSettings'] as Map<String, dynamic>).putIfAbsent(
|
||||||
|
"$comicId@$sourceKey",
|
||||||
|
() => <String, dynamic>{},
|
||||||
|
)[key] = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetComicReaderSettings(String comicId, String sourceKey) {
|
||||||
|
final allComicSettings = _data['comicSpecificSettings'] as Map;
|
||||||
|
if (allComicSettings.containsKey("$comicId@$sourceKey")) {
|
||||||
|
allComicSettings.remove("$comicId@$sourceKey");
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetAllComicReaderSettings() {
|
||||||
|
_data['comicSpecificSettings'] = <String, Map<String, dynamic>>{};
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,3 +281,6 @@ function processImage(image, cid, eid, page, sourceKey) {
|
|||||||
return futureImage;
|
return futureImage;
|
||||||
}
|
}
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
const _defaultSourceListUrl =
|
||||||
|
"https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json";
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:sqlite3/sqlite3.dart';
|
import 'package:sqlite3/sqlite3.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
|
|
||||||
@@ -21,7 +23,52 @@ class CacheManager {
|
|||||||
|
|
||||||
int _limitSize = 2 * 1024 * 1024 * 1024;
|
int _limitSize = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
CacheManager._create(){
|
static Future<int> _scanDir(Pointer<void> dbP, String dir) async {
|
||||||
|
var res = await Isolate.run(() async {
|
||||||
|
int totalSize = 0;
|
||||||
|
List<String> unmanagedFiles = [];
|
||||||
|
var db = sqlite3.fromPointer(dbP);
|
||||||
|
await for (var file in Directory(dir).list(recursive: true)) {
|
||||||
|
if (file is File) {
|
||||||
|
var size = await file.length();
|
||||||
|
var segments = file.uri.pathSegments;
|
||||||
|
var name = segments.last;
|
||||||
|
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
|
||||||
|
var res = db.select('''
|
||||||
|
SELECT * FROM cache
|
||||||
|
WHERE dir = ? AND name = ?
|
||||||
|
''', [dir, name]);
|
||||||
|
if (res.isEmpty) {
|
||||||
|
unmanagedFiles.add(file.path);
|
||||||
|
} else {
|
||||||
|
totalSize += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'totalSize': totalSize,
|
||||||
|
'unmanagedFiles': unmanagedFiles,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// delete unmanaged files
|
||||||
|
// Only modify the database in the main isolate to avoid deadlock
|
||||||
|
for (var filePath in res['unmanagedFiles'] as List<String>) {
|
||||||
|
var file = File(filePath);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
var segments = file.uri.pathSegments;
|
||||||
|
var name = segments.last;
|
||||||
|
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
|
||||||
|
CacheManager()._db.execute('''
|
||||||
|
DELETE FROM cache
|
||||||
|
WHERE dir = ? AND name = ?
|
||||||
|
''', [dir, name]);
|
||||||
|
}
|
||||||
|
return res['totalSize'] as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
CacheManager._create() {
|
||||||
Directory(cachePath).createSync(recursive: true);
|
Directory(cachePath).createSync(recursive: true);
|
||||||
_db = sqlite3.open('${App.dataPath}/cache.db');
|
_db = sqlite3.open('${App.dataPath}/cache.db');
|
||||||
_db.execute('''
|
_db.execute('''
|
||||||
@@ -33,100 +80,103 @@ class CacheManager {
|
|||||||
type TEXT
|
type TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
compute((path) => Directory(path).size, cachePath)
|
_scanDir(_db.handle, cachePath).then((value) {
|
||||||
.then((value) => _currentSize = value);
|
_currentSize = value;
|
||||||
|
checkCache();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the singleton instance of CacheManager.
|
||||||
factory CacheManager() => instance ??= CacheManager._create();
|
factory CacheManager() => instance ??= CacheManager._create();
|
||||||
|
|
||||||
/// set cache size limit in MB
|
/// set cache size limit in MB
|
||||||
void setLimitSize(int size){
|
void setLimitSize(int size) {
|
||||||
_limitSize = size * 1024 * 1024;
|
_limitSize = size * 1024 * 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setType(String key, String? type){
|
/// Write cache to disk.
|
||||||
_db.execute('''
|
Future<void> writeCache(String key, List<int> data,
|
||||||
UPDATE cache
|
[int duration = 7 * 24 * 60 * 60 * 1000]) async {
|
||||||
SET type = ?
|
await delete(key);
|
||||||
WHERE key = ?
|
|
||||||
''', [type, key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
String? getType(String key){
|
|
||||||
var res = _db.select('''
|
|
||||||
SELECT type FROM cache
|
|
||||||
WHERE key = ?
|
|
||||||
''', [key]);
|
|
||||||
if(res.isEmpty){
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return res.first[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> writeCache(String key, List<int> data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
|
|
||||||
this.dir++;
|
this.dir++;
|
||||||
this.dir %= 100;
|
this.dir %= 100;
|
||||||
var dir = this.dir;
|
var dir = this.dir;
|
||||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
var name = md5.convert(key.codeUnits).toString();
|
||||||
var file = File('$cachePath/$dir/$name');
|
var file = File('$cachePath/$dir/$name');
|
||||||
while(await file.exists()){
|
|
||||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
|
||||||
file = File('$cachePath/$dir/$name');
|
|
||||||
}
|
|
||||||
await file.create(recursive: true);
|
await file.create(recursive: true);
|
||||||
await file.writeAsBytes(data);
|
await file.writeAsBytes(data);
|
||||||
var expires = DateTime.now().millisecondsSinceEpoch + duration;
|
var expires = DateTime.now().millisecondsSinceEpoch + duration;
|
||||||
_db.execute('''
|
_db.execute('''
|
||||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||||
''', [key, dir.toString(), name, expires]);
|
''', [key, dir.toString(), name, expires]);
|
||||||
if(_currentSize != null) {
|
if (_currentSize != null) {
|
||||||
_currentSize = _currentSize! + data.length;
|
_currentSize = _currentSize! + data.length;
|
||||||
}
|
}
|
||||||
checkCacheIfRequired();
|
checkCacheIfRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<CachingFile> openWrite(String key) async{
|
/// Find cache by key.
|
||||||
this.dir++;
|
/// If cache is expired, it will be deleted and return null.
|
||||||
this.dir %= 100;
|
/// If cache is not found, it will return null.
|
||||||
var dir = this.dir;
|
/// If cache is found, it will return the file, and update the expires time.
|
||||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
Future<File?> findCache(String key) async {
|
||||||
var file = File('$cachePath/$dir/$name');
|
|
||||||
while(await file.exists()){
|
|
||||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
|
||||||
file = File('$cachePath/$dir/$name');
|
|
||||||
}
|
|
||||||
await file.create(recursive: true);
|
|
||||||
return CachingFile._(key, dir.toString(), name, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<File?> findCache(String key) async{
|
|
||||||
var res = _db.select('''
|
var res = _db.select('''
|
||||||
SELECT * FROM cache
|
SELECT * FROM cache
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
''', [key]);
|
''', [key]);
|
||||||
if(res.isEmpty){
|
if (res.isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
var row = res.first;
|
var row = res.first;
|
||||||
var dir = row[1] as String;
|
var dir = row[1] as String;
|
||||||
var name = row[2] as String;
|
var name = row[2] as String;
|
||||||
|
var expires = row[3] as int;
|
||||||
var file = File('$cachePath/$dir/$name');
|
var file = File('$cachePath/$dir/$name');
|
||||||
if(await file.exists()){
|
var now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (expires < now) {
|
||||||
|
// expired
|
||||||
|
_db.execute('''
|
||||||
|
DELETE FROM cache
|
||||||
|
WHERE key = ?
|
||||||
|
''', [key]);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (await file.exists()) {
|
||||||
|
// update time
|
||||||
|
var expires = now + 7 * 24 * 60 * 60 * 1000;
|
||||||
|
_db.execute('''
|
||||||
|
UPDATE cache
|
||||||
|
SET expires = ?
|
||||||
|
WHERE key = ?
|
||||||
|
''', [expires, key]);
|
||||||
return file;
|
return file;
|
||||||
|
} else {
|
||||||
|
_db.execute('''
|
||||||
|
DELETE FROM cache
|
||||||
|
WHERE key = ?
|
||||||
|
''', [key]);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isChecking = false;
|
bool _isChecking = false;
|
||||||
|
|
||||||
|
/// Check cache size and delete expired cache.
|
||||||
|
/// Only check cache if current size is greater than limit size.
|
||||||
void checkCacheIfRequired() {
|
void checkCacheIfRequired() {
|
||||||
if(_currentSize != null && _currentSize! > _limitSize){
|
if (_currentSize != null && _currentSize! > _limitSize) {
|
||||||
checkCache();
|
checkCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkCache() async{
|
/// Check cache size and delete expired cache.
|
||||||
if(_isChecking){
|
/// If current size is greater than limit size,
|
||||||
|
/// delete cache until current size is less than limit size.
|
||||||
|
Future<void> checkCache() async {
|
||||||
|
if (_isChecking) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_isChecking = true;
|
_isChecking = true;
|
||||||
@@ -134,39 +184,42 @@ class CacheManager {
|
|||||||
SELECT * FROM cache
|
SELECT * FROM cache
|
||||||
WHERE expires < ?
|
WHERE expires < ?
|
||||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||||
for(var row in res){
|
for (var row in res) {
|
||||||
var dir = row[1] as String;
|
var dir = row[1] as String;
|
||||||
var name = row[2] as String;
|
var name = row[2] as String;
|
||||||
var file = File('$cachePath/$dir/$name');
|
var file = File('$cachePath/$dir/$name');
|
||||||
if(await file.exists()){
|
if (await file.exists()) {
|
||||||
|
var size = await file.length();
|
||||||
|
_currentSize = _currentSize! - size;
|
||||||
await file.delete();
|
await file.delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_db.execute('''
|
if (res.isNotEmpty) {
|
||||||
|
_db.execute('''
|
||||||
DELETE FROM cache
|
DELETE FROM cache
|
||||||
WHERE expires < ?
|
WHERE expires < ?
|
||||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||||
|
|
||||||
int count = 0;
|
|
||||||
var res2 = _db.select('''
|
|
||||||
SELECT COUNT(*) FROM cache
|
|
||||||
''');
|
|
||||||
if(res2.isNotEmpty){
|
|
||||||
count = res2.first[0] as int;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){
|
while (_currentSize != null && _currentSize! > _limitSize) {
|
||||||
var res = _db.select('''
|
var res = _db.select('''
|
||||||
SELECT * FROM cache
|
SELECT * FROM cache
|
||||||
ORDER BY expires ASC
|
ORDER BY expires ASC
|
||||||
limit 10
|
limit 10
|
||||||
''');
|
''');
|
||||||
for(var row in res){
|
if (res.isEmpty) {
|
||||||
|
// There are many files unmanaged by the cache manager.
|
||||||
|
// Clear all cache.
|
||||||
|
await Directory(cachePath).delete(recursive: true);
|
||||||
|
Directory(cachePath).createSync(recursive: true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (var row in res) {
|
||||||
var key = row[0] as String;
|
var key = row[0] as String;
|
||||||
var dir = row[1] as String;
|
var dir = row[1] as String;
|
||||||
var name = row[2] as String;
|
var name = row[2] as String;
|
||||||
var file = File('$cachePath/$dir/$name');
|
var file = File('$cachePath/$dir/$name');
|
||||||
if(await file.exists()){
|
if (await file.exists()) {
|
||||||
var size = await file.length();
|
var size = await file.length();
|
||||||
await file.delete();
|
await file.delete();
|
||||||
_db.execute('''
|
_db.execute('''
|
||||||
@@ -174,7 +227,7 @@ class CacheManager {
|
|||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
''', [key]);
|
''', [key]);
|
||||||
_currentSize = _currentSize! - size;
|
_currentSize = _currentSize! - size;
|
||||||
if(_currentSize! <= _limitSize){
|
if (_currentSize! <= _limitSize) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -183,18 +236,18 @@ class CacheManager {
|
|||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
''', [key]);
|
''', [key]);
|
||||||
}
|
}
|
||||||
count--;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_isChecking = false;
|
_isChecking = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(String key) async{
|
/// Delete cache by key.
|
||||||
|
Future<void> delete(String key) async {
|
||||||
var res = _db.select('''
|
var res = _db.select('''
|
||||||
SELECT * FROM cache
|
SELECT * FROM cache
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
''', [key]);
|
''', [key]);
|
||||||
if(res.isEmpty){
|
if (res.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var row = res.first;
|
var row = res.first;
|
||||||
@@ -202,7 +255,7 @@ class CacheManager {
|
|||||||
var name = row[2] as String;
|
var name = row[2] as String;
|
||||||
var file = File('$cachePath/$dir/$name');
|
var file = File('$cachePath/$dir/$name');
|
||||||
var fileSize = 0;
|
var fileSize = 0;
|
||||||
if(await file.exists()){
|
if (await file.exists()) {
|
||||||
fileSize = await file.length();
|
fileSize = await file.length();
|
||||||
await file.delete();
|
await file.delete();
|
||||||
}
|
}
|
||||||
@@ -210,11 +263,12 @@ class CacheManager {
|
|||||||
DELETE FROM cache
|
DELETE FROM cache
|
||||||
WHERE key = ?
|
WHERE key = ?
|
||||||
''', [key]);
|
''', [key]);
|
||||||
if(_currentSize != null) {
|
if (_currentSize != null) {
|
||||||
_currentSize = _currentSize! - fileSize;
|
_currentSize = _currentSize! - fileSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete all cache.
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await Directory(cachePath).delete(recursive: true);
|
await Directory(cachePath).delete(recursive: true);
|
||||||
Directory(cachePath).createSync(recursive: true);
|
Directory(cachePath).createSync(recursive: true);
|
||||||
@@ -223,75 +277,4 @@ class CacheManager {
|
|||||||
''');
|
''');
|
||||||
_currentSize = 0;
|
_currentSize = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteKeyword(String keyword) async{
|
|
||||||
var res = _db.select('''
|
|
||||||
SELECT * FROM cache
|
|
||||||
WHERE key LIKE ?
|
|
||||||
''', ['%$keyword%']);
|
|
||||||
for(var row in res){
|
|
||||||
var key = row[0] as String;
|
|
||||||
var dir = row[1] as String;
|
|
||||||
var name = row[2] as String;
|
|
||||||
var file = File('$cachePath/$dir/$name');
|
|
||||||
var fileSize = 0;
|
|
||||||
if(await file.exists()){
|
|
||||||
fileSize = await file.length();
|
|
||||||
try {
|
|
||||||
await file.delete();
|
|
||||||
}
|
|
||||||
finally {}
|
|
||||||
}
|
|
||||||
_db.execute('''
|
|
||||||
DELETE FROM cache
|
|
||||||
WHERE key = ?
|
|
||||||
''', [key]);
|
|
||||||
if(_currentSize != null) {
|
|
||||||
_currentSize = _currentSize! - fileSize;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class CachingFile{
|
|
||||||
CachingFile._(this.key, this.dir, this.name, this.file);
|
|
||||||
|
|
||||||
final String key;
|
|
||||||
|
|
||||||
final String dir;
|
|
||||||
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
final File file;
|
|
||||||
|
|
||||||
final List<int> _buffer = [];
|
|
||||||
|
|
||||||
Future<void> writeBytes(List<int> data) async{
|
|
||||||
_buffer.addAll(data);
|
|
||||||
if(_buffer.length > 1024 * 1024){
|
|
||||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
|
||||||
_buffer.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> close() async{
|
|
||||||
if(_buffer.isNotEmpty){
|
|
||||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
|
||||||
}
|
|
||||||
CacheManager()._db.execute('''
|
|
||||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
|
||||||
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
|
|
||||||
CacheManager().checkCacheIfRequired();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cancel() async{
|
|
||||||
await file.deleteIgnoreError();
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
_buffer.clear();
|
|
||||||
if(file.existsSync()) {
|
|
||||||
file.deleteSync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -184,6 +184,9 @@ class ComicSource {
|
|||||||
|
|
||||||
final HandleClickTagEvent? handleClickTagEvent;
|
final HandleClickTagEvent? handleClickTagEvent;
|
||||||
|
|
||||||
|
/// Callback when a tag suggestion is selected in search.
|
||||||
|
final TagSuggestionSelectFunc? onTagSuggestionSelected;
|
||||||
|
|
||||||
final LinkHandler? linkHandler;
|
final LinkHandler? linkHandler;
|
||||||
|
|
||||||
final bool enableTagsSuggestions;
|
final bool enableTagsSuggestions;
|
||||||
@@ -259,6 +262,7 @@ class ComicSource {
|
|||||||
this.idMatcher,
|
this.idMatcher,
|
||||||
this.translations,
|
this.translations,
|
||||||
this.handleClickTagEvent,
|
this.handleClickTagEvent,
|
||||||
|
this.onTagSuggestionSelected,
|
||||||
this.linkHandler,
|
this.linkHandler,
|
||||||
this.enableTagsSuggestions,
|
this.enableTagsSuggestions,
|
||||||
this.enableTagsTranslate,
|
this.enableTagsTranslate,
|
||||||
|
@@ -116,6 +116,26 @@ class Comic {
|
|||||||
toString() => "$sourceKey@$id";
|
toString() => "$sourceKey@$id";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ComicID {
|
||||||
|
final ComicType type;
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
const ComicID(this.type, this.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is! ComicID) return false;
|
||||||
|
return other.type == type && other.id == id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => type.hashCode ^ id.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => "$type@$id";
|
||||||
|
}
|
||||||
|
|
||||||
class ComicDetails with HistoryMixin {
|
class ComicDetails with HistoryMixin {
|
||||||
@override
|
@override
|
||||||
final String title;
|
final String title;
|
||||||
|
@@ -148,6 +148,7 @@ class ComicSourceParser {
|
|||||||
_parseIdMatch(),
|
_parseIdMatch(),
|
||||||
_parseTranslation(),
|
_parseTranslation(),
|
||||||
_parseClickTagEvent(),
|
_parseClickTagEvent(),
|
||||||
|
_parseTagSuggestionSelectFunc(),
|
||||||
_parseLinkHandler(),
|
_parseLinkHandler(),
|
||||||
_getValue("search.enableTagsSuggestions") ?? false,
|
_getValue("search.enableTagsSuggestions") ?? false,
|
||||||
_getValue("comic.enableTagsTranslate") ?? false,
|
_getValue("comic.enableTagsTranslate") ?? false,
|
||||||
@@ -1057,6 +1058,19 @@ class ComicSourceParser {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TagSuggestionSelectFunc? _parseTagSuggestionSelectFunc() {
|
||||||
|
if (!_checkExists("search.onTagSuggestionSelected")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (namespace, tag) {
|
||||||
|
var res = JsEngine().runCode("""
|
||||||
|
ComicSource.sources.$_key.search.onTagSuggestionSelected(
|
||||||
|
${jsonEncode(namespace)}, ${jsonEncode(tag)})
|
||||||
|
""");
|
||||||
|
return res is String ? res : "$namespace:$tag";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
LinkHandler? _parseLinkHandler() {
|
LinkHandler? _parseLinkHandler() {
|
||||||
if (!_checkExists("comic.link")) {
|
if (!_checkExists("comic.link")) {
|
||||||
return null;
|
return null;
|
||||||
|
@@ -44,5 +44,10 @@ typedef VoteCommentFunc = Future<Res<int?>> Function(
|
|||||||
typedef HandleClickTagEvent = PageJumpTarget? Function(
|
typedef HandleClickTagEvent = PageJumpTarget? Function(
|
||||||
String namespace, String tag);
|
String namespace, String tag);
|
||||||
|
|
||||||
|
/// Handle tag suggestion selection event. Should return the text to insert
|
||||||
|
/// into the search field.
|
||||||
|
typedef TagSuggestionSelectFunc = String Function(
|
||||||
|
String namespace, String tag);
|
||||||
|
|
||||||
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
|
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
|
||||||
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
@@ -1,4 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:sqlite3/sqlite3.dart';
|
import 'package:sqlite3/sqlite3.dart';
|
||||||
@@ -209,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
|
|
||||||
late Database _db;
|
late Database _db;
|
||||||
|
|
||||||
|
late Map<String, int> counts;
|
||||||
|
|
||||||
|
int get totalComics {
|
||||||
|
int total = 0;
|
||||||
|
for (var t in counts.values) {
|
||||||
|
total += t;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
int folderComics(String folder) {
|
||||||
|
return counts[folder] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
|
counts = {};
|
||||||
_db = sqlite3.open("${App.dataPath}/local_favorite.db");
|
_db = sqlite3.open("${App.dataPath}/local_favorite.db");
|
||||||
_db.execute("""
|
_db.execute("""
|
||||||
create table if not exists folder_order (
|
create table if not exists folder_order (
|
||||||
@@ -234,7 +251,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
alter table "$folder"
|
alter table "$folder"
|
||||||
add column translated_tags TEXT;
|
add column translated_tags TEXT;
|
||||||
""");
|
""");
|
||||||
var comics = getAllComics(folder);
|
var comics = getFolderComics(folder);
|
||||||
for (var comic in comics) {
|
for (var comic in comics) {
|
||||||
var translatedTags = _translateTags(comic.tags);
|
var translatedTags = _translateTags(comic.tags);
|
||||||
_db.execute("""
|
_db.execute("""
|
||||||
@@ -256,6 +273,13 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
appdata.settings['followUpdatesFolder'] = null;
|
appdata.settings['followUpdatesFolder'] = null;
|
||||||
}
|
}
|
||||||
|
initCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
void initCounts() {
|
||||||
|
for (var folder in folderNames) {
|
||||||
|
counts[folder] = count(folder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> find(String id, ComicType type) {
|
List<String> find(String id, ComicType type) {
|
||||||
@@ -349,7 +373,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
""").firstOrNull?["min_value"] ?? 0;
|
""").firstOrNull?["min_value"] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<FavoriteItem> getAllComics(String folder) {
|
List<FavoriteItem> getFolderComics(String folder) {
|
||||||
var rows = _db.select("""
|
var rows = _db.select("""
|
||||||
select * from "$folder"
|
select * from "$folder"
|
||||||
ORDER BY display_order;
|
ORDER BY display_order;
|
||||||
@@ -357,6 +381,54 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
|
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<FavoriteItem>> _getFolderComicsAsync(
|
||||||
|
String folder, Pointer<void> p) {
|
||||||
|
return Isolate.run(() {
|
||||||
|
var db = sqlite3.fromPointer(p);
|
||||||
|
var rows = db.select("""
|
||||||
|
select * from "$folder"
|
||||||
|
ORDER BY display_order;
|
||||||
|
""");
|
||||||
|
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a new isolate to get the comics in the folder
|
||||||
|
Future<List<FavoriteItem>> getFolderComicsAsync(String folder) {
|
||||||
|
return _getFolderComicsAsync(folder, _db.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FavoriteItem> getAllComics() {
|
||||||
|
var res = <FavoriteItem>{};
|
||||||
|
for (final folder in folderNames) {
|
||||||
|
var comics = _db.select("""
|
||||||
|
select * from "$folder";
|
||||||
|
""");
|
||||||
|
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
|
||||||
|
}
|
||||||
|
return res.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<FavoriteItem>> _getAllComicsAsync(
|
||||||
|
List<String> folders, Pointer<void> p) {
|
||||||
|
return Isolate.run(() {
|
||||||
|
var db = sqlite3.fromPointer(p);
|
||||||
|
var res = <FavoriteItem>{};
|
||||||
|
for (final folder in folders) {
|
||||||
|
var comics = db.select("""
|
||||||
|
select * from "$folder";
|
||||||
|
""");
|
||||||
|
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
|
||||||
|
}
|
||||||
|
return res.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a new isolate to get all the comics
|
||||||
|
Future<List<FavoriteItem>> getAllComicsAsync() {
|
||||||
|
return _getAllComicsAsync(folderNames, _db.handle);
|
||||||
|
}
|
||||||
|
|
||||||
void addTagTo(String folder, String id, String tag) {
|
void addTagTo(String folder, String id, String tag) {
|
||||||
_db.execute("""
|
_db.execute("""
|
||||||
update "$folder"
|
update "$folder"
|
||||||
@@ -422,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
""");
|
""");
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
counts[name] = 0;
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +609,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
""", [updateTime, comic.id, comic.type.value]);
|
""", [updateTime, comic.id, comic.type.value]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (counts[folder] == null) {
|
||||||
|
counts[folder] = count(folder);
|
||||||
|
} else {
|
||||||
|
counts[folder] = counts[folder]! + 1;
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -575,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void batchMoveFavorites(
|
||||||
|
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||||
|
_modifiedAfterLastCache = true;
|
||||||
|
|
||||||
|
if (!existsFolder(sourceFolder)) {
|
||||||
|
throw Exception("Source folder does not exist");
|
||||||
|
}
|
||||||
|
if (!existsFolder(targetFolder)) {
|
||||||
|
throw Exception("Target folder does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.execute("BEGIN TRANSACTION");
|
||||||
|
var displayOrder = maxValue(targetFolder) + 1;
|
||||||
|
try {
|
||||||
|
for (var item in items) {
|
||||||
|
_db.execute("""
|
||||||
|
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||||
|
select id, name, author, type, tags, cover_path, time, ?
|
||||||
|
from "$sourceFolder"
|
||||||
|
where id == ? and type == ?;
|
||||||
|
""", [displayOrder, item.id, item.type.value]);
|
||||||
|
|
||||||
|
_db.execute("""
|
||||||
|
delete from "$sourceFolder"
|
||||||
|
where id == ? and type == ?;
|
||||||
|
""", [item.id, item.type.value]);
|
||||||
|
|
||||||
|
displayOrder++;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
Log.error("Batch Move Favorites", e.toString());
|
||||||
|
_db.execute("ROLLBACK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_db.execute("COMMIT");
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
if (counts[targetFolder] == null) {
|
||||||
|
counts[targetFolder] = count(targetFolder);
|
||||||
|
} else {
|
||||||
|
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counts[sourceFolder] != null) {
|
||||||
|
counts[sourceFolder] = counts[sourceFolder]! - items.length;
|
||||||
|
} else {
|
||||||
|
counts[sourceFolder] = count(sourceFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void batchCopyFavorites(
|
||||||
|
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||||
|
_modifiedAfterLastCache = true;
|
||||||
|
|
||||||
|
if (!existsFolder(sourceFolder)) {
|
||||||
|
throw Exception("Source folder does not exist");
|
||||||
|
}
|
||||||
|
if (!existsFolder(targetFolder)) {
|
||||||
|
throw Exception("Target folder does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.execute("BEGIN TRANSACTION");
|
||||||
|
var displayOrder = maxValue(targetFolder) + 1;
|
||||||
|
try {
|
||||||
|
for (var item in items) {
|
||||||
|
_db.execute("""
|
||||||
|
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||||
|
select id, name, author, type, tags, cover_path, time, ?
|
||||||
|
from "$sourceFolder"
|
||||||
|
where id == ? and type == ?;
|
||||||
|
""", [displayOrder, item.id, item.type.value]);
|
||||||
|
|
||||||
|
displayOrder++;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
Log.error("Batch Copy Favorites", e.toString());
|
||||||
|
_db.execute("ROLLBACK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_db.execute("COMMIT");
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
if (counts[targetFolder] == null) {
|
||||||
|
counts[targetFolder] = count(targetFolder);
|
||||||
|
} else {
|
||||||
|
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// delete a folder
|
/// delete a folder
|
||||||
void deleteFolder(String name) {
|
void deleteFolder(String name) {
|
||||||
_modifiedAfterLastCache = true;
|
_modifiedAfterLastCache = true;
|
||||||
@@ -585,14 +759,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
delete from folder_order
|
delete from folder_order
|
||||||
where folder_name == ?;
|
where folder_name == ?;
|
||||||
""", [name]);
|
""", [name]);
|
||||||
|
counts.remove(name);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteComic(String folder, FavoriteItem comic) {
|
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
deleteComicWithId(folder, comic.id, comic.type);
|
|
||||||
}
|
|
||||||
|
|
||||||
void deleteComicWithId(String folder, String id, ComicType type) {
|
void deleteComicWithId(String folder, String id, ComicType type) {
|
||||||
_modifiedAfterLastCache = true;
|
_modifiedAfterLastCache = true;
|
||||||
LocalFavoriteImageProvider.delete(id, type.value);
|
LocalFavoriteImageProvider.delete(id, type.value);
|
||||||
@@ -600,6 +770,60 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
delete from "$folder"
|
delete from "$folder"
|
||||||
where id == ? and type == ?;
|
where id == ? and type == ?;
|
||||||
""", [id, type.value]);
|
""", [id, type.value]);
|
||||||
|
if (counts[folder] != null) {
|
||||||
|
counts[folder] = counts[folder]! - 1;
|
||||||
|
} else {
|
||||||
|
counts[folder] = count(folder);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
|
||||||
|
_modifiedAfterLastCache = true;
|
||||||
|
_db.execute("BEGIN TRANSACTION");
|
||||||
|
try {
|
||||||
|
for (var comic in comics) {
|
||||||
|
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
|
||||||
|
_db.execute("""
|
||||||
|
delete from "$folder"
|
||||||
|
where id == ? and type == ?;
|
||||||
|
""", [comic.id, comic.type.value]);
|
||||||
|
}
|
||||||
|
if (counts[folder] != null) {
|
||||||
|
counts[folder] = counts[folder]! - comics.length;
|
||||||
|
} else {
|
||||||
|
counts[folder] = count(folder);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error("Batch Delete Comics", e.toString());
|
||||||
|
_db.execute("ROLLBACK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_db.execute("COMMIT");
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
|
||||||
|
_modifiedAfterLastCache = true;
|
||||||
|
_db.execute("BEGIN TRANSACTION");
|
||||||
|
var folderNames = _getFolderNamesWithDB();
|
||||||
|
try {
|
||||||
|
for (var comic in comics) {
|
||||||
|
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
|
||||||
|
for (var folder in folderNames) {
|
||||||
|
_db.execute("""
|
||||||
|
delete from "$folder"
|
||||||
|
where id == ? and type == ?;
|
||||||
|
""", [comic.id, comic.type.value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error("Batch Delete Comics in All Folders", e.toString());
|
||||||
|
_db.execute("ROLLBACK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initCounts();
|
||||||
|
_db.execute("COMMIT");
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,11 +854,26 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
if (!existsFolder(folder)) {
|
if (!existsFolder(folder)) {
|
||||||
throw Exception("Failed to reorder: folder not found");
|
throw Exception("Failed to reorder: folder not found");
|
||||||
}
|
}
|
||||||
deleteFolder(folder);
|
_db.execute("BEGIN TRANSACTION");
|
||||||
createFolder(folder);
|
try {
|
||||||
for (int i = 0; i < newFolder.length; i++) {
|
for (int i = 0; i < newFolder.length; i++) {
|
||||||
addComic(folder, newFolder[i], i);
|
_db.execute("""
|
||||||
|
update "$folder"
|
||||||
|
set display_order = ?
|
||||||
|
where id == ? and type == ?;
|
||||||
|
""", [
|
||||||
|
i,
|
||||||
|
newFolder[i].id,
|
||||||
|
newFolder[i].type.value
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
catch (e) {
|
||||||
|
Log.error("Reorder", e.toString());
|
||||||
|
_db.execute("ROLLBACK");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_db.execute("COMMIT");
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,6 +898,8 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
set folder_name = ?
|
set folder_name = ?
|
||||||
where folder_name == ?;
|
where folder_name == ?;
|
||||||
""", [after, before]);
|
""", [after, before]);
|
||||||
|
counts[after] = counts[before] ?? 0;
|
||||||
|
counts.remove(before);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,10 +977,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
return comics;
|
return comics;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<FavoriteItemWithFolderInfo> search(String keyword) {
|
List<FavoriteItem> search(String keyword) {
|
||||||
var keywordList = keyword.split(" ");
|
var keywordList = keyword.split(" ");
|
||||||
keyword = keywordList.first;
|
keyword = keywordList.first;
|
||||||
var comics = <FavoriteItemWithFolderInfo>[];
|
var comics = <FavoriteItem>{};
|
||||||
for (var table in folderNames) {
|
for (var table in folderNames) {
|
||||||
keyword = "%$keyword%";
|
keyword = "%$keyword%";
|
||||||
var res = _db.select("""
|
var res = _db.select("""
|
||||||
@@ -747,15 +988,18 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
|
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ? OR translated_tags LIKE ?;
|
||||||
""", [keyword, keyword, keyword, keyword]);
|
""", [keyword, keyword, keyword, keyword]);
|
||||||
for (var comic in res) {
|
for (var comic in res) {
|
||||||
comics.add(
|
comics.add(FavoriteItem.fromRow(comic));
|
||||||
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
|
|
||||||
}
|
}
|
||||||
if (comics.length > 200) {
|
if (comics.length > 200) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool test(FavoriteItemWithFolderInfo comic, String keyword) {
|
bool test(FavoriteItem comic, String keyword) {
|
||||||
|
keyword = keyword.trim();
|
||||||
|
if (keyword.isEmpty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (comic.name.contains(keyword)) {
|
if (comic.name.contains(keyword)) {
|
||||||
return true;
|
return true;
|
||||||
} else if (comic.author.contains(keyword)) {
|
} else if (comic.author.contains(keyword)) {
|
||||||
@@ -766,12 +1010,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var i = 1; i < keywordList.length; i++) {
|
return comics.where((element) {
|
||||||
comics =
|
for (var i = 1; i < keywordList.length; i++) {
|
||||||
comics.where((element) => test(element, keywordList[i])).toList();
|
if (!test(element, keywordList[i])) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
return comics;
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
void editTags(String id, String folder, List<String> tags) {
|
void editTags(String id, String folder, List<String> tags) {
|
||||||
|
@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart' show ChangeNotifier;
|
|||||||
import 'package:sqlite3/sqlite3.dart';
|
import 'package:sqlite3/sqlite3.dart';
|
||||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||||
import 'package:venera/foundation/comic_type.dart';
|
import 'package:venera/foundation/comic_type.dart';
|
||||||
|
import 'package:venera/foundation/favorites.dart';
|
||||||
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
|
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
|
||||||
import 'package:venera/foundation/log.dart';
|
import 'package:venera/foundation/log.dart';
|
||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/utils/ext.dart';
|
||||||
@@ -132,6 +133,11 @@ class History implements Comic {
|
|||||||
@override
|
@override
|
||||||
String get description {
|
String get description {
|
||||||
var res = "";
|
var res = "";
|
||||||
|
if (group != null){
|
||||||
|
res += "${"Group @group".tlParams({
|
||||||
|
"group": group!,
|
||||||
|
})} - ";
|
||||||
|
}
|
||||||
if (ep >= 1) {
|
if (ep >= 1) {
|
||||||
res += "Chapter @ep".tlParams({
|
res += "Chapter @ep".tlParams({
|
||||||
"ep": ep,
|
"ep": ep,
|
||||||
@@ -305,6 +311,31 @@ class HistoryManager with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clearUnfavoritedHistory() {
|
||||||
|
_db.execute('BEGIN TRANSACTION;');
|
||||||
|
try {
|
||||||
|
final idAndTypes = _db.select("""
|
||||||
|
select id, type from history;
|
||||||
|
""");
|
||||||
|
for (var element in idAndTypes) {
|
||||||
|
final id = element["id"] as String;
|
||||||
|
final type = ComicType(element["type"] as int);
|
||||||
|
if (!LocalFavoritesManager().isExist(id, type)) {
|
||||||
|
_db.execute("""
|
||||||
|
delete from history
|
||||||
|
where id == ? and type == ?;
|
||||||
|
""", [id, type.value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_db.execute('COMMIT;');
|
||||||
|
} catch (e) {
|
||||||
|
_db.execute('ROLLBACK;');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
updateCache();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void remove(String id, ComicType type) async {
|
void remove(String id, ComicType type) async {
|
||||||
_db.execute("""
|
_db.execute("""
|
||||||
delete from history
|
delete from history
|
||||||
@@ -380,4 +411,23 @@ class HistoryManager with ChangeNotifier {
|
|||||||
isInitialized = false;
|
isInitialized = false;
|
||||||
_db.dispose();
|
_db.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void batchDeleteHistories(List<ComicID> histories) {
|
||||||
|
if (histories.isEmpty) return;
|
||||||
|
_db.execute('BEGIN TRANSACTION;');
|
||||||
|
try {
|
||||||
|
for (var history in histories) {
|
||||||
|
_db.execute("""
|
||||||
|
delete from history
|
||||||
|
where id == ? and type == ?;
|
||||||
|
""", [history.id, history.type.value]);
|
||||||
|
}
|
||||||
|
_db.execute('COMMIT;');
|
||||||
|
} catch (e) {
|
||||||
|
_db.execute('ROLLBACK;');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
updateCache();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:dio/io.dart';
|
import 'package:dio/io.dart';
|
||||||
|
import 'package:enough_convert/enough_convert.dart';
|
||||||
import 'package:flutter/foundation.dart' show protected;
|
import 'package:flutter/foundation.dart' show protected;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:html/parser.dart' as html;
|
import 'package:html/parser.dart' as html;
|
||||||
@@ -25,6 +26,7 @@ import 'package:venera/components/js_ui.dart';
|
|||||||
import 'package:venera/foundation/app.dart';
|
import 'package:venera/foundation/app.dart';
|
||||||
import 'package:venera/network/app_dio.dart';
|
import 'package:venera/network/app_dio.dart';
|
||||||
import 'package:venera/network/cookie_jar.dart';
|
import 'package:venera/network/cookie_jar.dart';
|
||||||
|
import 'package:venera/network/proxy.dart';
|
||||||
import 'package:venera/utils/init.dart';
|
import 'package:venera/utils/init.dart';
|
||||||
|
|
||||||
import 'comic_source/comic_source.dart';
|
import 'comic_source/comic_source.dart';
|
||||||
@@ -194,7 +196,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
|
|||||||
responseType: ResponseType.plain,
|
responseType: ResponseType.plain,
|
||||||
validateStatus: (status) => true,
|
validateStatus: (status) => true,
|
||||||
));
|
));
|
||||||
var proxy = await AppDio.getProxy();
|
var proxy = await getProxy();
|
||||||
dio.httpClientAdapter = IOHttpClientAdapter(
|
dio.httpClientAdapter = IOHttpClientAdapter(
|
||||||
createHttpClient: () {
|
createHttpClient: () {
|
||||||
return HttpClient()
|
return HttpClient()
|
||||||
@@ -371,6 +373,11 @@ mixin class _JSEngineApi {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "utf8":
|
case "utf8":
|
||||||
return isEncode ? utf8.encode(value) : utf8.decode(value);
|
return isEncode ? utf8.encode(value) : utf8.decode(value);
|
||||||
|
case "gbk":
|
||||||
|
final codec = const GbkCodec();
|
||||||
|
return isEncode
|
||||||
|
? Uint8List.fromList(codec.encode(value))
|
||||||
|
: codec.decode(value);
|
||||||
case "base64":
|
case "base64":
|
||||||
return isEncode ? base64Encode(value) : base64Decode(value);
|
return isEncode ? base64Encode(value) : base64Decode(value);
|
||||||
case "md5":
|
case "md5":
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||||
|
import 'package:flutter_saf/flutter_saf.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sqlite3/sqlite3.dart';
|
import 'package:sqlite3/sqlite3.dart';
|
||||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||||
@@ -107,15 +109,42 @@ class LocalComic with HistoryMixin implements Comic {
|
|||||||
|
|
||||||
void read() {
|
void read() {
|
||||||
var history = HistoryManager().find(id, comicType);
|
var history = HistoryManager().find(id, comicType);
|
||||||
|
int? firstDownloadedChapter;
|
||||||
|
int? firstDownloadedChapterGroup;
|
||||||
|
if (downloadedChapters.isNotEmpty && chapters != null) {
|
||||||
|
final chapters = this.chapters!;
|
||||||
|
if (chapters.isGrouped) {
|
||||||
|
for (int i=0; i<chapters.groupCount; i++) {
|
||||||
|
var group = chapters.getGroupByIndex(i);
|
||||||
|
var keys = group.keys.toList();
|
||||||
|
for (int j=0; j<keys.length; j++) {
|
||||||
|
var chapterId = keys[j];
|
||||||
|
if (downloadedChapters.contains(chapterId)) {
|
||||||
|
firstDownloadedChapter = j + 1;
|
||||||
|
firstDownloadedChapterGroup = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var keys = chapters.allChapters.keys;
|
||||||
|
for (int i = 0; i < keys.length; i++) {
|
||||||
|
if (downloadedChapters.contains(keys.elementAt(i))) {
|
||||||
|
firstDownloadedChapter = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
App.rootContext.to(
|
App.rootContext.to(
|
||||||
() => Reader(
|
() => Reader(
|
||||||
type: comicType,
|
type: comicType,
|
||||||
cid: id,
|
cid: id,
|
||||||
name: title,
|
name: title,
|
||||||
chapters: chapters,
|
chapters: chapters,
|
||||||
initialChapter: history?.ep,
|
initialChapter: history?.ep ?? firstDownloadedChapter,
|
||||||
initialPage: history?.page,
|
initialPage: history?.page,
|
||||||
initialChapterGroup: history?.group,
|
initialChapterGroup: history?.group ?? firstDownloadedChapterGroup,
|
||||||
history: history ??
|
history: history ??
|
||||||
History.fromModel(
|
History.fromModel(
|
||||||
model: this,
|
model: this,
|
||||||
@@ -461,7 +490,7 @@ class LocalManager with ChangeNotifier {
|
|||||||
if (comic != null) {
|
if (comic != null) {
|
||||||
return Directory(FilePath.join(path, comic.directory));
|
return Directory(FilePath.join(path, comic.directory));
|
||||||
}
|
}
|
||||||
const comicDirectoryMaxLength = 128;
|
const comicDirectoryMaxLength = 80;
|
||||||
if (name.length > comicDirectoryMaxLength) {
|
if (name.length > comicDirectoryMaxLength) {
|
||||||
name = name.substring(0, comicDirectoryMaxLength);
|
name = name.substring(0, comicDirectoryMaxLength);
|
||||||
}
|
}
|
||||||
@@ -546,6 +575,99 @@ class LocalManager with ChangeNotifier {
|
|||||||
remove(c.id, c.comicType);
|
remove(c.id, c.comicType);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void deleteComicChapters(LocalComic c, List<String> chapters) {
|
||||||
|
if (chapters.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var newDownloadedChapters = c.downloadedChapters
|
||||||
|
.where((e) => !chapters.contains(e))
|
||||||
|
.toList();
|
||||||
|
if (newDownloadedChapters.isNotEmpty) {
|
||||||
|
_db.execute(
|
||||||
|
'UPDATE comics SET downloadedChapters = ? WHERE id = ? AND comic_type = ?;',
|
||||||
|
[
|
||||||
|
jsonEncode(newDownloadedChapters),
|
||||||
|
c.id,
|
||||||
|
c.comicType.value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_db.execute(
|
||||||
|
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||||
|
[c.id, c.comicType.value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var shouldRemovedDirs = <Directory>[];
|
||||||
|
for (var chapter in chapters) {
|
||||||
|
var dir = Directory(FilePath.join(c.baseDir, chapter));
|
||||||
|
if (dir.existsSync()) {
|
||||||
|
shouldRemovedDirs.add(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shouldRemovedDirs.isNotEmpty) {
|
||||||
|
_deleteDirectories(shouldRemovedDirs);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void batchDeleteComics(List<LocalComic> comics, [bool removeFileOnDisk = true, bool removeFavoriteAndHistory = true]) {
|
||||||
|
if (comics.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldRemovedDirs = <Directory>[];
|
||||||
|
_db.execute('BEGIN TRANSACTION;');
|
||||||
|
try {
|
||||||
|
for (var c in comics) {
|
||||||
|
if (removeFileOnDisk) {
|
||||||
|
var dir = Directory(FilePath.join(path, c.directory));
|
||||||
|
if (dir.existsSync()) {
|
||||||
|
shouldRemovedDirs.add(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_db.execute(
|
||||||
|
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||||
|
[c.id, c.comicType.value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(e, s) {
|
||||||
|
Log.error("LocalManager", "Failed to batch delete comics: $e", s);
|
||||||
|
_db.execute('ROLLBACK;');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_db.execute('COMMIT;');
|
||||||
|
|
||||||
|
var comicIDs = comics.map((e) => ComicID(e.comicType, e.id)).toList();
|
||||||
|
|
||||||
|
if (removeFavoriteAndHistory) {
|
||||||
|
LocalFavoritesManager().batchDeleteComicsInAllFolders(comicIDs);
|
||||||
|
HistoryManager().batchDeleteHistories(comicIDs);
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
if (removeFileOnDisk) {
|
||||||
|
_deleteDirectories(shouldRemovedDirs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the directories in a separate isolate to avoid blocking the UI thread.
|
||||||
|
static void _deleteDirectories(List<Directory> directories) {
|
||||||
|
Isolate.run(() async {
|
||||||
|
await SAFTaskWorker().init();
|
||||||
|
for (var dir in directories) {
|
||||||
|
try {
|
||||||
|
if (dir.existsSync()) {
|
||||||
|
await dir.delete(recursive: true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LocalSortType {
|
enum LocalSortType {
|
||||||
|
@@ -1,4 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:display_mode/display_mode.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_saf/flutter_saf.dart';
|
import 'package:flutter_saf/flutter_saf.dart';
|
||||||
import 'package:rhttp/rhttp.dart';
|
import 'package:rhttp/rhttp.dart';
|
||||||
import 'package:venera/foundation/app.dart';
|
import 'package:venera/foundation/app.dart';
|
||||||
@@ -12,6 +16,7 @@ import 'package:venera/pages/follow_updates_page.dart';
|
|||||||
import 'package:venera/pages/settings/settings_page.dart';
|
import 'package:venera/pages/settings/settings_page.dart';
|
||||||
import 'package:venera/utils/app_links.dart';
|
import 'package:venera/utils/app_links.dart';
|
||||||
import 'package:venera/utils/handle_text_share.dart';
|
import 'package:venera/utils/handle_text_share.dart';
|
||||||
|
import 'package:venera/utils/opencc.dart';
|
||||||
import 'package:venera/utils/tags_translation.dart';
|
import 'package:venera/utils/tags_translation.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
import 'foundation/appdata.dart';
|
import 'foundation/appdata.dart';
|
||||||
@@ -40,6 +45,7 @@ Future<void> init() async {
|
|||||||
TagsTranslation.readData().wait(),
|
TagsTranslation.readData().wait(),
|
||||||
JsEngine().init().wait(),
|
JsEngine().init().wait(),
|
||||||
ComicSourceManager().init().wait(),
|
ComicSourceManager().init().wait(),
|
||||||
|
OpenCC.init(),
|
||||||
];
|
];
|
||||||
await Future.wait(futures);
|
await Future.wait(futures);
|
||||||
CacheManager().setLimitSize(appdata.settings['cacheSize']);
|
CacheManager().setLimitSize(appdata.settings['cacheSize']);
|
||||||
@@ -47,10 +53,23 @@ Future<void> init() async {
|
|||||||
if (App.isAndroid) {
|
if (App.isAndroid) {
|
||||||
handleLinks();
|
handleLinks();
|
||||||
handleTextShare();
|
handleTextShare();
|
||||||
|
try {
|
||||||
|
await FlutterDisplayMode.setHighRefreshRate();
|
||||||
|
} catch(e) {
|
||||||
|
Log.error("Display Mode", "Failed to set high refresh rate: $e");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
|
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||||
};
|
};
|
||||||
|
if (App.isWindows) {
|
||||||
|
// Report to the monitor thread that the app is running
|
||||||
|
// https://github.com/venera-app/venera/issues/343
|
||||||
|
Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
const methodChannel = MethodChannel('venera/method_channel');
|
||||||
|
methodChannel.invokeMethod("heartBeat");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _checkOldConfigs() {
|
void _checkOldConfigs() {
|
||||||
@@ -84,8 +103,7 @@ Future<void> _checkAppUpdates() async {
|
|||||||
appdata.writeImplicitData();
|
appdata.writeImplicitData();
|
||||||
ComicSourcePage.checkComicSourceUpdate();
|
ComicSourcePage.checkComicSourceUpdate();
|
||||||
if (appdata.settings['checkUpdateOnStart']) {
|
if (appdata.settings['checkUpdateOnStart']) {
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
await checkUpdateUi(false, true);
|
||||||
await checkUpdateUi(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -237,6 +237,27 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
if (widget != null) {
|
if (widget != null) {
|
||||||
|
/// 如果无法检测到状态栏高度设定指定高度
|
||||||
|
/// https://github.com/flutter/flutter/issues/161086
|
||||||
|
var isPaddingCheckError =
|
||||||
|
MediaQuery.of(context).viewPadding.top <= 0 ||
|
||||||
|
MediaQuery.of(context).viewPadding.top > 50;
|
||||||
|
|
||||||
|
if (isPaddingCheckError) {
|
||||||
|
widget = MediaQuery(
|
||||||
|
data: MediaQuery.of(context).copyWith(
|
||||||
|
viewPadding: const EdgeInsets.only(
|
||||||
|
top: 15,
|
||||||
|
bottom: 15,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 15,
|
||||||
|
bottom: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: widget);
|
||||||
|
}
|
||||||
|
|
||||||
widget = OverlayWidget(widget);
|
widget = OverlayWidget(widget);
|
||||||
if (App.isDesktop) {
|
if (App.isDesktop) {
|
||||||
widget = Shortcuts(
|
widget = Shortcuts(
|
||||||
|
@@ -7,7 +7,7 @@ import 'package:rhttp/rhttp.dart' as rhttp;
|
|||||||
import 'package:venera/foundation/appdata.dart';
|
import 'package:venera/foundation/appdata.dart';
|
||||||
import 'package:venera/foundation/log.dart';
|
import 'package:venera/foundation/log.dart';
|
||||||
import 'package:venera/network/cache.dart';
|
import 'package:venera/network/cache.dart';
|
||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/network/proxy.dart';
|
||||||
|
|
||||||
import '../foundation/app.dart';
|
import '../foundation/app.dart';
|
||||||
import 'cloudflare.dart';
|
import 'cloudflare.dart';
|
||||||
@@ -96,9 +96,11 @@ class MyLogInterceptor implements Interceptor {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
Log.info("Network", "${options.method} ${options.uri}\n"
|
Log.info(
|
||||||
"headers:\n${options.headers}\n"
|
"Network",
|
||||||
"data:\n${options.data}");
|
"${options.method} ${options.uri}\n"
|
||||||
|
"headers:\n${options.headers}\n"
|
||||||
|
"data:\n${options.data}");
|
||||||
options.connectTimeout = const Duration(seconds: 15);
|
options.connectTimeout = const Duration(seconds: 15);
|
||||||
options.receiveTimeout = const Duration(seconds: 15);
|
options.receiveTimeout = const Duration(seconds: 15);
|
||||||
options.sendTimeout = const Duration(seconds: 15);
|
options.sendTimeout = const Duration(seconds: 15);
|
||||||
@@ -107,64 +109,15 @@ class MyLogInterceptor implements Interceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AppDio with DioMixin {
|
class AppDio with DioMixin {
|
||||||
String? _proxy = proxy;
|
|
||||||
|
|
||||||
AppDio([BaseOptions? options]) {
|
AppDio([BaseOptions? options]) {
|
||||||
this.options = options ?? BaseOptions();
|
this.options = options ?? BaseOptions();
|
||||||
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
|
httpClientAdapter = RHttpAdapter();
|
||||||
proxySettings: proxy == null
|
|
||||||
? const rhttp.ProxySettings.noProxy()
|
|
||||||
: rhttp.ProxySettings.proxy(proxy!),
|
|
||||||
));
|
|
||||||
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
|
||||||
interceptors.add(NetworkCacheManager());
|
interceptors.add(NetworkCacheManager());
|
||||||
interceptors.add(CloudflareInterceptor());
|
interceptors.add(CloudflareInterceptor());
|
||||||
interceptors.add(MyLogInterceptor());
|
interceptors.add(MyLogInterceptor());
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? proxy;
|
|
||||||
|
|
||||||
static Future<String?> getProxy() async {
|
|
||||||
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
|
|
||||||
|
|
||||||
String res;
|
|
||||||
if (!App.isLinux) {
|
|
||||||
const channel = MethodChannel("venera/method_channel");
|
|
||||||
try {
|
|
||||||
res = await channel.invokeMethod("getProxy");
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res = "No Proxy";
|
|
||||||
}
|
|
||||||
if (res == "No Proxy") return null;
|
|
||||||
|
|
||||||
if (res.contains(";")) {
|
|
||||||
var proxies = res.split(";");
|
|
||||||
for (String proxy in proxies) {
|
|
||||||
proxy = proxy.removeAllBlank;
|
|
||||||
if (proxy.startsWith('https=')) {
|
|
||||||
return proxy.substring(6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final RegExp regex = RegExp(
|
|
||||||
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
|
|
||||||
caseSensitive: false,
|
|
||||||
multiLine: false,
|
|
||||||
);
|
|
||||||
if (!regex.hasMatch(res)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
static final Map<String, bool> _requests = {};
|
static final Map<String, bool> _requests = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -184,16 +137,6 @@ class AppDio with DioMixin {
|
|||||||
_requests[path] = true;
|
_requests[path] = true;
|
||||||
options!.headers!.remove('prevent-parallel');
|
options!.headers!.remove('prevent-parallel');
|
||||||
}
|
}
|
||||||
proxy = await getProxy();
|
|
||||||
if (_proxy != proxy) {
|
|
||||||
Log.info("Network", "Proxy changed to $proxy");
|
|
||||||
_proxy = proxy;
|
|
||||||
httpClientAdapter = RHttpAdapter(rhttp.ClientSettings(
|
|
||||||
proxySettings: proxy == null
|
|
||||||
? const rhttp.ProxySettings.noProxy()
|
|
||||||
: rhttp.ProxySettings.proxy(proxy!),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return super.request<T>(
|
return super.request<T>(
|
||||||
path,
|
path,
|
||||||
@@ -213,7 +156,26 @@ class AppDio with DioMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RHttpAdapter implements HttpClientAdapter {
|
class RHttpAdapter implements HttpClientAdapter {
|
||||||
rhttp.ClientSettings settings;
|
Future<rhttp.ClientSettings> get settings async {
|
||||||
|
var proxy = await getProxy();
|
||||||
|
|
||||||
|
return rhttp.ClientSettings(
|
||||||
|
proxySettings: proxy == null
|
||||||
|
? const rhttp.ProxySettings.noProxy()
|
||||||
|
: rhttp.ProxySettings.proxy(proxy),
|
||||||
|
redirectSettings: const rhttp.RedirectSettings.limited(5),
|
||||||
|
timeoutSettings: const rhttp.TimeoutSettings(
|
||||||
|
connectTimeout: Duration(seconds: 15),
|
||||||
|
keepAliveTimeout: Duration(seconds: 60),
|
||||||
|
keepAlivePing: Duration(seconds: 30),
|
||||||
|
),
|
||||||
|
throwOnStatusCode: false,
|
||||||
|
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
|
||||||
|
tlsSettings: rhttp.TlsSettings(
|
||||||
|
sni: appdata.settings['sni'] != false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Map<String, List<String>> _getOverrides() {
|
static Map<String, List<String>> _getOverrides() {
|
||||||
if (!appdata.settings['enableDnsOverrides'] == true) {
|
if (!appdata.settings['enableDnsOverrides'] == true) {
|
||||||
@@ -231,22 +193,6 @@ class RHttpAdapter implements HttpClientAdapter {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
|
|
||||||
settings = settings.copyWith(
|
|
||||||
redirectSettings: const rhttp.RedirectSettings.limited(5),
|
|
||||||
timeoutSettings: const rhttp.TimeoutSettings(
|
|
||||||
connectTimeout: Duration(seconds: 15),
|
|
||||||
keepAliveTimeout: Duration(seconds: 60),
|
|
||||||
keepAlivePing: Duration(seconds: 30),
|
|
||||||
),
|
|
||||||
throwOnStatusCode: false,
|
|
||||||
dnsSettings: rhttp.DnsSettings.static(overrides: _getOverrides()),
|
|
||||||
tlsSettings: rhttp.TlsSettings(
|
|
||||||
sni: appdata.settings['sni'] != false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void close({bool force = false}) {}
|
void close({bool force = false}) {}
|
||||||
|
|
||||||
@@ -256,10 +202,15 @@ class RHttpAdapter implements HttpClientAdapter {
|
|||||||
Stream<Uint8List>? requestStream,
|
Stream<Uint8List>? requestStream,
|
||||||
Future<void>? cancelFuture,
|
Future<void>? cancelFuture,
|
||||||
) async {
|
) async {
|
||||||
|
if (options.headers['User-Agent'] == null &&
|
||||||
|
options.headers['user-agent'] == null) {
|
||||||
|
options.headers['User-Agent'] = "venera/v${App.version}";
|
||||||
|
}
|
||||||
|
|
||||||
var res = await rhttp.Rhttp.request(
|
var res = await rhttp.Rhttp.request(
|
||||||
method: rhttp.HttpMethod(options.method),
|
method: rhttp.HttpMethod(options.method),
|
||||||
url: options.uri.toString(),
|
url: options.uri.toString(),
|
||||||
settings: settings,
|
settings: await settings,
|
||||||
expectBody: rhttp.HttpExpectBody.stream,
|
expectBody: rhttp.HttpExpectBody.stream,
|
||||||
body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream),
|
body: requestStream == null ? null : rhttp.HttpBody.stream(requestStream),
|
||||||
headers: rhttp.HttpHeaders.rawMap(
|
headers: rhttp.HttpHeaders.rawMap(
|
||||||
@@ -289,7 +240,7 @@ class RHttpAdapter implements HttpClientAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String _getStatusMessage(int statusCode) {
|
static String _getStatusMessage(int statusCode) {
|
||||||
return switch(statusCode) {
|
return switch (statusCode) {
|
||||||
200 => "OK",
|
200 => "OK",
|
||||||
201 => "Created",
|
201 => "Created",
|
||||||
202 => "Accepted",
|
202 => "Accepted",
|
||||||
@@ -299,9 +250,11 @@ class RHttpAdapter implements HttpClientAdapter {
|
|||||||
302 => "Found",
|
302 => "Found",
|
||||||
400 => "Invalid Status Code 400: The Request is invalid.",
|
400 => "Invalid Status Code 400: The Request is invalid.",
|
||||||
401 => "Invalid Status Code 401: The Request is unauthorized.",
|
401 => "Invalid Status Code 401: The Request is unauthorized.",
|
||||||
403 => "Invalid Status Code 403: No permission to access the resource. Check your account or network.",
|
403 =>
|
||||||
|
"Invalid Status Code 403: No permission to access the resource. Check your account or network.",
|
||||||
404 => "Invalid Status Code 404: Not found.",
|
404 => "Invalid Status Code 404: Not found.",
|
||||||
429 => "Invalid Status Code 429: Too many requests. Please try again later.",
|
429 =>
|
||||||
|
"Invalid Status Code 429: Too many requests. Please try again later.",
|
||||||
_ => "Invalid Status Code $statusCode",
|
_ => "Invalid Status Code $statusCode",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -552,7 +552,7 @@ class _ImageDownloadWrapper {
|
|||||||
void start() async {
|
void start() async {
|
||||||
int lastBytes = 0;
|
int lastBytes = 0;
|
||||||
try {
|
try {
|
||||||
await for (var p in ImageDownloader.loadComicImage(
|
await for (var p in ImageDownloader.loadComicImageUnwrapped(
|
||||||
image, task.source.key, task.comicId, chapter)) {
|
image, task.source.key, task.comicId, chapter)) {
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
return;
|
return;
|
||||||
|
@@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:dio/io.dart';
|
import 'package:dio/io.dart';
|
||||||
import 'package:venera/network/app_dio.dart';
|
import 'package:venera/network/app_dio.dart';
|
||||||
|
import 'package:venera/network/proxy.dart';
|
||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/utils/ext.dart';
|
||||||
|
|
||||||
class FileDownloader {
|
class FileDownloader {
|
||||||
@@ -105,7 +106,7 @@ class FileDownloader {
|
|||||||
|
|
||||||
void _download(StreamController<DownloadingStatus> resultStream) async {
|
void _download(StreamController<DownloadingStatus> resultStream) async {
|
||||||
try {
|
try {
|
||||||
var proxy = await AppDio.getProxy();
|
var proxy = await getProxy();
|
||||||
_dio.httpClientAdapter = IOHttpClientAdapter(
|
_dio.httpClientAdapter = IOHttpClientAdapter(
|
||||||
createHttpClient: () {
|
createHttpClient: () {
|
||||||
return HttpClient()
|
return HttpClient()
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||||
@@ -8,7 +9,7 @@ import 'package:venera/utils/image.dart';
|
|||||||
|
|
||||||
import 'app_dio.dart';
|
import 'app_dio.dart';
|
||||||
|
|
||||||
class ImageDownloader {
|
abstract class ImageDownloader {
|
||||||
static Stream<ImageDownloadProgress> loadThumbnail(
|
static Stream<ImageDownloadProgress> loadThumbnail(
|
||||||
String url, String? sourceKey,
|
String url, String? sourceKey,
|
||||||
[String? cid]) async* {
|
[String? cid]) async* {
|
||||||
@@ -82,7 +83,40 @@ class ImageDownloader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static final _loadingImages = <String, _StreamWrapper<ImageDownloadProgress>>{};
|
||||||
|
|
||||||
|
/// Cancel all loading images.
|
||||||
|
static void cancelAllLoadingImages() {
|
||||||
|
for (var wrapper in _loadingImages.values) {
|
||||||
|
wrapper.cancel();
|
||||||
|
}
|
||||||
|
_loadingImages.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a comic image from the network or cache.
|
||||||
|
/// The function will prevent multiple requests for the same image.
|
||||||
static Stream<ImageDownloadProgress> loadComicImage(
|
static Stream<ImageDownloadProgress> loadComicImage(
|
||||||
|
String imageKey, String? sourceKey, String cid, String eid) {
|
||||||
|
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
||||||
|
if (_loadingImages.containsKey(cacheKey)) {
|
||||||
|
return _loadingImages[cacheKey]!.stream;
|
||||||
|
}
|
||||||
|
final stream = _StreamWrapper<ImageDownloadProgress>(
|
||||||
|
_loadComicImage(imageKey, sourceKey, cid, eid),
|
||||||
|
(wrapper) {
|
||||||
|
_loadingImages.remove(cacheKey);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_loadingImages[cacheKey] = stream;
|
||||||
|
return stream.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<ImageDownloadProgress> loadComicImageUnwrapped(
|
||||||
|
String imageKey, String? sourceKey, String cid, String eid) {
|
||||||
|
return _loadComicImage(imageKey, sourceKey, cid, eid);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<ImageDownloadProgress> _loadComicImage(
|
||||||
String imageKey, String? sourceKey, String cid, String eid) async* {
|
String imageKey, String? sourceKey, String cid, String eid) async* {
|
||||||
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
||||||
final cache = await CacheManager().findCache(cacheKey);
|
final cache = await CacheManager().findCache(cacheKey);
|
||||||
@@ -189,6 +223,74 @@ class ImageDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A wrapper class for a stream that
|
||||||
|
/// allows multiple listeners to listen to the same stream.
|
||||||
|
class _StreamWrapper<T> {
|
||||||
|
final Stream<T> _stream;
|
||||||
|
|
||||||
|
final List<StreamController> controllers = [];
|
||||||
|
|
||||||
|
final void Function(_StreamWrapper<T> wrapper) onClosed;
|
||||||
|
|
||||||
|
bool isClosed = false;
|
||||||
|
|
||||||
|
_StreamWrapper(this._stream, this.onClosed) {
|
||||||
|
_listen();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _listen() async {
|
||||||
|
try {
|
||||||
|
await for (var data in _stream) {
|
||||||
|
if (isClosed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (var controller in controllers) {
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
controller.add(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
for (var controller in controllers) {
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
controller.addError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
for (var controller in controllers) {
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controllers.clear();
|
||||||
|
isClosed = true;
|
||||||
|
onClosed(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<T> get stream {
|
||||||
|
if (isClosed) {
|
||||||
|
throw Exception('Stream is closed');
|
||||||
|
}
|
||||||
|
var controller = StreamController<T>();
|
||||||
|
controllers.add(controller);
|
||||||
|
controller.onCancel = () {
|
||||||
|
controllers.remove(controller);
|
||||||
|
};
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
for (var controller in controllers) {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
controllers.clear();
|
||||||
|
isClosed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ImageDownloadProgress {
|
class ImageDownloadProgress {
|
||||||
final int currentBytes;
|
final int currentBytes;
|
||||||
|
|
||||||
|
60
lib/network/proxy.dart
Normal file
60
lib/network/proxy.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:venera/foundation/app.dart';
|
||||||
|
import 'package:venera/foundation/appdata.dart';
|
||||||
|
import 'package:venera/utils/ext.dart';
|
||||||
|
|
||||||
|
String? _cachedProxy;
|
||||||
|
|
||||||
|
DateTime? _cachedProxyTime;
|
||||||
|
|
||||||
|
Future<String?> getProxy() async {
|
||||||
|
if (_cachedProxyTime != null &&
|
||||||
|
DateTime.now().difference(_cachedProxyTime!).inSeconds < 1) {
|
||||||
|
return _cachedProxy;
|
||||||
|
}
|
||||||
|
String? proxy = await _getProxy();
|
||||||
|
_cachedProxy = proxy;
|
||||||
|
_cachedProxyTime = DateTime.now();
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getProxy() async {
|
||||||
|
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
|
||||||
|
|
||||||
|
String res;
|
||||||
|
if (!App.isLinux) {
|
||||||
|
const channel = MethodChannel("venera/method_channel");
|
||||||
|
try {
|
||||||
|
res = await channel.invokeMethod("getProxy");
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res = "No Proxy";
|
||||||
|
}
|
||||||
|
if (res == "No Proxy") return null;
|
||||||
|
|
||||||
|
if (res.contains(";")) {
|
||||||
|
var proxies = res.split(";");
|
||||||
|
for (String proxy in proxies) {
|
||||||
|
proxy = proxy.removeAllBlank;
|
||||||
|
if (proxy.startsWith('https=')) {
|
||||||
|
return proxy.substring(6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final RegExp regex = RegExp(
|
||||||
|
r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$',
|
||||||
|
caseSensitive: false,
|
||||||
|
multiLine: false,
|
||||||
|
);
|
||||||
|
if (!regex.hasMatch(res)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
@@ -27,7 +27,7 @@ class _NormalComicChapters extends StatefulWidget {
|
|||||||
class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
||||||
late _ComicPageState state;
|
late _ComicPageState state;
|
||||||
|
|
||||||
bool reverse = false;
|
late bool reverse;
|
||||||
|
|
||||||
bool showAll = false;
|
bool showAll = false;
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
reverse = appdata.settings["reverseChapterOrder"] ?? false;
|
||||||
history = widget.history;
|
history = widget.history;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +177,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late _ComicPageState state;
|
late _ComicPageState state;
|
||||||
|
|
||||||
bool reverse = false;
|
late bool reverse;
|
||||||
|
|
||||||
bool showAll = false;
|
bool showAll = false;
|
||||||
|
|
||||||
@@ -191,6 +192,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
reverse = appdata.settings["reverseChapterOrder"] ?? false;
|
||||||
history = widget.history;
|
history = widget.history;
|
||||||
if (history?.group != null) {
|
if (history?.group != null) {
|
||||||
index = history!.group! - 1;
|
index = history!.group! - 1;
|
||||||
|
@@ -410,20 +410,26 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
String text;
|
String text;
|
||||||
if (haveChapter) {
|
if (haveChapter) {
|
||||||
var epName = "E$ep";
|
var epName = "E$ep";
|
||||||
|
String? groupName;
|
||||||
try {
|
try {
|
||||||
epName = group == null
|
if (group == null){
|
||||||
? comic.chapters!.titles.elementAt(
|
epName = comic.chapters!.titles.elementAt(
|
||||||
math.min(ep - 1, comic.chapters!.length - 1),
|
math.min(ep - 1, comic.chapters!.length - 1),
|
||||||
)
|
);
|
||||||
: comic.chapters!
|
} else {
|
||||||
.getGroupByIndex(group - 1)
|
groupName = comic.chapters!.groups.elementAt(group - 1);
|
||||||
.values
|
epName = comic.chapters!
|
||||||
.elementAt(ep - 1);
|
.getGroupByIndex(group - 1)
|
||||||
|
.values
|
||||||
|
.elementAt(ep - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch(e) {
|
catch(e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
text = "${"Last Reading".tl}: $epName P$page";
|
text = groupName == null
|
||||||
|
? "${"Last Reading".tl}: $epName P$page"
|
||||||
|
: "${"Last Reading".tl}: $groupName $epName P$page";
|
||||||
} else {
|
} else {
|
||||||
text = "${"Last Reading".tl}: P$page";
|
text = "${"Last Reading".tl}: P$page";
|
||||||
}
|
}
|
||||||
|
@@ -51,9 +51,7 @@ class ComicSourcePage extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(body: const _Body());
|
||||||
body: const _Body(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +85,7 @@ class _BodyState extends State<_Body> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SmoothCustomScrollView(
|
return SmoothCustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppbar(
|
SliverAppbar(title: Text('Comic Source'.tl), style: AppbarStyle.shadow),
|
||||||
title: Text('Comic Source'.tl),
|
|
||||||
style: AppbarStyle.shadow,
|
|
||||||
),
|
|
||||||
buildCard(context),
|
buildCard(context),
|
||||||
for (var source in ComicSource.all())
|
for (var source in ComicSource.all())
|
||||||
_SliverComicSource(
|
_SliverComicSource(
|
||||||
@@ -109,9 +104,7 @@ class _BodyState extends State<_Body> {
|
|||||||
showConfirmDialog(
|
showConfirmDialog(
|
||||||
context: App.rootContext,
|
context: App.rootContext,
|
||||||
title: "Delete".tl,
|
title: "Delete".tl,
|
||||||
content: "Delete comic source '@n' ?".tlParams({
|
content: "Delete comic source '@n' ?".tlParams({"n": source.name}),
|
||||||
"n": source.name,
|
|
||||||
}),
|
|
||||||
btnColor: context.colorScheme.error,
|
btnColor: context.colorScheme.error,
|
||||||
onConfirm: () {
|
onConfirm: () {
|
||||||
var file = File(source.filePath);
|
var file = File(source.filePath);
|
||||||
@@ -133,14 +126,16 @@ class _BodyState extends State<_Body> {
|
|||||||
title: const Text("Reload Configs"),
|
title: const Text("Reload Configs"),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text("cancel")),
|
child: const Text("cancel"),
|
||||||
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await ComicSourceManager().reload();
|
await ComicSourceManager().reload();
|
||||||
App.forceRebuild();
|
App.forceRebuild();
|
||||||
},
|
},
|
||||||
child: const Text("continue")),
|
child: const Text("continue"),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -157,8 +152,10 @@ class _BodyState extends State<_Body> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> update(ComicSource source,
|
static Future<void> update(
|
||||||
[bool showLoading = true]) async {
|
ComicSource source, [
|
||||||
|
bool showLoading = true,
|
||||||
|
]) async {
|
||||||
if (!source.url.isURL) {
|
if (!source.url.isURL) {
|
||||||
App.rootContext.showMessage(message: "Invalid url config");
|
App.rootContext.showMessage(message: "Invalid url config");
|
||||||
return;
|
return;
|
||||||
@@ -174,8 +171,10 @@ class _BodyState extends State<_Body> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
var res = await AppDio().get<String>(source.url,
|
var res = await AppDio().get<String>(
|
||||||
options: Options(responseType: ResponseType.plain));
|
source.url,
|
||||||
|
options: Options(responseType: ResponseType.plain),
|
||||||
|
);
|
||||||
if (cancel) return;
|
if (cancel) return;
|
||||||
controller?.close();
|
controller?.close();
|
||||||
await ComicSourceParser().parse(res.data!, source.filePath);
|
await ComicSourceParser().parse(res.data!, source.filePath);
|
||||||
@@ -192,14 +191,6 @@ class _BodyState extends State<_Body> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget buildCard(BuildContext context) {
|
Widget buildCard(BuildContext context) {
|
||||||
Widget buildButton(
|
|
||||||
{required Widget child, required VoidCallback onPressed}) {
|
|
||||||
return Button.normal(
|
|
||||||
onPressed: onPressed,
|
|
||||||
child: child,
|
|
||||||
).fixHeight(32);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -213,47 +204,46 @@ class _BodyState extends State<_Body> {
|
|||||||
),
|
),
|
||||||
TextField(
|
TextField(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: "URL",
|
hintText: "URL",
|
||||||
border: const UnderlineInputBorder(),
|
border: const UnderlineInputBorder(),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
suffix: IconButton(
|
suffix: IconButton(
|
||||||
onPressed: () => handleAddSource(url),
|
onPressed: () => handleAddSource(url),
|
||||||
icon: const Icon(Icons.check))),
|
icon: const Icon(Icons.check),
|
||||||
|
),
|
||||||
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
url = value;
|
url = value;
|
||||||
},
|
},
|
||||||
onSubmitted: handleAddSource,
|
onSubmitted: handleAddSource,
|
||||||
).paddingHorizontal(16).paddingBottom(8),
|
).paddingHorizontal(16).paddingBottom(8),
|
||||||
ListTile(
|
Wrap(
|
||||||
title: Text("Comic Source list".tl),
|
spacing: 8,
|
||||||
trailing: buildButton(
|
runSpacing: 8,
|
||||||
child: Text("View".tl),
|
children: [
|
||||||
onPressed: () {
|
FilledButton.tonalIcon(
|
||||||
showPopUpWidget(
|
icon: Icon(Icons.article_outlined),
|
||||||
App.rootContext,
|
label: Text("Comic Source list".tl),
|
||||||
_ComicSourceList(handleAddSource),
|
onPressed: () {
|
||||||
);
|
showPopUpWidget(
|
||||||
},
|
App.rootContext,
|
||||||
),
|
_ComicSourceList(handleAddSource),
|
||||||
),
|
);
|
||||||
ListTile(
|
},
|
||||||
title: Text("Use a config file".tl),
|
),
|
||||||
trailing: buildButton(
|
FilledButton.tonalIcon(
|
||||||
onPressed: _selectFile,
|
icon: Icon(Icons.file_open_outlined),
|
||||||
child: Text("Select".tl),
|
label: Text("Use a config file".tl),
|
||||||
),
|
onPressed: _selectFile,
|
||||||
),
|
),
|
||||||
ListTile(
|
FilledButton.tonalIcon(
|
||||||
title: Text("Help".tl),
|
icon: Icon(Icons.help_outline),
|
||||||
trailing: buildButton(
|
label: Text("Help".tl),
|
||||||
onPressed: help,
|
onPressed: help,
|
||||||
child: Text("Open".tl),
|
),
|
||||||
),
|
_CheckUpdatesButton(),
|
||||||
),
|
],
|
||||||
ListTile(
|
).paddingHorizontal(12).paddingVertical(8),
|
||||||
title: Text("Check updates".tl),
|
|
||||||
trailing: _CheckUpdatesButton(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -277,7 +267,8 @@ class _BodyState extends State<_Body> {
|
|||||||
|
|
||||||
void help() {
|
void help() {
|
||||||
launchUrlString(
|
launchUrlString(
|
||||||
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
|
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> handleAddSource(String url) async {
|
Future<void> handleAddSource(String url) async {
|
||||||
@@ -288,11 +279,16 @@ class _BodyState extends State<_Body> {
|
|||||||
splits.removeWhere((element) => element == "");
|
splits.removeWhere((element) => element == "");
|
||||||
var fileName = splits.last;
|
var fileName = splits.last;
|
||||||
bool cancel = false;
|
bool cancel = false;
|
||||||
var controller = showLoadingDialog(App.rootContext,
|
var controller = showLoadingDialog(
|
||||||
onCancel: () => cancel = true, barrierDismissible: false);
|
App.rootContext,
|
||||||
|
onCancel: () => cancel = true,
|
||||||
|
barrierDismissible: false,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
var res = await AppDio()
|
var res = await AppDio().get<String>(
|
||||||
.get<String>(url, options: Options(responseType: ResponseType.plain));
|
url,
|
||||||
|
options: Options(responseType: ResponseType.plain),
|
||||||
|
);
|
||||||
if (cancel) return;
|
if (cancel) return;
|
||||||
controller.close();
|
controller.close();
|
||||||
await addSource(res.data!, fileName);
|
await addSource(res.data!, fileName);
|
||||||
@@ -322,127 +318,178 @@ class _ComicSourceList extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ComicSourceListState extends State<_ComicSourceList> {
|
class _ComicSourceListState extends State<_ComicSourceList> {
|
||||||
bool loading = true;
|
|
||||||
List? json;
|
List? json;
|
||||||
|
bool changed = false;
|
||||||
|
var controller = TextEditingController();
|
||||||
|
|
||||||
void load() async {
|
void load() async {
|
||||||
var dio = AppDio();
|
if (json != null) {
|
||||||
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
|
setState(() {
|
||||||
if (res.statusCode != 200) {
|
json = null;
|
||||||
context.showMessage(message: "Network error".tl);
|
});
|
||||||
|
}
|
||||||
|
if (controller.text.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
json = [];
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
var dio = AppDio();
|
||||||
json = jsonDecode(res.data!);
|
try {
|
||||||
loading = false;
|
var res = await dio.get<String>(controller.text);
|
||||||
});
|
if (res.statusCode != 200) {
|
||||||
|
throw "error";
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
json = jsonDecode(res.data!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
context.showMessage(message: "Network error".tl);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
json = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
controller.text = appdata.settings['comicSourceListUrl'];
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
if (changed) {
|
||||||
|
appdata.settings['comicSourceListUrl'] = controller.text;
|
||||||
|
appdata.saveData();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PopUpWidgetScaffold(
|
return PopUpWidgetScaffold(title: "Comic Source".tl, body: buildBody());
|
||||||
title: "Comic Source".tl,
|
|
||||||
tailing: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.settings),
|
|
||||||
onPressed: () async {
|
|
||||||
await showInputDialog(
|
|
||||||
context: context,
|
|
||||||
title: "Set comic source list url".tl,
|
|
||||||
initialValue: appdata.settings['comicSourceListUrl'],
|
|
||||||
onConfirm: (value) {
|
|
||||||
appdata.settings['comicSourceListUrl'] = value;
|
|
||||||
appdata.saveData();
|
|
||||||
setState(() {
|
|
||||||
loading = true;
|
|
||||||
json = null;
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
body: buildBody(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildBody() {
|
Widget buildBody() {
|
||||||
if (loading) {
|
var currentKey = ComicSource.all().map((e) => e.key).toList();
|
||||||
load();
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
return ListView.builder(
|
||||||
} else {
|
itemCount: (json?.length ?? 1) + 1,
|
||||||
var currentKey = ComicSource.all().map((e) => e.key).toList();
|
itemBuilder: (context, index) {
|
||||||
return ListView.builder(
|
if (index == 0) {
|
||||||
itemCount: json!.length + 1,
|
return Container(
|
||||||
itemBuilder: (context, index) {
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
if (index == 0) {
|
decoration: BoxDecoration(
|
||||||
return Container(
|
border: Border.all(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
padding: const EdgeInsets.all(8),
|
width: 0.6,
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
color: context.colorScheme.primaryContainer,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(8),
|
||||||
children: [
|
),
|
||||||
const Icon(Icons.info_outline),
|
child: Column(
|
||||||
const SizedBox(width: 8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Expanded(
|
children: [
|
||||||
child: Column(
|
ListTile(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
leading: Icon(Icons.source_outlined),
|
||||||
children: [
|
title: Text("Repo URL".tl),
|
||||||
Text("Do not report any issues related to sources to App repo.".tl),
|
),
|
||||||
Text("Click the setting icon to change the source list url.".tl),
|
TextField(
|
||||||
],
|
controller: controller,
|
||||||
),
|
decoration: InputDecoration(
|
||||||
|
hintText: "URL",
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
),
|
),
|
||||||
],
|
onChanged: (value) {
|
||||||
),
|
changed = true;
|
||||||
);
|
|
||||||
}
|
|
||||||
index--;
|
|
||||||
|
|
||||||
var key = json![index]["key"];
|
|
||||||
var action = currentKey.contains(key)
|
|
||||||
? const Icon(Icons.check, size: 20).paddingRight(8)
|
|
||||||
: Button.filled(
|
|
||||||
child: Text("Add".tl),
|
|
||||||
onPressed: () async {
|
|
||||||
var fileName = json![index]["fileName"];
|
|
||||||
var url = json![index]["url"];
|
|
||||||
if (url == null || !(url.toString()).isURL) {
|
|
||||||
var listUrl =
|
|
||||||
appdata.settings['comicSourceListUrl'] as String;
|
|
||||||
if (listUrl
|
|
||||||
.replaceFirst("https://", "")
|
|
||||||
.replaceFirst("http://", "")
|
|
||||||
.contains("/")) {
|
|
||||||
url =
|
|
||||||
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
|
|
||||||
fileName;
|
|
||||||
} else {
|
|
||||||
url = '$listUrl/$fileName';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await widget.onAdd(url);
|
|
||||||
setState(() {});
|
|
||||||
},
|
},
|
||||||
).fixHeight(32);
|
).paddingHorizontal(16).paddingBottom(8),
|
||||||
|
Text(
|
||||||
var description = json![index]["version"];
|
"The URL should point to a 'index.json' file".tl,
|
||||||
if (json![index]["description"] != null) {
|
).paddingLeft(16),
|
||||||
description = "$description\n${json![index]["description"]}";
|
Text(
|
||||||
}
|
"Do not report any issues related to sources to App repo.".tl,
|
||||||
|
).paddingLeft(16),
|
||||||
return ListTile(
|
const SizedBox(height: 8),
|
||||||
title: Text(json![index]["name"]),
|
Row(
|
||||||
subtitle: Text(description),
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
trailing: action,
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
launchUrlString(
|
||||||
|
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text("Help".tl),
|
||||||
|
),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: load,
|
||||||
|
child: Text("Refresh".tl),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
}
|
if (index == 1 && json == null) {
|
||||||
|
return Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
).fixWidth(24).fixHeight(24),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
index--;
|
||||||
|
|
||||||
|
var key = json![index]["key"];
|
||||||
|
var action = currentKey.contains(key)
|
||||||
|
? const Icon(Icons.check, size: 20).paddingRight(8)
|
||||||
|
: Button.filled(
|
||||||
|
child: Text("Add".tl),
|
||||||
|
onPressed: () async {
|
||||||
|
var fileName = json![index]["fileName"];
|
||||||
|
var url = json![index]["url"];
|
||||||
|
if (url == null || !(url.toString()).isURL) {
|
||||||
|
var listUrl =
|
||||||
|
appdata.settings['comicSourceListUrl'] as String;
|
||||||
|
if (listUrl
|
||||||
|
.replaceFirst("https://", "")
|
||||||
|
.replaceFirst("http://", "")
|
||||||
|
.contains("/")) {
|
||||||
|
url =
|
||||||
|
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
|
||||||
|
fileName;
|
||||||
|
} else {
|
||||||
|
url = '$listUrl/$fileName';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await widget.onAdd(url);
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
).fixHeight(32);
|
||||||
|
|
||||||
|
var description = json![index]["version"];
|
||||||
|
if (json![index]["description"] != null) {
|
||||||
|
description = "$description\n${json![index]["description"]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: Text(json![index]["name"]),
|
||||||
|
subtitle: Text(description),
|
||||||
|
trailing: action,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,8 +557,7 @@ void _addAllPagesWithComicSource(ComicSource source) {
|
|||||||
!networkFavorites.contains(source.favoriteData!.key)) {
|
!networkFavorites.contains(source.favoriteData!.key)) {
|
||||||
networkFavorites.add(source.favoriteData!.key);
|
networkFavorites.add(source.favoriteData!.key);
|
||||||
}
|
}
|
||||||
if (source.searchPageData != null &&
|
if (source.searchPageData != null && !searchPages.contains(source.key)) {
|
||||||
!searchPages.contains(source.key)) {
|
|
||||||
searchPages.add(source.key);
|
searchPages.add(source.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,15 +599,10 @@ class __EditFilePageState extends State<_EditFilePage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: Appbar(
|
appBar: Appbar(title: Text("Edit".tl)),
|
||||||
title: Text("Edit".tl),
|
|
||||||
),
|
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(height: 0.6, color: context.colorScheme.outlineVariant),
|
||||||
height: 0.6,
|
|
||||||
color: context.colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: CodeEditor(
|
child: CodeEditor(
|
||||||
initialValue: current,
|
initialValue: current,
|
||||||
@@ -602,9 +643,11 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void showUpdateDialog() async {
|
void showUpdateDialog() async {
|
||||||
var text = ComicSourceManager().availableUpdates.entries.map((e) {
|
var text = ComicSourceManager().availableUpdates.entries
|
||||||
return "${ComicSource.find(e.key)!.name}: ${e.value}";
|
.map((e) {
|
||||||
}).join("\n");
|
return "${ComicSource.find(e.key)!.name}: ${e.value}";
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
bool doUpdate = false;
|
bool doUpdate = false;
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: App.rootContext,
|
context: App.rootContext,
|
||||||
@@ -649,11 +692,15 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Button.normal(
|
return FilledButton.tonalIcon(
|
||||||
|
icon: isLoading ? SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
) : Icon(Icons.update),
|
||||||
|
label: Text("Check updates".tl),
|
||||||
onPressed: check,
|
onPressed: check,
|
||||||
isLoading: isLoading,
|
);
|
||||||
child: Text("Check".tl),
|
|
||||||
).fixHeight(32);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,10 +789,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(source.name, style: ts.s18),
|
||||||
source.name,
|
|
||||||
style: ts.s18,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -778,7 +822,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
|||||||
style: const TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).paddingLeft(4)
|
).paddingLeft(4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
@@ -823,15 +867,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Column(children: buildSourceSettings().toList()),
|
||||||
children: buildSourceSettings().toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Column(
|
|
||||||
children: _buildAccount().toList(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
SliverToBoxAdapter(child: Column(children: _buildAccount().toList())),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -857,8 +895,10 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
current = item.value['options']
|
current =
|
||||||
.firstWhere((e) => e['value'] == current)['text'] ??
|
item.value['options'].firstWhere(
|
||||||
|
(e) => e['value'] == current,
|
||||||
|
)['text'] ??
|
||||||
current;
|
current;
|
||||||
}
|
}
|
||||||
yield ListTile(
|
yield ListTile(
|
||||||
@@ -866,8 +906,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
|||||||
trailing: Select(
|
trailing: Select(
|
||||||
current: (current as String).ts(source.key),
|
current: (current as String).ts(source.key),
|
||||||
values: (item.value['options'] as List)
|
values: (item.value['options'] as List)
|
||||||
.map<String>((e) =>
|
.map<String>(
|
||||||
((e['text'] ?? e['value']) as String).ts(source.key))
|
(e) => ((e['text'] ?? e['value']) as String).ts(source.key),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onTap: (i) {
|
onTap: (i) {
|
||||||
source.data['settings'][key] =
|
source.data['settings'][key] =
|
||||||
@@ -895,8 +936,11 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
|||||||
source.data['settings'][key] ?? item.value['default'] ?? '';
|
source.data['settings'][key] ?? item.value['default'] ?? '';
|
||||||
yield ListTile(
|
yield ListTile(
|
||||||
title: Text((item.value['title'] as String).ts(source.key)),
|
title: Text((item.value['title'] as String).ts(source.key)),
|
||||||
subtitle:
|
subtitle: Text(
|
||||||
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
|
current,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -937,10 +981,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
|||||||
trailing: const Icon(Icons.arrow_right),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await context.to(
|
await context.to(
|
||||||
() => _LoginPage(
|
() => _LoginPage(config: source.account!, source: source),
|
||||||
config: source.account!,
|
|
||||||
source: source,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
source.saveData();
|
source.saveData();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
@@ -986,9 +1027,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
|
|||||||
trailing: loading
|
trailing: loading
|
||||||
? const SizedBox.square(
|
? const SizedBox.square(
|
||||||
dimension: 24,
|
dimension: 24,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Icon(Icons.refresh),
|
: const Icon(Icons.refresh),
|
||||||
);
|
);
|
||||||
@@ -1029,9 +1068,7 @@ class _LoginPageState extends State<_LoginPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: const Appbar(
|
appBar: const Appbar(title: Text('')),
|
||||||
title: Text(''),
|
|
||||||
),
|
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -1159,8 +1196,9 @@ class _LoginPageState extends State<_LoginPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
loading = true;
|
loading = true;
|
||||||
});
|
});
|
||||||
var cookies =
|
var cookies = widget.config.cookieFields!
|
||||||
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
|
.map((e) => _cookies[e] ?? '')
|
||||||
|
.toList();
|
||||||
widget.config.validateCookies!(cookies).then((value) {
|
widget.config.validateCookies!(cookies).then((value) {
|
||||||
if (value) {
|
if (value) {
|
||||||
widget.source.data['account'] = 'ok';
|
widget.source.data['account'] = 'ok';
|
||||||
|
@@ -133,7 +133,7 @@ void addFavorite(List<Comic> comics) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
|
Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
|
||||||
var comics = LocalFavoritesManager().getAllComics(folder);
|
var comics = LocalFavoritesManager().getFolderComics(folder);
|
||||||
|
|
||||||
Future<void> updateSingleComic(int index) async {
|
Future<void> updateSingleComic(int index) async {
|
||||||
int retry = 3;
|
int retry = 3;
|
||||||
|
@@ -18,14 +18,16 @@ import 'package:venera/network/download.dart';
|
|||||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||||
import 'package:venera/pages/reader/reader.dart';
|
import 'package:venera/pages/reader/reader.dart';
|
||||||
import 'package:venera/pages/settings/settings_page.dart';
|
import 'package:venera/pages/settings/settings_page.dart';
|
||||||
|
import 'package:venera/utils/ext.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
|
import 'package:venera/utils/opencc.dart';
|
||||||
|
import 'package:venera/utils/tags_translation.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
|
|
||||||
part 'favorite_actions.dart';
|
part 'favorite_actions.dart';
|
||||||
part 'side_bar.dart';
|
part 'side_bar.dart';
|
||||||
part 'local_favorites_page.dart';
|
part 'local_favorites_page.dart';
|
||||||
part 'network_favorites_page.dart';
|
part 'network_favorites_page.dart';
|
||||||
part 'local_search_page.dart';
|
|
||||||
|
|
||||||
const _kLeftBarWidth = 256.0;
|
const _kLeftBarWidth = 256.0;
|
||||||
|
|
||||||
@@ -65,6 +67,11 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
|||||||
folder = data['name'];
|
folder = data['name'];
|
||||||
isNetwork = data['isNetwork'] ?? false;
|
isNetwork = data['isNetwork'] ?? false;
|
||||||
}
|
}
|
||||||
|
if (folder != null
|
||||||
|
&& !isNetwork
|
||||||
|
&& !LocalFavoritesManager().existsFolder(folder!)) {
|
||||||
|
folder = null;
|
||||||
|
}
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,5 +1,11 @@
|
|||||||
part of 'favorites_page.dart';
|
part of 'favorites_page.dart';
|
||||||
|
|
||||||
|
const _localAllFolderLabel = '^_^[%local_all%]^_^';
|
||||||
|
|
||||||
|
/// If the number of comics in a folder exceeds this limit, it will be
|
||||||
|
/// fetched asynchronously.
|
||||||
|
const _asyncDataFetchLimit = 500;
|
||||||
|
|
||||||
class _LocalFavoritesPage extends StatefulWidget {
|
class _LocalFavoritesPage extends StatefulWidget {
|
||||||
const _LocalFavoritesPage({required this.folder, super.key});
|
const _LocalFavoritesPage({required this.folder, super.key});
|
||||||
|
|
||||||
@@ -31,25 +37,132 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
|
|
||||||
int? lastSelectedIndex;
|
int? lastSelectedIndex;
|
||||||
|
|
||||||
|
bool get isAllFolder => widget.folder == _localAllFolderLabel;
|
||||||
|
|
||||||
|
LocalFavoritesManager get manager => LocalFavoritesManager();
|
||||||
|
|
||||||
|
bool isLoading = false;
|
||||||
|
|
||||||
|
var searchResults = <FavoriteItem>[];
|
||||||
|
|
||||||
|
void updateSearchResult() {
|
||||||
|
setState(() {
|
||||||
|
if (keyword.trim().isEmpty) {
|
||||||
|
searchResults = comics;
|
||||||
|
} else {
|
||||||
|
searchResults = [];
|
||||||
|
for (var comic in comics) {
|
||||||
|
if (matchKeyword(keyword, comic) ||
|
||||||
|
matchKeywordT(keyword, comic) ||
|
||||||
|
matchKeywordS(keyword, comic)) {
|
||||||
|
searchResults.add(comic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void updateComics() {
|
void updateComics() {
|
||||||
if (keyword.isEmpty) {
|
if (isLoading) return;
|
||||||
setState(() {
|
if (isAllFolder) {
|
||||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
var totalComics = manager.totalComics;
|
||||||
});
|
if (totalComics < _asyncDataFetchLimit) {
|
||||||
|
comics = manager.getAllComics();
|
||||||
|
} else {
|
||||||
|
isLoading = true;
|
||||||
|
manager
|
||||||
|
.getAllComicsAsync()
|
||||||
|
.minTime(const Duration(milliseconds: 200))
|
||||||
|
.then((value) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
isLoading = false;
|
||||||
|
comics = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
var folderComics = manager.folderComics(widget.folder);
|
||||||
comics = LocalFavoritesManager().searchInFolder(widget.folder, keyword);
|
if (folderComics < _asyncDataFetchLimit) {
|
||||||
});
|
comics = manager.getFolderComics(widget.folder);
|
||||||
|
} else {
|
||||||
|
isLoading = true;
|
||||||
|
manager
|
||||||
|
.getFolderComicsAsync(widget.folder)
|
||||||
|
.minTime(const Duration(milliseconds: 200))
|
||||||
|
.then((value) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
isLoading = false;
|
||||||
|
comics = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool matchKeyword(String keyword, FavoriteItem comic) {
|
||||||
|
var list = keyword.split(" ");
|
||||||
|
for (var k in list) {
|
||||||
|
if (k.isEmpty) continue;
|
||||||
|
if (comic.title.contains(k)) {
|
||||||
|
continue;
|
||||||
|
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) {
|
||||||
|
continue;
|
||||||
|
} else if (comic.tags.any((tag) {
|
||||||
|
if (tag == k) {
|
||||||
|
return true;
|
||||||
|
} else if (tag.contains(':') && tag.split(':')[1] == k) {
|
||||||
|
return true;
|
||||||
|
} else if (App.locale.languageCode != 'en' &&
|
||||||
|
tag.translateTagsToCN == k) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})) {
|
||||||
|
continue;
|
||||||
|
} else if (comic.author == k) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert keyword to traditional Chinese to match comics
|
||||||
|
bool matchKeywordT(String keyword, FavoriteItem comic) {
|
||||||
|
if (!OpenCC.hasChineseSimplified(keyword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
keyword = OpenCC.simplifiedToTraditional(keyword);
|
||||||
|
return matchKeyword(keyword, comic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert keyword to simplified Chinese to match comics
|
||||||
|
bool matchKeywordS(String keyword, FavoriteItem comic) {
|
||||||
|
if (!OpenCC.hasChineseTraditional(keyword)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
keyword = OpenCC.traditionalToSimplified(keyword);
|
||||||
|
return matchKeyword(keyword, comic);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
|
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
|
||||||
comics = LocalFavoritesManager().getAllComics(widget.folder);
|
if (!isAllFolder) {
|
||||||
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
||||||
networkSource = a;
|
networkSource = a;
|
||||||
networkFolder = b;
|
networkFolder = b;
|
||||||
|
} else {
|
||||||
|
networkSource = null;
|
||||||
|
networkFolder = null;
|
||||||
|
}
|
||||||
|
comics = [];
|
||||||
|
updateComics();
|
||||||
LocalFavoritesManager().addListener(updateComics);
|
LocalFavoritesManager().addListener(updateComics);
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
@@ -62,16 +175,33 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
|
|
||||||
void selectAll() {
|
void selectAll() {
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
|
if (searchMode) {
|
||||||
|
selectedComics = searchResults.asMap().map((k, v) => MapEntry(v, true));
|
||||||
|
} else {
|
||||||
|
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void invertSelection() {
|
void invertSelection() {
|
||||||
setState(() {
|
setState(() {
|
||||||
comics.asMap().forEach((k, v) {
|
if (searchMode) {
|
||||||
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
|
for (var c in searchResults) {
|
||||||
});
|
if (selectedComics.containsKey(c)) {
|
||||||
selectedComics.removeWhere((k, v) => !v);
|
selectedComics.remove(c);
|
||||||
|
} else {
|
||||||
|
selectedComics[c] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (var c in comics) {
|
||||||
|
if (selectedComics.containsKey(c)) {
|
||||||
|
selectedComics.remove(c);
|
||||||
|
} else {
|
||||||
|
selectedComics[c] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +243,11 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
var title = favPage.folder ?? "Unselected".tl;
|
||||||
|
if (title == _localAllFolderLabel) {
|
||||||
|
title = "All".tl;
|
||||||
|
}
|
||||||
|
|
||||||
Widget body = SmoothCustomScrollView(
|
Widget body = SmoothCustomScrollView(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -135,10 +270,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
onTap: context.width < _kTwoPanelChangeWidth
|
onTap: context.width < _kTwoPanelChangeWidth
|
||||||
? favPage.showFolderSelector
|
? favPage.showFolderSelector
|
||||||
: null,
|
: null,
|
||||||
child: Text(favPage.folder ?? "Unselected".tl),
|
child: Text(title),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (networkSource != null)
|
if (networkSource != null && !isAllFolder)
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: "Sync".tl,
|
message: "Sync".tl,
|
||||||
child: Flyout(
|
child: Flyout(
|
||||||
@@ -191,14 +326,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
keyword = "";
|
||||||
searchMode = true;
|
searchMode = true;
|
||||||
|
updateSearchResult();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MenuButton(
|
if (!isAllFolder)
|
||||||
entries: [
|
MenuButton(
|
||||||
MenuEntry(
|
entries: [
|
||||||
|
MenuEntry(
|
||||||
icon: Icons.edit_outlined,
|
icon: Icons.edit_outlined,
|
||||||
text: "Rename".tl,
|
text: "Rename".tl,
|
||||||
onClick: () {
|
onClick: () {
|
||||||
@@ -220,8 +358,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
MenuEntry(
|
),
|
||||||
|
MenuEntry(
|
||||||
icon: Icons.reorder,
|
icon: Icons.reorder,
|
||||||
text: "Reorder".tl,
|
text: "Reorder".tl,
|
||||||
onClick: () {
|
onClick: () {
|
||||||
@@ -241,8 +380,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
MenuEntry(
|
),
|
||||||
|
MenuEntry(
|
||||||
icon: Icons.upload_file,
|
icon: Icons.upload_file,
|
||||||
text: "Export".tl,
|
text: "Export".tl,
|
||||||
onClick: () {
|
onClick: () {
|
||||||
@@ -253,8 +393,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
data: utf8.encode(json),
|
data: utf8.encode(json),
|
||||||
filename: "${widget.folder}.json",
|
filename: "${widget.folder}.json",
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
MenuEntry(
|
),
|
||||||
|
MenuEntry(
|
||||||
icon: Icons.update,
|
icon: Icons.update,
|
||||||
text: "Update Comics Info".tl,
|
text: "Update Comics Info".tl,
|
||||||
onClick: () {
|
onClick: () {
|
||||||
@@ -265,8 +406,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
},
|
||||||
MenuEntry(
|
),
|
||||||
|
MenuEntry(
|
||||||
icon: Icons.delete_outline,
|
icon: Icons.delete_outline,
|
||||||
text: "Delete Folder".tl,
|
text: "Delete Folder".tl,
|
||||||
color: context.colorScheme.error,
|
color: context.colorScheme.error,
|
||||||
@@ -284,9 +426,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
favPage.folderList?.updateFolders();
|
favPage.folderList?.updateFolders();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
else if (multiSelectMode)
|
else if (multiSelectMode)
|
||||||
@@ -310,10 +453,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
"Selected @c comics".tlParams({"c": selectedComics.length})),
|
"Selected @c comics".tlParams({"c": selectedComics.length})),
|
||||||
actions: [
|
actions: [
|
||||||
MenuButton(entries: [
|
MenuButton(entries: [
|
||||||
|
if (!isAllFolder)
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.drive_file_move,
|
icon: Icons.drive_file_move,
|
||||||
text: "Move to folder".tl,
|
text: "Move to folder".tl,
|
||||||
onClick: () => favoriteOption('move')),
|
onClick: () => favoriteOption('move')),
|
||||||
|
if (!isAllFolder)
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.copy,
|
icon: Icons.copy,
|
||||||
text: "Copy to folder".tl,
|
text: "Copy to folder".tl,
|
||||||
@@ -330,22 +475,23 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
icon: Icons.flip,
|
icon: Icons.flip,
|
||||||
text: "Invert Selection".tl,
|
text: "Invert Selection".tl,
|
||||||
onClick: invertSelection),
|
onClick: invertSelection),
|
||||||
MenuEntry(
|
if (!isAllFolder)
|
||||||
icon: Icons.delete_outline,
|
MenuEntry(
|
||||||
text: "Delete Comic".tl,
|
icon: Icons.delete_outline,
|
||||||
color: context.colorScheme.error,
|
text: "Delete Comic".tl,
|
||||||
onClick: () {
|
color: context.colorScheme.error,
|
||||||
showConfirmDialog(
|
onClick: () {
|
||||||
context: context,
|
showConfirmDialog(
|
||||||
title: "Delete".tl,
|
context: context,
|
||||||
content: "Delete @c comics?"
|
title: "Delete".tl,
|
||||||
.tlParams({"c": selectedComics.length}),
|
content: "Delete @c comics?"
|
||||||
btnColor: context.colorScheme.error,
|
.tlParams({"c": selectedComics.length}),
|
||||||
onConfirm: () {
|
btnColor: context.colorScheme.error,
|
||||||
_deleteComicWithId();
|
onConfirm: () {
|
||||||
},
|
_deleteComicWithId();
|
||||||
);
|
},
|
||||||
}),
|
);
|
||||||
|
}),
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.download,
|
icon: Icons.download,
|
||||||
text: "Download".tl,
|
text: "Download".tl,
|
||||||
@@ -380,9 +526,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
searchMode = false;
|
setState(() {
|
||||||
keyword = "";
|
searchMode = false;
|
||||||
updateComics();
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -391,131 +537,142 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: "Search".tl,
|
hintText: "Search".tl,
|
||||||
border: InputBorder.none,
|
border: UnderlineInputBorder(),
|
||||||
),
|
),
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
keyword = v;
|
keyword = v;
|
||||||
updateComics();
|
updateSearchResult();
|
||||||
},
|
},
|
||||||
),
|
).paddingBottom(8).paddingRight(8),
|
||||||
),
|
),
|
||||||
SliverGridComics(
|
if (isLoading)
|
||||||
comics: comics,
|
SliverToBoxAdapter(
|
||||||
selections: selectedComics,
|
child: SizedBox(
|
||||||
menuBuilder: (c) {
|
height: 200,
|
||||||
return [
|
child: const Center(
|
||||||
MenuEntry(
|
child: CircularProgressIndicator(),
|
||||||
icon: Icons.delete,
|
|
||||||
text: "Delete".tl,
|
|
||||||
onClick: () {
|
|
||||||
LocalFavoritesManager().deleteComicWithId(
|
|
||||||
widget.folder,
|
|
||||||
c.id,
|
|
||||||
(c as FavoriteItem).type,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
MenuEntry(
|
),
|
||||||
icon: Icons.check,
|
)
|
||||||
text: "Select".tl,
|
else
|
||||||
onClick: () {
|
SliverGridComics(
|
||||||
setState(() {
|
comics: searchMode ? searchResults : comics,
|
||||||
if (!multiSelectMode) {
|
selections: selectedComics,
|
||||||
multiSelectMode = true;
|
menuBuilder: (c) {
|
||||||
}
|
return [
|
||||||
if (selectedComics.containsKey(c as FavoriteItem)) {
|
if (!isAllFolder)
|
||||||
selectedComics.remove(c);
|
MenuEntry(
|
||||||
_checkExitSelectMode();
|
icon: Icons.delete,
|
||||||
} else {
|
text: "Delete".tl,
|
||||||
selectedComics[c] = true;
|
onClick: () {
|
||||||
}
|
LocalFavoritesManager().deleteComicWithId(
|
||||||
lastSelectedIndex = comics.indexOf(c);
|
widget.folder,
|
||||||
});
|
c.id,
|
||||||
},
|
(c as FavoriteItem).type,
|
||||||
),
|
);
|
||||||
MenuEntry(
|
},
|
||||||
icon: Icons.download,
|
),
|
||||||
text: "Download".tl,
|
|
||||||
onClick: () {
|
|
||||||
downloadComic(c as FavoriteItem);
|
|
||||||
context.showMessage(
|
|
||||||
message: "Download started".tl,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (appdata.settings["onClickFavorite"] == "viewDetail")
|
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.menu_book_outlined,
|
icon: Icons.check,
|
||||||
text: "Read".tl,
|
text: "Select".tl,
|
||||||
onClick: () {
|
onClick: () {
|
||||||
App.mainNavigatorKey?.currentContext?.to(
|
setState(() {
|
||||||
() => ReaderWithLoading(
|
if (!multiSelectMode) {
|
||||||
id: c.id,
|
multiSelectMode = true;
|
||||||
sourceKey: c.sourceKey,
|
}
|
||||||
),
|
if (selectedComics.containsKey(c as FavoriteItem)) {
|
||||||
|
selectedComics.remove(c);
|
||||||
|
_checkExitSelectMode();
|
||||||
|
} else {
|
||||||
|
selectedComics[c] = true;
|
||||||
|
}
|
||||||
|
lastSelectedIndex = comics.indexOf(c);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.download,
|
||||||
|
text: "Download".tl,
|
||||||
|
onClick: () {
|
||||||
|
downloadComic(c as FavoriteItem);
|
||||||
|
context.showMessage(
|
||||||
|
message: "Download started".tl,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
if (appdata.settings["onClickFavorite"] == "viewDetail")
|
||||||
},
|
MenuEntry(
|
||||||
onTap: (c) {
|
icon: Icons.menu_book_outlined,
|
||||||
if (multiSelectMode) {
|
text: "Read".tl,
|
||||||
setState(() {
|
onClick: () {
|
||||||
if (selectedComics.containsKey(c as FavoriteItem)) {
|
App.mainNavigatorKey?.currentContext?.to(
|
||||||
selectedComics.remove(c);
|
() => ReaderWithLoading(
|
||||||
_checkExitSelectMode();
|
id: c.id,
|
||||||
} else {
|
sourceKey: c.sourceKey,
|
||||||
selectedComics[c] = true;
|
),
|
||||||
}
|
);
|
||||||
lastSelectedIndex = comics.indexOf(c);
|
},
|
||||||
});
|
),
|
||||||
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
|
];
|
||||||
App.mainNavigatorKey?.currentContext
|
},
|
||||||
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
|
onTap: (c) {
|
||||||
} else {
|
if (multiSelectMode) {
|
||||||
App.mainNavigatorKey?.currentContext?.to(
|
setState(() {
|
||||||
() => ReaderWithLoading(
|
if (selectedComics.containsKey(c as FavoriteItem)) {
|
||||||
id: c.id,
|
selectedComics.remove(c);
|
||||||
sourceKey: c.sourceKey,
|
_checkExitSelectMode();
|
||||||
),
|
} else {
|
||||||
);
|
selectedComics[c] = true;
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPressed: (c) {
|
|
||||||
setState(() {
|
|
||||||
if (!multiSelectMode) {
|
|
||||||
multiSelectMode = true;
|
|
||||||
if (!selectedComics.containsKey(c as FavoriteItem)) {
|
|
||||||
selectedComics[c] = true;
|
|
||||||
}
|
|
||||||
lastSelectedIndex = comics.indexOf(c);
|
|
||||||
} else {
|
|
||||||
if (lastSelectedIndex != null) {
|
|
||||||
int start = lastSelectedIndex!;
|
|
||||||
int end = comics.indexOf(c as FavoriteItem);
|
|
||||||
if (start > end) {
|
|
||||||
int temp = start;
|
|
||||||
start = end;
|
|
||||||
end = temp;
|
|
||||||
}
|
}
|
||||||
|
lastSelectedIndex = comics.indexOf(c);
|
||||||
|
});
|
||||||
|
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
|
||||||
|
App.mainNavigatorKey?.currentContext
|
||||||
|
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
|
||||||
|
} else {
|
||||||
|
App.mainNavigatorKey?.currentContext?.to(
|
||||||
|
() => ReaderWithLoading(
|
||||||
|
id: c.id,
|
||||||
|
sourceKey: c.sourceKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPressed: (c) {
|
||||||
|
setState(() {
|
||||||
|
if (!multiSelectMode) {
|
||||||
|
multiSelectMode = true;
|
||||||
|
if (!selectedComics.containsKey(c as FavoriteItem)) {
|
||||||
|
selectedComics[c] = true;
|
||||||
|
}
|
||||||
|
lastSelectedIndex = comics.indexOf(c);
|
||||||
|
} else {
|
||||||
|
if (lastSelectedIndex != null) {
|
||||||
|
int start = lastSelectedIndex!;
|
||||||
|
int end = comics.indexOf(c as FavoriteItem);
|
||||||
|
if (start > end) {
|
||||||
|
int temp = start;
|
||||||
|
start = end;
|
||||||
|
end = temp;
|
||||||
|
}
|
||||||
|
|
||||||
for (int i = start; i <= end; i++) {
|
for (int i = start; i <= end; i++) {
|
||||||
if (i == lastSelectedIndex) continue;
|
if (i == lastSelectedIndex) continue;
|
||||||
|
|
||||||
var comic = comics[i];
|
var comic = comics[i];
|
||||||
if (selectedComics.containsKey(comic)) {
|
if (selectedComics.containsKey(comic)) {
|
||||||
selectedComics.remove(comic);
|
selectedComics.remove(comic);
|
||||||
} else {
|
} else {
|
||||||
selectedComics[comic] = true;
|
selectedComics[comic] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
|
||||||
}
|
}
|
||||||
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
|
_checkExitSelectMode();
|
||||||
}
|
});
|
||||||
_checkExitSelectMode();
|
},
|
||||||
});
|
),
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
body = AppScrollBar(
|
body = AppScrollBar(
|
||||||
@@ -638,32 +795,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (option == 'move') {
|
if (option == 'move') {
|
||||||
for (var c in selectedComics.keys) {
|
var comics = selectedComics.keys
|
||||||
for (var s in selectedLocalFolders) {
|
.map((e) => e as FavoriteItem)
|
||||||
LocalFavoritesManager().moveFavorite(
|
.toList();
|
||||||
favPage.folder as String,
|
for (var f in selectedLocalFolders) {
|
||||||
s,
|
LocalFavoritesManager().batchMoveFavorites(
|
||||||
c.id,
|
favPage.folder as String,
|
||||||
(c as FavoriteItem).type);
|
f,
|
||||||
}
|
comics,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (var c in selectedComics.keys) {
|
var comics = selectedComics.keys
|
||||||
for (var s in selectedLocalFolders) {
|
.map((e) => e as FavoriteItem)
|
||||||
LocalFavoritesManager().addComic(
|
.toList();
|
||||||
s,
|
for (var f in selectedLocalFolders) {
|
||||||
FavoriteItem(
|
LocalFavoritesManager().batchCopyFavorites(
|
||||||
id: c.id,
|
favPage.folder as String,
|
||||||
name: c.title,
|
f,
|
||||||
coverPath: c.cover,
|
comics,
|
||||||
author: c.subtitle ?? '',
|
);
|
||||||
type: ComicType((c.sourceKey == 'local'
|
|
||||||
? 0
|
|
||||||
: c.sourceKey.hashCode)),
|
|
||||||
tags: c.tags ?? [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
App.rootContext.pop();
|
App.rootContext.pop();
|
||||||
@@ -699,13 +850,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _deleteComicWithId() {
|
void _deleteComicWithId() {
|
||||||
for (var c in selectedComics.keys) {
|
var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList();
|
||||||
LocalFavoritesManager().deleteComicWithId(
|
LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted);
|
||||||
widget.folder,
|
|
||||||
c.id,
|
|
||||||
(c as FavoriteItem).type,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_cancel();
|
_cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,7 +871,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
|||||||
final _key = GlobalKey();
|
final _key = GlobalKey();
|
||||||
var reorderWidgetKey = UniqueKey();
|
var reorderWidgetKey = UniqueKey();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
late var comics = LocalFavoritesManager().getAllComics(widget.name);
|
late var comics = LocalFavoritesManager().getFolderComics(widget.name);
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
|
|
||||||
static int _floatToInt8(double x) {
|
static int _floatToInt8(double x) {
|
||||||
@@ -746,7 +892,10 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
if (changed) {
|
if (changed) {
|
||||||
LocalFavoritesManager().reorder(comics, widget.name);
|
// Delay to ensure navigation is completed
|
||||||
|
Future.delayed(const Duration(milliseconds: 200), () {
|
||||||
|
LocalFavoritesManager().reorder(comics, widget.name);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -781,27 +930,31 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
|
|||||||
appBar: Appbar(
|
appBar: Appbar(
|
||||||
title: Text("Reorder".tl),
|
title: Text("Reorder".tl),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
Tooltip(
|
||||||
icon: const Icon(Icons.info_outline),
|
message: "Information".tl,
|
||||||
onPressed: () {
|
child: IconButton(
|
||||||
showInfoDialog(
|
icon: const Icon(Icons.info_outline),
|
||||||
context: context,
|
onPressed: () {
|
||||||
title: "Reorder".tl,
|
showInfoDialog(
|
||||||
content: "Long press and drag to reorder.".tl,
|
context: context,
|
||||||
);
|
title: "Reorder".tl,
|
||||||
},
|
content: "Long press and drag to reorder.".tl,
|
||||||
),
|
);
|
||||||
IconButton(
|
},
|
||||||
icon: const Icon(Icons.swap_vert),
|
),
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
comics = comics.reversed.toList();
|
|
||||||
changed = true;
|
|
||||||
showToast(
|
|
||||||
message: "Reversed successfully".tl, context: context);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: "Reverse".tl,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.swap_vert),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
comics = comics.reversed.toList();
|
||||||
|
changed = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: ReorderableBuilder<FavoriteItem>(
|
body: ReorderableBuilder<FavoriteItem>(
|
||||||
|
@@ -1,41 +0,0 @@
|
|||||||
part of 'favorites_page.dart';
|
|
||||||
|
|
||||||
class LocalSearchPage extends StatefulWidget {
|
|
||||||
const LocalSearchPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<LocalSearchPage> createState() => _LocalSearchPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LocalSearchPageState extends State<LocalSearchPage> {
|
|
||||||
String keyword = '';
|
|
||||||
|
|
||||||
var comics = <FavoriteItemWithFolderInfo>[];
|
|
||||||
|
|
||||||
late final SearchBarController controller;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
controller = SearchBarController(onSearch: (text) {
|
|
||||||
keyword = text;
|
|
||||||
comics = LocalFavoritesManager().search(keyword);
|
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
body: SmoothCustomScrollView(slivers: [
|
|
||||||
SliverSearchBar(controller: controller),
|
|
||||||
SliverGridComics(
|
|
||||||
comics: comics,
|
|
||||||
badgeBuilder: (c) {
|
|
||||||
return (c as FavoriteItemWithFolderInfo).folder;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
|||||||
folders = LocalFavoritesManager().folderNames;
|
folders = LocalFavoritesManager().folderNames;
|
||||||
findNetworkFolders();
|
findNetworkFolders();
|
||||||
appdata.settings.addListener(updateFolders);
|
appdata.settings.addListener(updateFolders);
|
||||||
|
LocalFavoritesManager().addListener(updateFolders);
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
appdata.settings.removeListener(updateFolders);
|
appdata.settings.removeListener(updateFolders);
|
||||||
|
LocalFavoritesManager().removeListener(updateFolders);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -86,58 +88,14 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
|||||||
padding: widget.withAppbar
|
padding: widget.withAppbar
|
||||||
? EdgeInsets.zero
|
? EdgeInsets.zero
|
||||||
: EdgeInsets.only(top: context.padding.top),
|
: EdgeInsets.only(top: context.padding.top),
|
||||||
itemCount: folders.length + networkFolders.length + 2,
|
itemCount: folders.length + networkFolders.length + 3,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
return Container(
|
return buildLocalTitle();
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
}
|
||||||
child: Row(
|
index--;
|
||||||
children: [
|
if (index == 0) {
|
||||||
Icon(
|
return buildLocalFolder(_localAllFolderLabel);
|
||||||
Icons.local_activity,
|
|
||||||
color: context.colorScheme.secondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text("Local".tl),
|
|
||||||
const Spacer(),
|
|
||||||
MenuButton(
|
|
||||||
entries: [
|
|
||||||
MenuEntry(
|
|
||||||
icon: Icons.search,
|
|
||||||
text: 'Search'.tl,
|
|
||||||
onClick: () {
|
|
||||||
context.to(() => const LocalSearchPage());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MenuEntry(
|
|
||||||
icon: Icons.add,
|
|
||||||
text: 'Create Folder'.tl,
|
|
||||||
onClick: () {
|
|
||||||
newFolder().then((value) {
|
|
||||||
setState(() {
|
|
||||||
folders =
|
|
||||||
LocalFavoritesManager().folderNames;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
MenuEntry(
|
|
||||||
icon: Icons.reorder,
|
|
||||||
text: 'Sort'.tl,
|
|
||||||
onClick: () {
|
|
||||||
sortFolders().then((value) {
|
|
||||||
setState(() {
|
|
||||||
folders =
|
|
||||||
LocalFavoritesManager().folderNames;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).paddingHorizontal(16),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
index--;
|
index--;
|
||||||
if (index < folders.length) {
|
if (index < folders.length) {
|
||||||
@@ -145,38 +103,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
|||||||
}
|
}
|
||||||
index -= folders.length;
|
index -= folders.length;
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
return Container(
|
return buildNetworkTitle();
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
margin: const EdgeInsets.only(top: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: context.colorScheme.outlineVariant,
|
|
||||||
width: 0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.cloud,
|
|
||||||
color: context.colorScheme.secondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text("Network".tl),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.settings),
|
|
||||||
onPressed: () {
|
|
||||||
showPopUpWidget(
|
|
||||||
App.rootContext,
|
|
||||||
setFavoritesPagesWidget(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).paddingHorizontal(16),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
index--;
|
index--;
|
||||||
return buildNetworkFolder(networkFolders[index]);
|
return buildNetworkFolder(networkFolders[index]);
|
||||||
@@ -188,8 +115,95 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget buildLocalTitle() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.local_activity,
|
||||||
|
color: context.colorScheme.secondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text("Local".tl),
|
||||||
|
const Spacer(),
|
||||||
|
MenuButton(
|
||||||
|
entries: [
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.add,
|
||||||
|
text: 'Create Folder'.tl,
|
||||||
|
onClick: () {
|
||||||
|
newFolder().then((value) {
|
||||||
|
setState(() {
|
||||||
|
folders = LocalFavoritesManager().folderNames;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.reorder,
|
||||||
|
text: 'Sort'.tl,
|
||||||
|
onClick: () {
|
||||||
|
sortFolders().then((value) {
|
||||||
|
setState(() {
|
||||||
|
folders = LocalFavoritesManager().folderNames;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingHorizontal(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildNetworkTitle() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
margin: const EdgeInsets.only(top: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: context.colorScheme.outlineVariant,
|
||||||
|
width: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud,
|
||||||
|
color: context.colorScheme.secondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text("Network".tl),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings),
|
||||||
|
onPressed: () {
|
||||||
|
showPopUpWidget(
|
||||||
|
App.rootContext,
|
||||||
|
setFavoritesPagesWidget(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingHorizontal(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget buildLocalFolder(String name) {
|
Widget buildLocalFolder(String name) {
|
||||||
bool isSelected = name == favPage.folder && !favPage.isNetwork;
|
bool isSelected = name == favPage.folder && !favPage.isNetwork;
|
||||||
|
int count = 0;
|
||||||
|
if (name == _localAllFolderLabel) {
|
||||||
|
count = LocalFavoritesManager().totalComics;
|
||||||
|
} else {
|
||||||
|
count = LocalFavoritesManager().folderComics(name);
|
||||||
|
}
|
||||||
|
var folderName = name == _localAllFolderLabel
|
||||||
|
? "All".tl
|
||||||
|
: getFavoriteDataOrNull(name)?.title ?? name;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -214,7 +228,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.only(left: 16),
|
padding: const EdgeInsets.only(left: 16),
|
||||||
child: Text(name),
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(folderName),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
margin: EdgeInsets.only(right: 8),
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colorScheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(count.toString()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -140,6 +140,14 @@ class _HistoryPageState extends State<HistoryPage> {
|
|||||||
title: 'Clear History'.tl,
|
title: 'Clear History'.tl,
|
||||||
content: Text('Are you sure you want to clear your history?'.tl),
|
content: Text('Are you sure you want to clear your history?'.tl),
|
||||||
actions: [
|
actions: [
|
||||||
|
Button.outlined(
|
||||||
|
onPressed: () {
|
||||||
|
HistoryManager().clearUnfavoritedHistory();
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
child: Text('Clear Unfavorited'.tl),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
Button.filled(
|
Button.filled(
|
||||||
color: context.colorScheme.error,
|
color: context.colorScheme.error,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
@@ -14,6 +14,7 @@ import 'package:venera/utils/io.dart';
|
|||||||
import 'package:venera/utils/pdf.dart';
|
import 'package:venera/utils/pdf.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
import 'package:zip_flutter/zip_flutter.dart';
|
import 'package:zip_flutter/zip_flutter.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class LocalComicsPage extends StatefulWidget {
|
class LocalComicsPage extends StatefulWidget {
|
||||||
const LocalComicsPage({super.key});
|
const LocalComicsPage({super.key});
|
||||||
@@ -143,6 +144,14 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
addFavorite(selectedComics.keys.toList());
|
addFavorite(selectedComics.keys.toList());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (selectedComics.length == 1)
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.folder_open,
|
||||||
|
text: "Open Folder".tl,
|
||||||
|
onClick: () {
|
||||||
|
openComicFolder(selectedComics.keys.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (selectedComics.length == 1)
|
if (selectedComics.length == 1)
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.chrome_reader_mode_outlined,
|
icon: Icons.chrome_reader_mode_outlined,
|
||||||
@@ -306,12 +315,20 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// prevent dirty data
|
// prevent dirty data
|
||||||
var comic = LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
|
var comic =
|
||||||
|
LocalManager().find(c.id, ComicType.fromKey(c.sourceKey))!;
|
||||||
comic.read();
|
comic.read();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
menuBuilder: (c) {
|
menuBuilder: (c) {
|
||||||
return [
|
return [
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.folder_open,
|
||||||
|
text: "Open Folder".tl,
|
||||||
|
onClick: () {
|
||||||
|
openComicFolder(c as LocalComic);
|
||||||
|
},
|
||||||
|
),
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.delete,
|
icon: Icons.delete,
|
||||||
text: "Delete".tl,
|
text: "Delete".tl,
|
||||||
@@ -360,28 +377,49 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
context: App.rootContext,
|
context: App.rootContext,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
bool removeComicFile = true;
|
bool removeComicFile = true;
|
||||||
|
bool removeFavoriteAndHistory = true;
|
||||||
return StatefulBuilder(builder: (context, state) {
|
return StatefulBuilder(builder: (context, state) {
|
||||||
return ContentDialog(
|
return ContentDialog(
|
||||||
title: "Delete".tl,
|
title: "Delete".tl,
|
||||||
content: CheckboxListTile(
|
content: Column(
|
||||||
title: Text("Also remove files on disk".tl),
|
children: [
|
||||||
value: removeComicFile,
|
CheckboxListTile(
|
||||||
onChanged: (v) {
|
title: Text("Remove local favorite and history".tl),
|
||||||
state(() {
|
value: removeFavoriteAndHistory,
|
||||||
removeComicFile = !removeComicFile;
|
onChanged: (v) {
|
||||||
});
|
state(() {
|
||||||
},
|
removeFavoriteAndHistory = !removeFavoriteAndHistory;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text("Also remove files on disk".tl),
|
||||||
|
value: removeComicFile,
|
||||||
|
onChanged: (v) {
|
||||||
|
state(() {
|
||||||
|
removeComicFile = !removeComicFile;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (comics.length == 1 && comics.first.hasChapters)
|
||||||
|
TextButton(
|
||||||
|
child: Text("Delete Chapters".tl),
|
||||||
|
onPressed: () {
|
||||||
|
context.pop();
|
||||||
|
showDeleteChaptersPopWindow(context, comics.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.pop();
|
context.pop();
|
||||||
for (var comic in comics) {
|
LocalManager().batchDeleteComics(
|
||||||
LocalManager().deleteComic(
|
comics,
|
||||||
comic,
|
removeComicFile,
|
||||||
removeComicFile,
|
removeFavoriteAndHistory,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
isDeleted = true;
|
isDeleted = true;
|
||||||
},
|
},
|
||||||
child: Text("Confirm".tl),
|
child: Text("Confirm".tl),
|
||||||
@@ -444,7 +482,10 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
var fileName = "";
|
var fileName = "";
|
||||||
// For each comic, export it to a file
|
// For each comic, export it to a file
|
||||||
for (var comic in comics) {
|
for (var comic in comics) {
|
||||||
fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext);
|
fileName = FilePath.join(
|
||||||
|
cacheDir,
|
||||||
|
sanitizeFileName(comic.title, maxLength: 100) + ext,
|
||||||
|
);
|
||||||
await export(comic, fileName);
|
await export(comic, fileName);
|
||||||
current++;
|
current++;
|
||||||
if (comics.length > 1) {
|
if (comics.length > 1) {
|
||||||
@@ -493,3 +534,102 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
|
|
||||||
typedef ExportComicFunc = Future<File> Function(
|
typedef ExportComicFunc = Future<File> Function(
|
||||||
LocalComic comic, String outFilePath);
|
LocalComic comic, String outFilePath);
|
||||||
|
|
||||||
|
/// Opens the folder containing the comic in the system file explorer
|
||||||
|
Future<void> openComicFolder(LocalComic comic) async {
|
||||||
|
try {
|
||||||
|
final folderPath = comic.baseDir;
|
||||||
|
|
||||||
|
if (App.isWindows) {
|
||||||
|
await Process.run('explorer', [folderPath]);
|
||||||
|
} else if (App.isMacOS) {
|
||||||
|
await Process.run('open', [folderPath]);
|
||||||
|
} else if (App.isLinux) {
|
||||||
|
// Try different file managers commonly found on Linux
|
||||||
|
try {
|
||||||
|
await Process.run('xdg-open', [folderPath]);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to other common file managers
|
||||||
|
try {
|
||||||
|
await Process.run('nautilus', [folderPath]);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await Process.run('dolphin', [folderPath]);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await Process.run('thunar', [folderPath]);
|
||||||
|
} catch (e) {
|
||||||
|
// Last resort: use the URL launcher with file:// protocol
|
||||||
|
await launchUrlString('file://$folderPath');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For mobile platforms, use the URL launcher with file:// protocol
|
||||||
|
await launchUrlString('file://$folderPath');
|
||||||
|
}
|
||||||
|
} catch (e, s) {
|
||||||
|
Log.error("Open Folder", "Failed to open comic folder: $e", s);
|
||||||
|
// Show error message to user
|
||||||
|
if (App.rootContext.mounted) {
|
||||||
|
App.rootContext.showMessage(message: "Failed to open folder: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void showDeleteChaptersPopWindow(BuildContext context, LocalComic comic) {
|
||||||
|
var chapters = <String>[];
|
||||||
|
|
||||||
|
showPopUpWidget(
|
||||||
|
context,
|
||||||
|
PopUpWidgetScaffold(
|
||||||
|
title: "Delete Chapters".tl,
|
||||||
|
body: StatefulBuilder(builder: (context, setState) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: comic.downloadedChapters.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
var id = comic.downloadedChapters[index];
|
||||||
|
var chapter = comic.chapters![id] ?? "Unknown Chapter";
|
||||||
|
return CheckboxListTile(
|
||||||
|
title: Text(chapter),
|
||||||
|
value: chapters.contains(id),
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
if (v == true) {
|
||||||
|
chapters.add(id);
|
||||||
|
} else {
|
||||||
|
chapters.remove(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
Future.delayed(const Duration(milliseconds: 200), () {
|
||||||
|
LocalManager().deleteComicChapters(comic, chapters);
|
||||||
|
});
|
||||||
|
App.rootContext.pop();
|
||||||
|
},
|
||||||
|
child: Text("Submit".tl),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -131,11 +131,11 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
|||||||
}
|
}
|
||||||
if (context.reader.mode.key.startsWith('gallery')) {
|
if (context.reader.mode.key.startsWith('gallery')) {
|
||||||
if (forward) {
|
if (forward) {
|
||||||
if (!context.reader.toNextPage() && !context.reader.isLastChapterOfGroup) {
|
if (!context.reader.toNextPage(reader.cid, reader.type) && !context.reader.isLastChapterOfGroup) {
|
||||||
context.reader.toNextChapter();
|
context.reader.toNextChapter();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!context.reader.toPrevPage() && !context.reader.isFirstChapterOfGroup) {
|
if (!context.reader.toPrevPage(reader.cid, reader.type) && !context.reader.isFirstChapterOfGroup) {
|
||||||
context.reader.toPrevChapter();
|
context.reader.toPrevChapter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,12 +152,19 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
|||||||
|
|
||||||
bool _dragInProgress = false;
|
bool _dragInProgress = false;
|
||||||
|
|
||||||
|
bool get _enableDoubleTapToZoom =>
|
||||||
|
appdata.settings.getReaderSetting(reader.cid, reader.type.sourceKey, 'enableDoubleTapToZoom');
|
||||||
|
|
||||||
void onTapUp(TapUpDetails event) {
|
void onTapUp(TapUpDetails event) {
|
||||||
if (_longPressInProgress) {
|
if (_longPressInProgress) {
|
||||||
_longPressInProgress = false;
|
_longPressInProgress = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final location = event.globalPosition;
|
final location = event.globalPosition;
|
||||||
|
if (!_enableDoubleTapToZoom) {
|
||||||
|
onTap(location);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final previousLocation = _previousEvent?.globalPosition;
|
final previousLocation = _previousEvent?.globalPosition;
|
||||||
if (previousLocation != null) {
|
if (previousLocation != null) {
|
||||||
if ((location - previousLocation).distanceSquared <
|
if ((location - previousLocation).distanceSquared <
|
||||||
@@ -184,7 +191,8 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
|||||||
} else if (context.readerScaffold.isOpen) {
|
} else if (context.readerScaffold.isOpen) {
|
||||||
context.readerScaffold.openOrClose();
|
context.readerScaffold.openOrClose();
|
||||||
} else {
|
} else {
|
||||||
if (appdata.settings['enableTapToTurnPages']) {
|
if (appdata.settings.getReaderSetting(
|
||||||
|
reader.cid, reader.type.sourceKey, 'enableTapToTurnPages')) {
|
||||||
bool isLeft = false, isRight = false, isTop = false, isBottom = false;
|
bool isLeft = false, isRight = false, isTop = false, isBottom = false;
|
||||||
final width = context.width;
|
final width = context.width;
|
||||||
final height = context.height;
|
final height = context.height;
|
||||||
@@ -201,11 +209,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
|||||||
isBottom = true;
|
isBottom = true;
|
||||||
}
|
}
|
||||||
bool isCenter = false;
|
bool isCenter = false;
|
||||||
var prev = context.reader.toPrevPage;
|
var prev = () => context.reader.toPrevPage(context.reader.cid, context.reader.type);
|
||||||
var next = context.reader.toNextPage;
|
var next = () => context.reader.toNextPage(context.reader.cid, context.reader.type);
|
||||||
if (appdata.settings['reverseTapToTurnPages']) {
|
if (appdata.settings.getReaderSetting(
|
||||||
prev = context.reader.toNextPage;
|
reader.cid, reader.type.sourceKey, 'reverseTapToTurnPages')) {
|
||||||
next = context.reader.toPrevPage;
|
prev = () => context.reader.toNextPage(context.reader.cid, context.reader.type);
|
||||||
|
next = () => context.reader.toPrevPage(context.reader.cid, context.reader.type);
|
||||||
}
|
}
|
||||||
switch (context.reader.mode) {
|
switch (context.reader.mode) {
|
||||||
case ReaderMode.galleryLeftToRight:
|
case ReaderMode.galleryLeftToRight:
|
||||||
@@ -287,6 +296,12 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
|||||||
text: "Copy Image".tl,
|
text: "Copy Image".tl,
|
||||||
onClick: () => copyImage(location),
|
onClick: () => copyImage(location),
|
||||||
),
|
),
|
||||||
|
if (!reader.isLoading)
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.download_outlined,
|
||||||
|
text: "Save Image".tl,
|
||||||
|
onClick: () => saveImage(location),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -319,6 +334,17 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
|||||||
context.showMessage(message: "No Image");
|
context.showMessage(message: "No Image");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void saveImage(Offset location) async {
|
||||||
|
var controller = reader._imageViewController;
|
||||||
|
var image = await controller!.getImageByOffset(location);
|
||||||
|
if (image != null) {
|
||||||
|
var filetype = detectFileType(image);
|
||||||
|
saveFile(filename: "image${filetype.ext}", data: image);
|
||||||
|
} else {
|
||||||
|
context.showMessage(message: "No Image");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DragListener {
|
class _DragListener {
|
||||||
|
@@ -21,19 +21,35 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
ImageDownloader.cancelAllLoadingImages();
|
||||||
|
}
|
||||||
|
|
||||||
void load() async {
|
void load() async {
|
||||||
if (inProgress) return;
|
if (inProgress) return;
|
||||||
inProgress = true;
|
inProgress = true;
|
||||||
if (reader.type == ComicType.local ||
|
if (reader.type == ComicType.local ||
|
||||||
(LocalManager().isDownloaded(
|
(LocalManager().isDownloaded(
|
||||||
reader.cid, reader.type, reader.chapter, reader.widget.chapters))) {
|
reader.cid,
|
||||||
|
reader.type,
|
||||||
|
reader.chapter,
|
||||||
|
reader.widget.chapters,
|
||||||
|
))) {
|
||||||
try {
|
try {
|
||||||
var images = await LocalManager()
|
var images = await LocalManager().getImages(
|
||||||
.getImages(reader.cid, reader.type, reader.chapter);
|
reader.cid,
|
||||||
|
reader.type,
|
||||||
|
reader.chapter,
|
||||||
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
reader.images = images;
|
reader.images = images;
|
||||||
reader.isLoading = false;
|
reader.isLoading = false;
|
||||||
inProgress = false;
|
inProgress = false;
|
||||||
|
Future.microtask(() {
|
||||||
|
reader.updateHistory();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -59,6 +75,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
|||||||
reader.images = res.data;
|
reader.images = res.data;
|
||||||
reader.isLoading = false;
|
reader.isLoading = false;
|
||||||
inProgress = false;
|
inProgress = false;
|
||||||
|
Future.microtask(() {
|
||||||
|
reader.updateHistory();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,23 +88,29 @@ class _ReaderImagesState extends State<_ReaderImages> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (reader.isLoading) {
|
if (reader.isLoading) {
|
||||||
load();
|
load();
|
||||||
return const Center(
|
return const Center(child: CircularProgressIndicator());
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
);
|
|
||||||
} else if (error != null) {
|
} else if (error != null) {
|
||||||
return NetworkError(
|
return GestureDetector(
|
||||||
message: error!,
|
onTap: () {
|
||||||
retry: () {
|
context.readerScaffold.openOrClose();
|
||||||
setState(() {
|
|
||||||
reader.isLoading = true;
|
|
||||||
error = null;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: NetworkError(
|
||||||
|
message: error!,
|
||||||
|
retry: () {
|
||||||
|
setState(() {
|
||||||
|
reader.isLoading = true;
|
||||||
|
error = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
if (reader.mode.isGallery) {
|
if (reader.mode.isGallery) {
|
||||||
return _GalleryMode(
|
return _GalleryMode(
|
||||||
key: Key('${reader.mode.key}_${reader.imagesPerPage}'));
|
key: Key('${reader.mode.key}_${reader.imagesPerPage}'),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return _ContinuousMode(key: Key(reader.mode.key));
|
return _ContinuousMode(key: Key(reader.mode.key));
|
||||||
}
|
}
|
||||||
@@ -104,15 +129,26 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
implements _ImageViewController {
|
implements _ImageViewController {
|
||||||
late PageController controller;
|
late PageController controller;
|
||||||
|
|
||||||
late List<bool> cached;
|
|
||||||
|
|
||||||
int get preCacheCount => appdata.settings["preloadImageCount"];
|
int get preCacheCount => appdata.settings["preloadImageCount"];
|
||||||
|
|
||||||
var photoViewControllers = <int, PhotoViewController>{};
|
var photoViewControllers = <int, PhotoViewController>{};
|
||||||
|
|
||||||
late _ReaderState reader;
|
late _ReaderState reader;
|
||||||
|
|
||||||
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
|
/// [totalPages] is the total number of pages in the current chapter.
|
||||||
|
/// More than one images can be displayed on one page.
|
||||||
|
int get totalPages {
|
||||||
|
if (!reader.showSingleImageOnFirstPage(reader.cid, reader.type)) {
|
||||||
|
return (reader.images!.length /
|
||||||
|
reader.imagesPerPage(reader.cid, reader.type))
|
||||||
|
.ceil();
|
||||||
|
} else {
|
||||||
|
return 1 +
|
||||||
|
((reader.images!.length - 1) /
|
||||||
|
reader.imagesPerPage(reader.cid, reader.type))
|
||||||
|
.ceil();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var imageStates = <State<ComicImage>>{};
|
var imageStates = <State<ComicImage>>{};
|
||||||
|
|
||||||
@@ -125,24 +161,56 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
reader = context.reader;
|
reader = context.reader;
|
||||||
controller = PageController(initialPage: reader.page);
|
controller = PageController(initialPage: reader.page);
|
||||||
reader._imageViewController = this;
|
reader._imageViewController = this;
|
||||||
cached = List.filled(reader.maxPage + 2, false);
|
|
||||||
Future.microtask(() {
|
Future.microtask(() {
|
||||||
context.readerScaffold.setFloatingButton(0);
|
context.readerScaffold.setFloatingButton(0);
|
||||||
});
|
});
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void cache(int current) {
|
/// Get the range of images for the given page. [page] is 1-based.
|
||||||
for (int i = current + 1; i <= current + preCacheCount; i++) {
|
(int start, int end) getPageImagesRange(int page) {
|
||||||
if (i <= totalPages && !cached[i]) {
|
var imagesPerPage = reader.imagesPerPage(reader.cid, reader.type);
|
||||||
int startIndex = (i - 1) * reader.imagesPerPage;
|
if (reader.showSingleImageOnFirstPage(reader.cid, reader.type)) {
|
||||||
int endIndex =
|
if (page == 1) {
|
||||||
math.min(startIndex + reader.imagesPerPage, reader.images!.length);
|
return (0, 1);
|
||||||
for (int i = startIndex; i < endIndex; i++) {
|
} else {
|
||||||
precacheImage(
|
int startIndex = (page - 2) * imagesPerPage + 1;
|
||||||
_createImageProviderFromKey(reader.images![i], context), context);
|
int endIndex = math.min(
|
||||||
}
|
startIndex + imagesPerPage,
|
||||||
cached[i] = true;
|
reader.images!.length,
|
||||||
|
);
|
||||||
|
return (startIndex, endIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int startIndex = (page - 1) * imagesPerPage;
|
||||||
|
int endIndex = math.min(
|
||||||
|
startIndex + imagesPerPage,
|
||||||
|
reader.images!.length,
|
||||||
|
);
|
||||||
|
return (startIndex, endIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [cache] is used to cache the images.
|
||||||
|
/// The count of images to cache is determined by the [preCacheCount] setting.
|
||||||
|
/// For previous page and next page, it will do a memory cache.
|
||||||
|
/// For current page, it will do nothing because it is already on the screen.
|
||||||
|
/// For other pages, it will do a pre-download cache.
|
||||||
|
void cache(int startPage) {
|
||||||
|
for (int i = startPage - 1; i <= startPage + preCacheCount; i++) {
|
||||||
|
if (i == startPage || i <= 0 || i > totalPages) continue;
|
||||||
|
bool shouldPreCache = i == startPage + 1 || i == startPage - 1;
|
||||||
|
_cachePage(i, shouldPreCache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cachePage(int page, bool shouldPreCache) {
|
||||||
|
var (startIndex, endIndex) = getPageImagesRange(page);
|
||||||
|
for (int i = startIndex; i < endIndex; i++) {
|
||||||
|
if (shouldPreCache) {
|
||||||
|
_precacheImage(i + 1, context);
|
||||||
|
} else {
|
||||||
|
_preDownloadImage(i + 1, context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,16 +232,12 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
var controller = photoViewControllers[reader.page]!;
|
var controller = photoViewControllers[reader.page]!;
|
||||||
Offset value = event.delta;
|
Offset value = event.delta;
|
||||||
if (isLongPressing) {
|
if (isLongPressing) {
|
||||||
controller.updateMultiple(
|
controller.updateMultiple(position: controller.position + value);
|
||||||
position: controller.position + value,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: PhotoViewGallery.builder(
|
child: PhotoViewGallery.builder(
|
||||||
backgroundDecoration: BoxDecoration(
|
backgroundDecoration: BoxDecoration(color: context.colorScheme.surface),
|
||||||
color: context.colorScheme.surface,
|
|
||||||
),
|
|
||||||
reverse: reader.mode == ReaderMode.galleryRightToLeft,
|
reverse: reader.mode == ReaderMode.galleryRightToLeft,
|
||||||
scrollDirection: reader.mode == ReaderMode.galleryTopToBottom
|
scrollDirection: reader.mode == ReaderMode.galleryTopToBottom
|
||||||
? Axis.vertical
|
? Axis.vertical
|
||||||
@@ -185,24 +249,26 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
child: const SizedBox(),
|
child: const SizedBox(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
int pageIndex = index - 1;
|
var (startIndex, endIndex) = getPageImagesRange(index);
|
||||||
int startIndex = pageIndex * reader.imagesPerPage;
|
List<String> pageImages = reader.images!.sublist(
|
||||||
int endIndex = math.min(
|
startIndex,
|
||||||
startIndex + reader.imagesPerPage, reader.images!.length);
|
endIndex,
|
||||||
List<String> pageImages =
|
);
|
||||||
reader.images!.sublist(startIndex, endIndex);
|
|
||||||
|
|
||||||
cached[index] = true;
|
|
||||||
cache(index);
|
cache(index);
|
||||||
|
|
||||||
photoViewControllers[index] ??= PhotoViewController();
|
photoViewControllers[index] ??= PhotoViewController();
|
||||||
|
|
||||||
if (reader.imagesPerPage == 1) {
|
if (reader.imagesPerPage(reader.cid, reader.type) == 1 ||
|
||||||
|
pageImages.length == 1) {
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
filterQuality: FilterQuality.medium,
|
filterQuality: FilterQuality.medium,
|
||||||
controller: photoViewControllers[index],
|
controller: photoViewControllers[index],
|
||||||
imageProvider:
|
imageProvider: _createImageProviderFromKey(
|
||||||
_createImageProviderFromKey(pageImages[0], context),
|
pageImages[0],
|
||||||
|
context,
|
||||||
|
startIndex + 1,
|
||||||
|
),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, error, s, retry) {
|
errorBuilder: (_, error, s, retry) {
|
||||||
return NetworkError(message: error.toString(), retry: retry);
|
return NetworkError(message: error.toString(), retry: retry);
|
||||||
@@ -211,10 +277,11 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PhotoViewGalleryPageOptions.customChild(
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
|
childSize: reader.size * 2,
|
||||||
controller: photoViewControllers[index],
|
controller: photoViewControllers[index],
|
||||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||||
maxScale: PhotoViewComputedScale.covered * 10.0,
|
maxScale: PhotoViewComputedScale.covered * 10.0,
|
||||||
child: buildPageImages(pageImages),
|
child: buildPageImages(pageImages, startIndex),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -234,22 +301,29 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
onPageChanged: (i) {
|
onPageChanged: (i) {
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
if (reader.isFirstChapterOfGroup || !reader.toPrevChapter()) {
|
if (reader.isFirstChapterOfGroup || !reader.toPrevChapter()) {
|
||||||
reader.toPage(1);
|
reader.toPage(reader.cid, reader.type, 1);
|
||||||
}
|
}
|
||||||
} else if (i == totalPages + 1) {
|
} else if (i == totalPages + 1) {
|
||||||
if (reader.isLastChapterOfGroup || !reader.toNextChapter()) {
|
if (reader.isLastChapterOfGroup || !reader.toNextChapter()) {
|
||||||
reader.toPage(totalPages);
|
reader.toPage(reader.cid, reader.type, totalPages);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
reader.setPage(i);
|
reader.setPage(i);
|
||||||
context.readerScaffold.update();
|
context.readerScaffold.update();
|
||||||
}
|
}
|
||||||
|
// Remove other pages' controllers to reset their state.
|
||||||
|
var keys = photoViewControllers.keys.toList();
|
||||||
|
for (var key in keys) {
|
||||||
|
if (key != i) {
|
||||||
|
photoViewControllers.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildPageImages(List<String> images) {
|
Widget buildPageImages(List<String> images, int startIndex) {
|
||||||
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
|
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
|
||||||
? Axis.vertical
|
? Axis.vertical
|
||||||
: Axis.horizontal;
|
: Axis.horizontal;
|
||||||
@@ -267,7 +341,11 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
child: ComicImage(
|
child: ComicImage(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
image: _createImageProviderFromKey(images[0], context),
|
image: _createImageProviderFromKey(
|
||||||
|
images[0],
|
||||||
|
context,
|
||||||
|
startIndex + 1,
|
||||||
|
),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
alignment: axis == Axis.vertical
|
alignment: axis == Axis.vertical
|
||||||
? Alignment.bottomCenter
|
? Alignment.bottomCenter
|
||||||
@@ -280,7 +358,11 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
child: ComicImage(
|
child: ComicImage(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: double.infinity,
|
height: double.infinity,
|
||||||
image: _createImageProviderFromKey(images[1], context),
|
image: _createImageProviderFromKey(
|
||||||
|
images[1],
|
||||||
|
context,
|
||||||
|
startIndex + 2,
|
||||||
|
),
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
alignment: axis == Axis.vertical
|
alignment: axis == Axis.vertical
|
||||||
? Alignment.topCenter
|
? Alignment.topCenter
|
||||||
@@ -288,12 +370,16 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
onInit: (state) => imageStates.add(state),
|
onInit: (state) => imageStates.add(state),
|
||||||
onDispose: (state) => imageStates.remove(state),
|
onDispose: (state) => imageStates.remove(state),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
imageWidgets = images.map((imageKey) {
|
imageWidgets = images.map((imageKey) {
|
||||||
ImageProvider imageProvider =
|
startIndex++;
|
||||||
_createImageProviderFromKey(imageKey, context);
|
ImageProvider imageProvider = _createImageProviderFromKey(
|
||||||
|
imageKey,
|
||||||
|
context,
|
||||||
|
startIndex,
|
||||||
|
);
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: ComicImage(
|
child: ComicImage(
|
||||||
image: imageProvider,
|
image: imageProvider,
|
||||||
@@ -354,10 +440,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
} else {
|
} else {
|
||||||
zoomPosition = Offset(0, 0);
|
zoomPosition = Offset(0, 0);
|
||||||
}
|
}
|
||||||
photoViewController.animateScale?.call(
|
photoViewController.animateScale?.call(target, zoomPosition);
|
||||||
target,
|
|
||||||
zoomPosition,
|
|
||||||
);
|
|
||||||
isLongPressing = true;
|
isLongPressing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,34 +485,24 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
keyRepeatTimer = null;
|
keyRepeatTimer = null;
|
||||||
}
|
}
|
||||||
if (forward == true) {
|
if (forward == true) {
|
||||||
controller.nextPage(
|
reader.toPage(reader.cid, reader.type, reader.page + 1);
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.ease,
|
|
||||||
);
|
|
||||||
} else if (forward == false) {
|
} else if (forward == false) {
|
||||||
controller.previousPage(
|
reader.toPage(reader.cid, reader.type, reader.page - 1);
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.ease,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
|
if (event is KeyRepeatEvent && keyRepeatTimer == null) {
|
||||||
keyRepeatTimer = Timer.periodic(
|
keyRepeatTimer = Timer.periodic(
|
||||||
const Duration(milliseconds: 100),
|
reader.enablePageAnimation(reader.cid, reader.type)
|
||||||
|
? const Duration(milliseconds: 200)
|
||||||
|
: const Duration(milliseconds: 50),
|
||||||
(timer) {
|
(timer) {
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
return;
|
return;
|
||||||
} else if (forward == true) {
|
} else if (forward == true) {
|
||||||
controller.nextPage(
|
reader.toPage(reader.cid, reader.type, reader.page + 1);
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
curve: Curves.ease,
|
|
||||||
);
|
|
||||||
} else if (forward == false) {
|
} else if (forward == false) {
|
||||||
controller.previousPage(
|
reader.toPage(reader.cid, reader.type, reader.page - 1);
|
||||||
duration: const Duration(milliseconds: 100),
|
|
||||||
curve: Curves.ease,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -447,8 +520,21 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
||||||
|
var imageKey = getImageKeyByOffset(offset);
|
||||||
|
if (imageKey == null) return null;
|
||||||
|
if (imageKey.startsWith("file://")) {
|
||||||
|
return await File(imageKey.substring(7)).readAsBytes();
|
||||||
|
} else {
|
||||||
|
return (await CacheManager().findCache(
|
||||||
|
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||||
|
))!.readAsBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? getImageKeyByOffset(Offset offset) {
|
||||||
String? imageKey;
|
String? imageKey;
|
||||||
if (reader.imagesPerPage == 1) {
|
if (reader.imagesPerPage(reader.cid, reader.type) == 1) {
|
||||||
imageKey = reader.images![reader.page - 1];
|
imageKey = reader.images![reader.page - 1];
|
||||||
} else {
|
} else {
|
||||||
for (var imageState in imageStates) {
|
for (var imageState in imageStates) {
|
||||||
@@ -457,14 +543,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (imageKey == null) return null;
|
return imageKey;
|
||||||
if (imageKey.startsWith("file://")) {
|
|
||||||
return await File(imageKey.substring(7)).readAsBytes();
|
|
||||||
} else {
|
|
||||||
return (await CacheManager().findCache(
|
|
||||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
|
||||||
.readAsBytes();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +552,7 @@ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
|
|||||||
PointerDeviceKind.mouse,
|
PointerDeviceKind.mouse,
|
||||||
PointerDeviceKind.stylus,
|
PointerDeviceKind.stylus,
|
||||||
PointerDeviceKind.invertedStylus,
|
PointerDeviceKind.invertedStylus,
|
||||||
PointerDeviceKind.unknown
|
PointerDeviceKind.unknown,
|
||||||
};
|
};
|
||||||
|
|
||||||
const double _kChangeChapterOffset = 160;
|
const double _kChangeChapterOffset = 160;
|
||||||
@@ -599,7 +678,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
void cacheImages(int current) {
|
void cacheImages(int current) {
|
||||||
for (int i = current + 1; i <= current + preCacheCount; i++) {
|
for (int i = current + 1; i <= current + preCacheCount; i++) {
|
||||||
if (i <= reader.maxPage && !cached[i]) {
|
if (i <= reader.maxPage && !cached[i]) {
|
||||||
_precacheImage(i, context);
|
_preDownloadImage(i, context);
|
||||||
cached[i] = true;
|
cached[i] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -608,10 +687,12 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
void onScroll() {
|
void onScroll() {
|
||||||
if (prepareToPrevChapter) {
|
if (prepareToPrevChapter) {
|
||||||
jumpToNextChapter = false;
|
jumpToNextChapter = false;
|
||||||
jumpToPrevChapter = scrollController.offset <
|
jumpToPrevChapter =
|
||||||
|
scrollController.offset <
|
||||||
scrollController.position.minScrollExtent - _kChangeChapterOffset;
|
scrollController.position.minScrollExtent - _kChangeChapterOffset;
|
||||||
} else if (prepareToNextChapter) {
|
} else if (prepareToNextChapter) {
|
||||||
jumpToNextChapter = scrollController.offset >
|
jumpToNextChapter =
|
||||||
|
scrollController.offset >
|
||||||
scrollController.position.maxScrollExtent + _kChangeChapterOffset;
|
scrollController.position.maxScrollExtent + _kChangeChapterOffset;
|
||||||
jumpToPrevChapter = false;
|
jumpToPrevChapter = false;
|
||||||
}
|
}
|
||||||
@@ -656,8 +737,8 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
physics: isCTRLPressed || _isMouseScrolling || disableScroll
|
physics: isCTRLPressed || _isMouseScrolling || disableScroll
|
||||||
? const NeverScrollableScrollPhysics()
|
? const NeverScrollableScrollPhysics()
|
||||||
: isZoomedIn
|
: isZoomedIn
|
||||||
? const ClampingScrollPhysics()
|
? const ClampingScrollPhysics()
|
||||||
: const BouncingScrollPhysics(),
|
: const BouncingScrollPhysics(),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0 || index == reader.maxPage + 1) {
|
if (index == 0 || index == reader.maxPage + 1) {
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
@@ -685,8 +766,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
scrollBehavior: const MaterialScrollBehavior()
|
scrollBehavior: const MaterialScrollBehavior().copyWith(
|
||||||
.copyWith(scrollbars: false, dragDevices: _kTouchLikeDeviceTypes),
|
scrollbars: false,
|
||||||
|
dragDevices: _kTouchLikeDeviceTypes,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
widget = Stack(
|
widget = Stack(
|
||||||
@@ -830,20 +913,14 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PhotoView.customChild(
|
return PhotoView.customChild(
|
||||||
backgroundDecoration: BoxDecoration(
|
backgroundDecoration: BoxDecoration(color: context.colorScheme.surface),
|
||||||
color: context.colorScheme.surface,
|
|
||||||
),
|
|
||||||
childSize: Size(width, height),
|
childSize: Size(width, height),
|
||||||
minScale: 1.0,
|
minScale: 1.0,
|
||||||
maxScale: 2.5,
|
maxScale: 2.5,
|
||||||
strictScale: true,
|
strictScale: true,
|
||||||
controller: photoViewController,
|
controller: photoViewController,
|
||||||
onScaleUpdate: onScaleUpdate,
|
onScaleUpdate: onScaleUpdate,
|
||||||
child: SizedBox(
|
child: SizedBox(width: width, height: height, child: widget),
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
child: widget,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,10 +990,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
} else {
|
} else {
|
||||||
zoomPosition = Offset(0, 0);
|
zoomPosition = Offset(0, 0);
|
||||||
}
|
}
|
||||||
photoViewController.animateScale?.call(
|
photoViewController.animateScale?.call(target, zoomPosition);
|
||||||
target,
|
|
||||||
zoomPosition,
|
|
||||||
);
|
|
||||||
onScaleUpdate(target);
|
onScaleUpdate(target);
|
||||||
isLongPressing = true;
|
isLongPressing = true;
|
||||||
}
|
}
|
||||||
@@ -975,13 +1049,13 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
}
|
}
|
||||||
if (forward == true) {
|
if (forward == true) {
|
||||||
scrollController.animateTo(
|
scrollController.animateTo(
|
||||||
scrollController.offset + context.height,
|
scrollController.offset + context.height * 0.25,
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.ease,
|
curve: Curves.ease,
|
||||||
);
|
);
|
||||||
} else if (forward == false) {
|
} else if (forward == false) {
|
||||||
scrollController.animateTo(
|
scrollController.animateTo(
|
||||||
scrollController.offset - context.height,
|
scrollController.offset - context.height * 0.25,
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.ease,
|
curve: Curves.ease,
|
||||||
);
|
);
|
||||||
@@ -998,25 +1072,34 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
Future<Uint8List?> getImageByOffset(Offset offset) async {
|
||||||
|
var imageKey = getImageKeyByOffset(offset);
|
||||||
|
if (imageKey == null) return null;
|
||||||
|
if (imageKey.startsWith("file://")) {
|
||||||
|
return await File(imageKey.substring(7)).readAsBytes();
|
||||||
|
} else {
|
||||||
|
return (await CacheManager().findCache(
|
||||||
|
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||||
|
))!.readAsBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? getImageKeyByOffset(Offset offset) {
|
||||||
String? imageKey;
|
String? imageKey;
|
||||||
for (var imageState in imageStates) {
|
for (var imageState in imageStates) {
|
||||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||||
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (imageKey == null) return null;
|
return imageKey;
|
||||||
if (imageKey.startsWith("file://")) {
|
|
||||||
return await File(imageKey.substring(7)).readAsBytes();
|
|
||||||
} else {
|
|
||||||
return (await CacheManager().findCache(
|
|
||||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
|
||||||
.readAsBytes();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageProvider _createImageProviderFromKey(
|
ImageProvider _createImageProviderFromKey(
|
||||||
String imageKey, BuildContext context) {
|
String imageKey,
|
||||||
|
BuildContext context,
|
||||||
|
int page,
|
||||||
|
) {
|
||||||
var reader = context.reader;
|
var reader = context.reader;
|
||||||
return ReaderImageProvider(
|
return ReaderImageProvider(
|
||||||
imageKey,
|
imageKey,
|
||||||
@@ -1030,21 +1113,38 @@ ImageProvider _createImageProviderFromKey(
|
|||||||
ImageProvider _createImageProvider(int page, BuildContext context) {
|
ImageProvider _createImageProvider(int page, BuildContext context) {
|
||||||
var reader = context.reader;
|
var reader = context.reader;
|
||||||
var imageKey = reader.images![page - 1];
|
var imageKey = reader.images![page - 1];
|
||||||
return _createImageProviderFromKey(imageKey, context);
|
return _createImageProviderFromKey(imageKey, context, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [_precacheImage] is used to precache the image for the given page.
|
||||||
|
/// The image is cached using the flutter's [precacheImage] method.
|
||||||
|
/// The image will be downloaded and decoded into memory.
|
||||||
void _precacheImage(int page, BuildContext context) {
|
void _precacheImage(int page, BuildContext context) {
|
||||||
precacheImage(
|
if (page <= 0 || page > context.reader.images!.length) {
|
||||||
_createImageProvider(page, context),
|
return;
|
||||||
context,
|
}
|
||||||
);
|
precacheImage(_createImageProvider(page, context), context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [_preDownloadImage] is used to download the image for the given page.
|
||||||
|
/// The image is downloaded using the [CacheManager] and saved to the local storage.
|
||||||
|
void _preDownloadImage(int page, BuildContext context) {
|
||||||
|
if (page <= 0 || page > context.reader.images!.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var reader = context.reader;
|
||||||
|
var imageKey = reader.images![page - 1];
|
||||||
|
if (imageKey.startsWith("file://")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var cid = reader.cid;
|
||||||
|
var eid = reader.eid;
|
||||||
|
var sourceKey = reader.type.comicSource?.key;
|
||||||
|
ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SwipeChangeChapterProgress extends StatefulWidget {
|
class _SwipeChangeChapterProgress extends StatefulWidget {
|
||||||
const _SwipeChangeChapterProgress({
|
const _SwipeChangeChapterProgress({this.controller, required this.isPrev});
|
||||||
this.controller,
|
|
||||||
required this.isPrev,
|
|
||||||
});
|
|
||||||
|
|
||||||
final ScrollController? controller;
|
final ScrollController? controller;
|
||||||
|
|
||||||
@@ -1161,7 +1261,12 @@ class _ProgressPainter extends CustomPainter {
|
|||||||
paint.color = color;
|
paint.color = color;
|
||||||
canvas.drawRRect(
|
canvas.drawRRect(
|
||||||
RRect.fromLTRBR(
|
RRect.fromLTRBR(
|
||||||
0, 0, size.width * value, size.height, Radius.circular(16)),
|
0,
|
||||||
|
0,
|
||||||
|
size.width * value,
|
||||||
|
size.height,
|
||||||
|
Radius.circular(16),
|
||||||
|
),
|
||||||
paint,
|
paint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -29,6 +29,7 @@ import 'package:venera/foundation/image_provider/reader_image.dart';
|
|||||||
import 'package:venera/foundation/local.dart';
|
import 'package:venera/foundation/local.dart';
|
||||||
import 'package:venera/foundation/log.dart';
|
import 'package:venera/foundation/log.dart';
|
||||||
import 'package:venera/foundation/res.dart';
|
import 'package:venera/foundation/res.dart';
|
||||||
|
import 'package:venera/network/images.dart';
|
||||||
import 'package:venera/pages/settings/settings_page.dart';
|
import 'package:venera/pages/settings/settings_page.dart';
|
||||||
import 'package:venera/utils/clipboard_image.dart';
|
import 'package:venera/utils/clipboard_image.dart';
|
||||||
import 'package:venera/utils/data_sync.dart';
|
import 'package:venera/utils/data_sync.dart';
|
||||||
@@ -110,7 +111,16 @@ class _ReaderState extends State<Reader>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
|
int get maxPage {
|
||||||
|
if (images == null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!showSingleImageOnFirstPage(cid, type)) {
|
||||||
|
return (images!.length / imagesPerPage(cid, type)).ceil();
|
||||||
|
} else {
|
||||||
|
return 1 + ((images!.length - 1) / imagesPerPage(cid, type)).ceil();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ComicType get type => widget.type;
|
ComicType get type => widget.type;
|
||||||
|
|
||||||
@@ -124,7 +134,8 @@ class _ReaderState extends State<Reader>
|
|||||||
late ReaderMode mode;
|
late ReaderMode mode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait;
|
bool get isPortrait =>
|
||||||
|
MediaQuery.of(context).orientation == Orientation.portrait;
|
||||||
|
|
||||||
History? history;
|
History? history;
|
||||||
|
|
||||||
@@ -151,14 +162,14 @@ class _ReaderState extends State<Reader>
|
|||||||
if (widget.initialPage != null) {
|
if (widget.initialPage != null) {
|
||||||
page = widget.initialPage!;
|
page = widget.initialPage!;
|
||||||
}
|
}
|
||||||
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
// mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||||
|
mode = ReaderMode.fromKey(appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'));
|
||||||
history = widget.history;
|
history = widget.history;
|
||||||
Future.microtask(() {
|
if (!appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSystemStatusBar')) {
|
||||||
updateHistory();
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
});
|
}
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) {
|
||||||
if (appdata.settings['enableTurnPageByVolumeKey']) {
|
handleVolumeEvent(cid, type);
|
||||||
handleVolumeEvent();
|
|
||||||
}
|
}
|
||||||
setImageCacheSize();
|
setImageCacheSize();
|
||||||
Future.delayed(const Duration(milliseconds: 200), () {
|
Future.delayed(const Duration(milliseconds: 200), () {
|
||||||
@@ -167,10 +178,18 @@ class _ReaderState extends State<Reader>
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
initImagesPerPage(widget.initialPage ?? 1);
|
if (!_isInitialized) {
|
||||||
|
initImagesPerPage(cid, type, widget.initialPage ?? 1);
|
||||||
|
_isInitialized = true;
|
||||||
|
} else {
|
||||||
|
// For orientation changed
|
||||||
|
_checkImagesPerPageChange(cid, type);
|
||||||
|
}
|
||||||
initReaderWindow();
|
initReaderWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,15 +230,21 @@ class _ReaderState extends State<Reader>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
_checkImagesPerPageChange();
|
_checkImagesPerPageChange(cid, type);
|
||||||
return KeyboardListener(
|
return KeyboardListener(
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onKeyEvent: onKeyEvent,
|
onKeyEvent: onKeyEvent,
|
||||||
child: _ReaderScaffold(
|
child: Overlay(
|
||||||
child: _ReaderGestureDetector(
|
initialEntries: [
|
||||||
child: _ReaderImages(key: Key(chapter.toString())),
|
OverlayEntry(builder: (context) {
|
||||||
),
|
return _ReaderScaffold(
|
||||||
|
child: _ReaderGestureDetector(
|
||||||
|
child: _ReaderImages(key: Key(chapter.toString())),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -250,7 +275,15 @@ class _ReaderState extends State<Reader>
|
|||||||
history!.page = images?.length ?? 1;
|
history!.page = images?.length ?? 1;
|
||||||
} else {
|
} else {
|
||||||
/// Record the first image of the page
|
/// Record the first image of the page
|
||||||
history!.page = (page - 1) * imagesPerPage + 1;
|
if (!showSingleImageOnFirstPage(cid, type) || imagesPerPage(cid, type) == 1) {
|
||||||
|
history!.page = (page - 1) * imagesPerPage(cid, type) + 1;
|
||||||
|
} else {
|
||||||
|
if (page == 1) {
|
||||||
|
history!.page = 1;
|
||||||
|
} else {
|
||||||
|
history!.page = (page - 2) * imagesPerPage(cid, type) + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
history!.maxPage = images?.length ?? 1;
|
history!.maxPage = images?.length ?? 1;
|
||||||
if (widget.chapters?.isGrouped ?? false) {
|
if (widget.chapters?.isGrouped ?? false) {
|
||||||
@@ -321,6 +354,8 @@ class _ReaderState extends State<Reader>
|
|||||||
abstract mixin class _ImagePerPageHandler {
|
abstract mixin class _ImagePerPageHandler {
|
||||||
late int _lastImagesPerPage;
|
late int _lastImagesPerPage;
|
||||||
|
|
||||||
|
late bool _lastOrientation;
|
||||||
|
|
||||||
bool get isPortrait;
|
bool get isPortrait;
|
||||||
|
|
||||||
int get page;
|
int get page;
|
||||||
@@ -329,46 +364,77 @@ abstract mixin class _ImagePerPageHandler {
|
|||||||
|
|
||||||
ReaderMode get mode;
|
ReaderMode get mode;
|
||||||
|
|
||||||
void initImagesPerPage(int initialPage) {
|
void initImagesPerPage(String cid, ComicType type, int initialPage) {
|
||||||
_lastImagesPerPage = imagesPerPage;
|
_lastImagesPerPage = imagesPerPage(cid, type);
|
||||||
if (imagesPerPage != 1) {
|
_lastOrientation = isPortrait;
|
||||||
page = (initialPage / imagesPerPage).ceil();
|
if (imagesPerPage(cid, type) != 1) {
|
||||||
|
if (showSingleImageOnFirstPage(cid, type)) {
|
||||||
|
page = ((initialPage - 1) / imagesPerPage(cid, type)).ceil() + 1;
|
||||||
|
} else {
|
||||||
|
page = (initialPage / imagesPerPage(cid, type)).ceil();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool showSingleImageOnFirstPage(String cid, ComicType type) =>
|
||||||
|
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
|
||||||
|
|
||||||
/// The number of images displayed on one screen
|
/// The number of images displayed on one screen
|
||||||
int get imagesPerPage {
|
int imagesPerPage(String cid, ComicType type) {
|
||||||
if (mode.isContinuous) return 1;
|
if (mode.isContinuous) return 1;
|
||||||
if (isPortrait) {
|
if (isPortrait) {
|
||||||
return appdata.settings['readerScreenPicNumberForPortrait'] ?? 1;
|
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
|
||||||
} else {
|
} else {
|
||||||
return appdata.settings['readerScreenPicNumberForLandscape'] ?? 1;
|
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForLandscape') ?? 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the number of images per page has changed
|
/// Check if the number of images per page has changed
|
||||||
void _checkImagesPerPageChange() {
|
void _checkImagesPerPageChange(String cid, ComicType type) {
|
||||||
int currentImagesPerPage = imagesPerPage;
|
int currentImagesPerPage = imagesPerPage(cid, type);
|
||||||
if (_lastImagesPerPage != currentImagesPerPage) {
|
bool currentOrientation = isPortrait;
|
||||||
|
|
||||||
|
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
|
||||||
_adjustPageForImagesPerPageChange(
|
_adjustPageForImagesPerPageChange(
|
||||||
_lastImagesPerPage, currentImagesPerPage);
|
cid, type, _lastImagesPerPage, currentImagesPerPage);
|
||||||
_lastImagesPerPage = currentImagesPerPage;
|
_lastImagesPerPage = currentImagesPerPage;
|
||||||
|
_lastOrientation = currentOrientation;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adjust the page number when the number of images per page changes
|
/// Adjust the page number when the number of images per page changes
|
||||||
void _adjustPageForImagesPerPageChange(
|
void _adjustPageForImagesPerPageChange(
|
||||||
int oldImagesPerPage, int newImagesPerPage) {
|
String cid, ComicType type, int oldImagesPerPage, int newImagesPerPage) {
|
||||||
int previousImageIndex = (page - 1) * oldImagesPerPage;
|
int previousImageIndex = 1;
|
||||||
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
|
if (!showSingleImageOnFirstPage(cid, type) || oldImagesPerPage == 1) {
|
||||||
page = newPage;
|
previousImageIndex = (page - 1) * oldImagesPerPage + 1;
|
||||||
|
} else {
|
||||||
|
if (page == 1) {
|
||||||
|
previousImageIndex = 1;
|
||||||
|
} else {
|
||||||
|
previousImageIndex = (page - 2) * oldImagesPerPage + 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int newPage;
|
||||||
|
if (newImagesPerPage != 1) {
|
||||||
|
if (showSingleImageOnFirstPage(cid, type)) {
|
||||||
|
newPage = ((previousImageIndex - 1) / newImagesPerPage).ceil() + 1;
|
||||||
|
} else {
|
||||||
|
newPage = (previousImageIndex / newImagesPerPage).ceil();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newPage = previousImageIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
page = newPage>0 ? newPage : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract mixin class _VolumeListener {
|
abstract mixin class _VolumeListener {
|
||||||
bool toNextPage();
|
bool toNextPage(String cid, ComicType type);
|
||||||
|
|
||||||
bool toPrevPage();
|
bool toPrevPage(String cid, ComicType type);
|
||||||
|
|
||||||
bool toNextChapter();
|
bool toNextChapter();
|
||||||
|
|
||||||
@@ -376,19 +442,19 @@ abstract mixin class _VolumeListener {
|
|||||||
|
|
||||||
VolumeListener? volumeListener;
|
VolumeListener? volumeListener;
|
||||||
|
|
||||||
void onDown() {
|
void onDown(String cid, ComicType type) {
|
||||||
if (!toNextPage()) {
|
if (!toNextPage(cid, type)) {
|
||||||
toNextChapter();
|
toNextChapter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onUp() {
|
void onUp(String cid, ComicType type) {
|
||||||
if (!toPrevPage()) {
|
if (!toPrevPage(cid, type)) {
|
||||||
toPrevChapter();
|
toPrevChapter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleVolumeEvent() {
|
void handleVolumeEvent(String cid, ComicType type) {
|
||||||
if (!App.isAndroid) {
|
if (!App.isAndroid) {
|
||||||
// Currently only support Android
|
// Currently only support Android
|
||||||
return;
|
return;
|
||||||
@@ -397,8 +463,8 @@ abstract mixin class _VolumeListener {
|
|||||||
volumeListener?.cancel();
|
volumeListener?.cancel();
|
||||||
}
|
}
|
||||||
volumeListener = VolumeListener(
|
volumeListener = VolumeListener(
|
||||||
onDown: onDown,
|
onDown: () => onDown(cid, type),
|
||||||
onUp: onUp,
|
onUp: () => onUp(cid, type),
|
||||||
)..listen();
|
)..listen();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,7 +496,7 @@ abstract mixin class _ReaderLocation {
|
|||||||
|
|
||||||
void update();
|
void update();
|
||||||
|
|
||||||
bool get enablePageAnimation => appdata.settings['enablePageAnimation'];
|
bool enablePageAnimation(String cid, ComicType type) => appdata.settings.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
|
||||||
|
|
||||||
_ImageViewController? _imageViewController;
|
_ImageViewController? _imageViewController;
|
||||||
|
|
||||||
@@ -449,25 +515,25 @@ abstract mixin class _ReaderLocation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the page is changed
|
/// Returns true if the page is changed
|
||||||
bool toNextPage() {
|
bool toNextPage(String cid, ComicType type) {
|
||||||
return toPage(page + 1);
|
return toPage(cid, type, page + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the page is changed
|
/// Returns true if the page is changed
|
||||||
bool toPrevPage() {
|
bool toPrevPage(String cid, ComicType type) {
|
||||||
return toPage(page - 1);
|
return toPage(cid, type, page - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
int _animationCount = 0;
|
int _animationCount = 0;
|
||||||
|
|
||||||
bool toPage(int page) {
|
bool toPage(String cid, ComicType type, int page) {
|
||||||
if (_validatePage(page)) {
|
if (_validatePage(page)) {
|
||||||
if (page == this.page && page != 1 && page != maxPage) {
|
if (page == this.page && page != 1 && page != maxPage) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.page = page;
|
this.page = page;
|
||||||
update();
|
update();
|
||||||
if (enablePageAnimation) {
|
if (enablePageAnimation(cid, type)) {
|
||||||
_animationCount++;
|
_animationCount++;
|
||||||
_imageViewController!.animateToPage(page).then((_) {
|
_imageViewController!.animateToPage(page).then((_) {
|
||||||
_animationCount--;
|
_animationCount--;
|
||||||
@@ -506,17 +572,17 @@ abstract mixin class _ReaderLocation {
|
|||||||
|
|
||||||
Timer? autoPageTurningTimer;
|
Timer? autoPageTurningTimer;
|
||||||
|
|
||||||
void autoPageTurning() {
|
void autoPageTurning(String cid, ComicType type) {
|
||||||
if (autoPageTurningTimer != null) {
|
if (autoPageTurningTimer != null) {
|
||||||
autoPageTurningTimer!.cancel();
|
autoPageTurningTimer!.cancel();
|
||||||
autoPageTurningTimer = null;
|
autoPageTurningTimer = null;
|
||||||
} else {
|
} else {
|
||||||
int interval = appdata.settings['autoPageTurningInterval'];
|
int interval = appdata.settings.getReaderSetting(cid, type.sourceKey, 'autoPageTurningInterval');
|
||||||
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
|
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
|
||||||
if (page == maxPage) {
|
if (page == maxPage) {
|
||||||
autoPageTurningTimer!.cancel();
|
autoPageTurningTimer!.cancel();
|
||||||
}
|
}
|
||||||
toNextPage();
|
toNextPage(cid, type);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,4 +669,6 @@ abstract interface class _ImageViewController {
|
|||||||
bool handleOnTap(Offset location);
|
bool handleOnTap(Offset location);
|
||||||
|
|
||||||
Future<Uint8List?> getImageByOffset(Offset offset);
|
Future<Uint8List?> getImageByOffset(Offset offset);
|
||||||
|
|
||||||
|
String? getImageKeyByOffset(Offset offset);
|
||||||
}
|
}
|
||||||
|
@@ -107,7 +107,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
if (!_isOpen) {
|
if (!_isOpen) {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
} else {
|
} else {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
if (!appdata.settings['showSystemStatusBar']) {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||||
|
} else {
|
||||||
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_isOpen = !_isOpen;
|
_isOpen = !_isOpen;
|
||||||
@@ -124,9 +128,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(child: widget.child),
|
||||||
child: widget.child,
|
|
||||||
),
|
|
||||||
if (appdata.settings['showPageNumberInReader'] == true)
|
if (appdata.settings['showPageNumberInReader'] == true)
|
||||||
buildPageInfoText(),
|
buildPageInfoText(),
|
||||||
buildStatusInfo(),
|
buildStatusInfo(),
|
||||||
@@ -164,10 +166,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.colorScheme.surface.toOpacity(0.92),
|
color: context.colorScheme.surface.toOpacity(0.92),
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(color: Colors.grey.toOpacity(0.5), width: 0.5),
|
||||||
color: Colors.grey.toOpacity(0.5),
|
|
||||||
width: 0.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -208,12 +207,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void addImageFavorite() {
|
void addImageFavorite() async {
|
||||||
try {
|
try {
|
||||||
if (context.reader.images![0].contains('file://')) {
|
if (context.reader.images![0].contains('file://')) {
|
||||||
showToast(
|
showToast(
|
||||||
message: "Local comic collection is not supported at present".tl,
|
message: "Local comic collection is not supported at present".tl,
|
||||||
context: context);
|
context: context,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String id = context.reader.cid;
|
String id = context.reader.cid;
|
||||||
@@ -222,14 +222,18 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
String title = context.reader.history!.title;
|
String title = context.reader.history!.title;
|
||||||
String subTitle = context.reader.history!.subtitle;
|
String subTitle = context.reader.history!.subtitle;
|
||||||
int maxPage = context.reader.images!.length;
|
int maxPage = context.reader.images!.length;
|
||||||
int page = context.reader.page;
|
int? page = await selectImage();
|
||||||
|
if (page == null) return;
|
||||||
|
page += 1;
|
||||||
String sourceKey = context.reader.type.sourceKey;
|
String sourceKey = context.reader.type.sourceKey;
|
||||||
String imageKey = context.reader.images![page - 1];
|
String imageKey = context.reader.images![page - 1];
|
||||||
List<String> tags = context.reader.widget.tags;
|
List<String> tags = context.reader.widget.tags;
|
||||||
String author = context.reader.widget.author;
|
String author = context.reader.widget.author;
|
||||||
|
|
||||||
var epName = context.reader.widget.chapters?.titles
|
var epName =
|
||||||
.elementAtOrNull(context.reader.chapter - 1) ??
|
context.reader.widget.chapters?.titles.elementAtOrNull(
|
||||||
|
context.reader.chapter - 1,
|
||||||
|
) ??
|
||||||
"E${context.reader.chapter}";
|
"E${context.reader.chapter}";
|
||||||
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
|
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
|
||||||
|
|
||||||
@@ -242,7 +246,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ImageFavoriteManager().deleteImageFavorite([
|
ImageFavoriteManager().deleteImageFavorite([
|
||||||
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName)
|
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName),
|
||||||
]);
|
]);
|
||||||
showToast(
|
showToast(
|
||||||
message: "Uncollected the image".tl,
|
message: "Uncollected the image".tl,
|
||||||
@@ -250,7 +254,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
seconds: 1,
|
seconds: 1,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
var imageFavoritesComic = ImageFavoriteManager().find(id, sourceKey) ??
|
var imageFavoritesComic =
|
||||||
|
ImageFavoriteManager().find(id, sourceKey) ??
|
||||||
ImageFavoritesComic(
|
ImageFavoritesComic(
|
||||||
id,
|
id,
|
||||||
[],
|
[],
|
||||||
@@ -264,12 +269,21 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
subTitle,
|
subTitle,
|
||||||
maxPage,
|
maxPage,
|
||||||
);
|
);
|
||||||
ImageFavorite imageFavorite =
|
ImageFavorite imageFavorite = ImageFavorite(
|
||||||
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName);
|
page,
|
||||||
ImageFavoritesEp? imageFavoritesEp =
|
imageKey,
|
||||||
imageFavoritesComic.imageFavoritesEp.firstWhereOrNull((e) {
|
null,
|
||||||
return e.ep == ep;
|
eid,
|
||||||
});
|
id,
|
||||||
|
ep,
|
||||||
|
sourceKey,
|
||||||
|
epName,
|
||||||
|
);
|
||||||
|
ImageFavoritesEp? imageFavoritesEp = imageFavoritesComic
|
||||||
|
.imageFavoritesEp
|
||||||
|
.firstWhereOrNull((e) {
|
||||||
|
return e.ep == ep;
|
||||||
|
});
|
||||||
if (imageFavoritesEp == null) {
|
if (imageFavoritesEp == null) {
|
||||||
if (page != firstPage) {
|
if (page != firstPage) {
|
||||||
var copy = imageFavorite.copyWith(
|
var copy = imageFavorite.copyWith(
|
||||||
@@ -279,10 +293,20 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
);
|
);
|
||||||
// 不是第一页的话, 自动塞一个封面进去
|
// 不是第一页的话, 自动塞一个封面进去
|
||||||
imageFavoritesEp = ImageFavoritesEp(
|
imageFavoritesEp = ImageFavoritesEp(
|
||||||
eid, ep, [copy, imageFavorite], epName, maxPage);
|
eid,
|
||||||
|
ep,
|
||||||
|
[copy, imageFavorite],
|
||||||
|
epName,
|
||||||
|
maxPage,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
imageFavoritesEp =
|
imageFavoritesEp = ImageFavoritesEp(
|
||||||
ImageFavoritesEp(eid, ep, [imageFavorite], epName, maxPage);
|
eid,
|
||||||
|
ep,
|
||||||
|
[imageFavorite],
|
||||||
|
epName,
|
||||||
|
maxPage,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp);
|
imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp);
|
||||||
} else {
|
} else {
|
||||||
@@ -306,7 +330,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
|
|
||||||
ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic);
|
ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic);
|
||||||
showToast(
|
showToast(
|
||||||
message: "Successfully collected".tl, context: context, seconds: 1);
|
message: "Successfully collected".tl,
|
||||||
|
context: context,
|
||||||
|
seconds: 1,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
update();
|
update();
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
@@ -325,44 +352,53 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
height: kBottomBarHeight,
|
height: kBottomBarHeight,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
const SizedBox(height: 8),
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: () => !isReversed
|
onPressed: () => !isReversed
|
||||||
? context.reader.chapter > 1
|
? context.reader.chapter > 1
|
||||||
? context.reader.toPrevChapter()
|
? context.reader.toPrevChapter()
|
||||||
: context.reader.toPage(1)
|
: context.reader.toPage(
|
||||||
|
context.reader.cid,
|
||||||
|
context.reader.type,
|
||||||
|
1,
|
||||||
|
)
|
||||||
: context.reader.chapter < context.reader.maxChapter
|
: context.reader.chapter < context.reader.maxChapter
|
||||||
? context.reader.toNextChapter()
|
? context.reader.toNextChapter()
|
||||||
: context.reader.toPage(context.reader.maxPage),
|
: context.reader.toPage(
|
||||||
|
context.reader.cid,
|
||||||
|
context.reader.type,
|
||||||
|
context.reader.maxPage,
|
||||||
|
),
|
||||||
icon: const Icon(Icons.first_page),
|
icon: const Icon(Icons.first_page),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(child: buildSlider()),
|
||||||
child: buildSlider(),
|
|
||||||
),
|
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: () => !isReversed
|
onPressed: () => !isReversed
|
||||||
? context.reader.chapter < context.reader.maxChapter
|
? context.reader.chapter < context.reader.maxChapter
|
||||||
? context.reader.toNextChapter()
|
? context.reader.toNextChapter()
|
||||||
: context.reader.toPage(context.reader.maxPage)
|
: context.reader.toPage(
|
||||||
: context.reader.chapter > 1
|
context.reader.cid,
|
||||||
? context.reader.toPrevChapter()
|
context.reader.type,
|
||||||
: context.reader.toPage(1),
|
context.reader.maxPage,
|
||||||
icon: const Icon(Icons.last_page)),
|
)
|
||||||
const SizedBox(
|
: context.reader.chapter > 1
|
||||||
width: 8,
|
? context.reader.toPrevChapter()
|
||||||
|
: context.reader.toPage(
|
||||||
|
context.reader.cid,
|
||||||
|
context.reader.type,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.last_page),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(
|
const SizedBox(width: 16),
|
||||||
width: 16,
|
|
||||||
),
|
|
||||||
Container(
|
Container(
|
||||||
height: 24,
|
height: 24,
|
||||||
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
|
padding: const EdgeInsets.fromLTRB(6, 2, 6, 0),
|
||||||
@@ -370,19 +406,19 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(child: Text(text)),
|
||||||
child: Text(text),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: "Collect the image".tl,
|
message: "Collect the image".tl,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isLiked() ? Icons.favorite : Icons.favorite_border),
|
isLiked() ? Icons.favorite : Icons.favorite_border,
|
||||||
onPressed: addImageFavorite),
|
),
|
||||||
|
onPressed: addImageFavorite,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (App.isWindows)
|
if (App.isDesktop)
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: "${"Full Screen".tl}(F12)",
|
message: "${"Full Screen".tl}(F12)",
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
@@ -420,14 +456,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
});
|
});
|
||||||
SystemChrome.setPreferredOrientations([
|
SystemChrome.setPreferredOrientations([
|
||||||
DeviceOrientation.landscapeLeft,
|
DeviceOrientation.landscapeLeft,
|
||||||
DeviceOrientation.landscapeRight
|
DeviceOrientation.landscapeRight,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
rotation = null;
|
rotation = null;
|
||||||
});
|
});
|
||||||
SystemChrome.setPreferredOrientations(
|
SystemChrome.setPreferredOrientations(
|
||||||
DeviceOrientation.values);
|
DeviceOrientation.values,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -439,7 +476,10 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
? const Icon(Icons.timer)
|
? const Icon(Icons.timer)
|
||||||
: const Icon(Icons.timer_sharp),
|
: const Icon(Icons.timer_sharp),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.reader.autoPageTurning();
|
context.reader.autoPageTurning(
|
||||||
|
context.reader.cid,
|
||||||
|
context.reader.type,
|
||||||
|
);
|
||||||
update();
|
update();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -466,9 +506,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
onPressed: share,
|
onPressed: share,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4)
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -499,19 +539,26 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
focusNode: sliderFocus,
|
focusNode: sliderFocus,
|
||||||
value: context.reader.page.toDouble(),
|
value: context.reader.page.toDouble(),
|
||||||
min: 1,
|
min: 1,
|
||||||
max:
|
max: context.reader.maxPage
|
||||||
context.reader.maxPage.clamp(context.reader.page, 1 << 16).toDouble(),
|
.clamp(context.reader.page, 1 << 16)
|
||||||
|
.toDouble(),
|
||||||
reversed: isReversed,
|
reversed: isReversed,
|
||||||
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
|
divisions: (context.reader.maxPage - 1).clamp(2, 1 << 16),
|
||||||
onChanged: (i) {
|
onChanged: (i) {
|
||||||
context.reader.toPage(i.toInt());
|
context.reader.toPage(
|
||||||
|
context.reader.cid,
|
||||||
|
context.reader.type,
|
||||||
|
i.toInt(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildPageInfoText() {
|
Widget buildPageInfoText() {
|
||||||
var epName = context.reader.widget.chapters?.titles
|
var epName =
|
||||||
.elementAtOrNull(context.reader.chapter - 1) ??
|
context.reader.widget.chapters?.titles.elementAtOrNull(
|
||||||
|
context.reader.chapter - 1,
|
||||||
|
) ??
|
||||||
"E${context.reader.chapter}";
|
"E${context.reader.chapter}";
|
||||||
if (epName.length > 8) {
|
if (epName.length > 8) {
|
||||||
epName = "${epName.substring(0, 8)}...";
|
epName = "${epName.substring(0, 8)}...";
|
||||||
@@ -570,94 +617,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Uint8List?> _getCurrentImageData() async {
|
|
||||||
var imageKey = context.reader.images![context.reader.page - 1];
|
|
||||||
var reader = context.reader;
|
|
||||||
if (context.reader.mode.isContinuous) {
|
|
||||||
var continuesState =
|
|
||||||
context.reader._imageViewController as _ContinuousModeState;
|
|
||||||
var imagesOnScreen =
|
|
||||||
continuesState.itemPositionsListener.itemPositions.value;
|
|
||||||
var images = imagesOnScreen
|
|
||||||
.map((e) => context.reader.images!.elementAtOrNull(e.index - 1))
|
|
||||||
.whereType<String>()
|
|
||||||
.toList();
|
|
||||||
String? selected;
|
|
||||||
if (images.length > 1) {
|
|
||||||
await showPopUpWidget(
|
|
||||||
context,
|
|
||||||
PopUpWidgetScaffold(
|
|
||||||
title: "Select an image on screen".tl,
|
|
||||||
body: GridView.builder(
|
|
||||||
itemCount: images.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
ImageProvider image;
|
|
||||||
var imageKey = images[index];
|
|
||||||
if (imageKey.startsWith('file://')) {
|
|
||||||
image = FileImage(File(imageKey.replaceFirst("file://", '')));
|
|
||||||
} else {
|
|
||||||
image = ReaderImageProvider(
|
|
||||||
imageKey,
|
|
||||||
reader.type.comicSource!.key,
|
|
||||||
reader.cid,
|
|
||||||
reader.eid,
|
|
||||||
reader.page,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return InkWell(
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
|
||||||
onTap: () {
|
|
||||||
selected = images[index];
|
|
||||||
App.rootContext.pop();
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
foregroundDecoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: Theme.of(context).colorScheme.outline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
child: Image(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
image: image,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(const EdgeInsets.all(8));
|
|
||||||
},
|
|
||||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
|
||||||
maxCrossAxisExtent: 200,
|
|
||||||
childAspectRatio: 0.7,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
selected = images.first;
|
|
||||||
}
|
|
||||||
if (selected == null) {
|
|
||||||
return null;
|
|
||||||
} else {
|
|
||||||
imageKey = selected!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (imageKey.startsWith("file://")) {
|
|
||||||
return await File(imageKey.substring(7)).readAsBytes();
|
|
||||||
} else {
|
|
||||||
return (await CacheManager().findCache(
|
|
||||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!
|
|
||||||
.readAsBytes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void saveCurrentImage() async {
|
void saveCurrentImage() async {
|
||||||
var data = await _getCurrentImageData();
|
var data = await selectImageToData();
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -667,30 +628,41 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void share() async {
|
void share() async {
|
||||||
var data = await _getCurrentImageData();
|
var data = await selectImageToData();
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var fileType = detectFileType(data);
|
var fileType = detectFileType(data);
|
||||||
var filename = "${context.reader.page}${fileType.ext}";
|
var filename = "${context.reader.page}${fileType.ext}";
|
||||||
Share.shareFile(
|
Share.shareFile(data: data, filename: filename, mime: fileType.mime);
|
||||||
data: data,
|
|
||||||
filename: filename,
|
|
||||||
mime: fileType.mime,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void openSetting() {
|
void openSetting() {
|
||||||
showSideBar(
|
showSideBar(
|
||||||
context,
|
context,
|
||||||
ReaderSettings(
|
ReaderSettings(
|
||||||
|
comicId: context.reader.cid,
|
||||||
|
comicSource: context.reader.type.sourceKey,
|
||||||
onChanged: (key) {
|
onChanged: (key) {
|
||||||
if (key == "readerMode") {
|
if (key == "readerMode") {
|
||||||
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
|
context.reader.mode = ReaderMode.fromKey(
|
||||||
|
appdata.settings.getReaderSetting(
|
||||||
|
context.reader.cid,
|
||||||
|
context.reader.type.sourceKey,
|
||||||
|
key,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (key == "enableTurnPageByVolumeKey") {
|
if (key == "enableTurnPageByVolumeKey") {
|
||||||
if (appdata.settings[key]) {
|
if (appdata.settings.getReaderSetting(
|
||||||
context.reader.handleVolumeEvent();
|
context.reader.cid,
|
||||||
|
context.reader.type.sourceKey,
|
||||||
|
key,
|
||||||
|
)) {
|
||||||
|
context.reader.handleVolumeEvent(
|
||||||
|
context.reader.cid,
|
||||||
|
context.reader.type,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
context.reader.stopVolumeEvent();
|
context.reader.stopVolumeEvent();
|
||||||
}
|
}
|
||||||
@@ -750,9 +722,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
? Icons.arrow_forward_ios
|
? Icons.arrow_forward_ios
|
||||||
: Icons.arrow_back_ios_outlined,
|
: Icons.arrow_back_ios_outlined,
|
||||||
size: 24,
|
size: 24,
|
||||||
color: Theme.of(context)
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
.colorScheme
|
|
||||||
.onPrimaryContainer,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -761,6 +731,77 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
}
|
}
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If there is only one image on screen, return it.
|
||||||
|
///
|
||||||
|
/// If there are multiple images on screen,
|
||||||
|
/// show an overlay to let the user select an image.
|
||||||
|
///
|
||||||
|
/// The return value is the index of the selected image.
|
||||||
|
Future<int?> selectImage() async {
|
||||||
|
var reader = context.reader;
|
||||||
|
var imageViewController = context.reader._imageViewController;
|
||||||
|
if (imageViewController is _GalleryModeState && reader.imagesPerPage == 1) {
|
||||||
|
return reader.page - 1;
|
||||||
|
} else {
|
||||||
|
var location = await _showSelectImageOverlay();
|
||||||
|
if (location == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var imageKey = imageViewController!.getImageKeyByOffset(location);
|
||||||
|
if (imageKey == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return reader.images!.indexOf(imageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [selectImage], but return the image data.
|
||||||
|
Future<Uint8List?> selectImageToData() async {
|
||||||
|
var i = await selectImage();
|
||||||
|
if (i == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var imageKey = context.reader.images![i];
|
||||||
|
if (imageKey.startsWith("file://")) {
|
||||||
|
return await File(imageKey.substring(7)).readAsBytes();
|
||||||
|
} else {
|
||||||
|
return (await CacheManager().findCache(
|
||||||
|
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||||
|
))!.readAsBytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Offset?> _showSelectImageOverlay() {
|
||||||
|
if (_isOpen) {
|
||||||
|
openOrClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var completer = Completer<Offset?>();
|
||||||
|
|
||||||
|
var overlay = Overlay.of(context);
|
||||||
|
OverlayEntry? entry;
|
||||||
|
entry = OverlayEntry(
|
||||||
|
builder: (context) {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: _SelectImageOverlayContent(
|
||||||
|
onTap: (offset) {
|
||||||
|
completer.complete(offset);
|
||||||
|
entry!.remove();
|
||||||
|
},
|
||||||
|
onDispose: () {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
overlay.insert(entry);
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BatteryWidget extends StatefulWidget {
|
class _BatteryWidget extends StatefulWidget {
|
||||||
@@ -853,20 +894,17 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
|
|||||||
size: 16,
|
size: 16,
|
||||||
color: batteryColor,
|
color: batteryColor,
|
||||||
// Stroke
|
// Stroke
|
||||||
shadows: List.generate(
|
shadows: List.generate(9, (index) {
|
||||||
9,
|
if (index == 4) {
|
||||||
(index) {
|
return null;
|
||||||
if (index == 4) {
|
}
|
||||||
return null;
|
double offsetX = (index % 3 - 1) * 0.8;
|
||||||
}
|
double offsetY = ((index / 3).floor() - 1) * 0.8;
|
||||||
double offsetX = (index % 3 - 1) * 0.8;
|
return Shadow(
|
||||||
double offsetY = ((index / 3).floor() - 1) * 0.8;
|
color: context.colorScheme.onInverseSurface,
|
||||||
return Shadow(
|
offset: Offset(offsetX, offsetY),
|
||||||
color: context.colorScheme.onInverseSurface,
|
);
|
||||||
offset: Offset(offsetX, offsetY),
|
}).whereType<Shadow>().toList(),
|
||||||
);
|
|
||||||
},
|
|
||||||
).whereType<Shadow>().toList(),
|
|
||||||
),
|
),
|
||||||
Stack(
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -941,3 +979,66 @@ class _ClockWidgetState extends State<_ClockWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SelectImageOverlayContent extends StatefulWidget {
|
||||||
|
const _SelectImageOverlayContent({
|
||||||
|
required this.onTap,
|
||||||
|
required this.onDispose,
|
||||||
|
});
|
||||||
|
|
||||||
|
final void Function(Offset) onTap;
|
||||||
|
|
||||||
|
final void Function() onDispose;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SelectImageOverlayContent> createState() =>
|
||||||
|
_SelectImageOverlayContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelectImageOverlayContentState
|
||||||
|
extends State<_SelectImageOverlayContent> {
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.onDispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTapUp: (details) {
|
||||||
|
widget.onTap(details.globalPosition);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withAlpha(50),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment(0, -0.8),
|
||||||
|
child: Container(
|
||||||
|
width: 232,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: context.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.info_outline),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Text(
|
||||||
|
"Click to select an image".tl,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: context.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -376,11 +376,16 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
controller.text =
|
controller.text =
|
||||||
controller.text.replaceLast(words[words.length - 1], "");
|
controller.text.replaceLast(words[words.length - 1], "");
|
||||||
}
|
}
|
||||||
if (type != null) {
|
final source = ComicSource.find(searchTarget);
|
||||||
controller.text += "${type.name}:$text ";
|
String insert;
|
||||||
|
if (source?.onTagSuggestionSelected != null) {
|
||||||
|
insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
|
||||||
} else {
|
} else {
|
||||||
controller.text += "$text ";
|
var t = text;
|
||||||
|
if (t.contains(' ')) t = "'$t'";
|
||||||
|
insert = type != null ? "${type.name}:$t" : t;
|
||||||
}
|
}
|
||||||
|
controller.text += "$insert ";
|
||||||
suggestions.clear();
|
suggestions.clear();
|
||||||
update();
|
update();
|
||||||
focusNode.requestFocus();
|
focusNode.requestFocus();
|
||||||
|
@@ -124,7 +124,7 @@ class _SearchResultPageState extends State<SearchResultPage> {
|
|||||||
options = widget.options ?? const [];
|
options = widget.options ?? const [];
|
||||||
validateOptions();
|
validateOptions();
|
||||||
appdata.addSearchHistory(text);
|
appdata.addSearchHistory(text);
|
||||||
suggestionsController = _SuggestionsController(controller);
|
suggestionsController = _SuggestionsController(controller, sourceKey);
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +213,8 @@ class _SuggestionsController {
|
|||||||
|
|
||||||
final SearchBarController controller;
|
final SearchBarController controller;
|
||||||
|
|
||||||
|
final String sourceKey;
|
||||||
|
|
||||||
OverlayEntry? entry;
|
OverlayEntry? entry;
|
||||||
|
|
||||||
void updateWidget() {
|
void updateWidget() {
|
||||||
@@ -270,7 +272,7 @@ class _SuggestionsController {
|
|||||||
find(TagsTranslation.cosplayerTags, TranslationType.cosplayer);
|
find(TagsTranslation.cosplayerTags, TranslationType.cosplayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
_SuggestionsController(this.controller);
|
_SuggestionsController(this.controller, this.sourceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Suggestions extends StatefulWidget {
|
class _Suggestions extends StatefulWidget {
|
||||||
@@ -400,14 +402,16 @@ class _SuggestionsState extends State<_Suggestions> {
|
|||||||
controller.text =
|
controller.text =
|
||||||
controller.text.replaceLast(words[words.length - 1], "");
|
controller.text.replaceLast(words[words.length - 1], "");
|
||||||
}
|
}
|
||||||
if (text.contains(' ')) {
|
final source = ComicSource.find(widget.controller.sourceKey);
|
||||||
text = "'$text'";
|
String insert;
|
||||||
}
|
if (source?.onTagSuggestionSelected != null) {
|
||||||
if (type != null) {
|
insert = source!.onTagSuggestionSelected!(type?.name ?? '', text);
|
||||||
controller.text += "${type.name}:$text ";
|
|
||||||
} else {
|
} else {
|
||||||
controller.text += "$text ";
|
var t = text;
|
||||||
|
if (t.contains(' ')) t = "'$t'";
|
||||||
|
insert = type != null ? "${type.name}:$t" : t;
|
||||||
}
|
}
|
||||||
|
controller.text += "$insert ";
|
||||||
widget.controller.suggestions.clear();
|
widget.controller.suggestions.clear();
|
||||||
widget.controller.remove();
|
widget.controller.remove();
|
||||||
}
|
}
|
||||||
|
@@ -96,10 +96,13 @@ Future<bool> checkUpdate() async {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
|
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true, bool delay = false]) async {
|
||||||
try {
|
try {
|
||||||
var value = await checkUpdate();
|
var value = await checkUpdate();
|
||||||
if (value) {
|
if (value) {
|
||||||
|
if (delay) {
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
}
|
||||||
showDialog(
|
showDialog(
|
||||||
context: App.rootContext,
|
context: App.rootContext,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
@@ -193,12 +193,46 @@ class LogsPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LogsPageState extends State<LogsPage> {
|
class _LogsPageState extends State<LogsPage> {
|
||||||
|
String logLevelToShow = "all";
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
var logToShow = logLevelToShow == "all"
|
||||||
|
? Log.logs
|
||||||
|
: Log.logs.where((log) => log.level.name == logLevelToShow).toList();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: Appbar(
|
appBar: Appbar(
|
||||||
title: const Text("Logs"),
|
title: Text("Logs".tl),
|
||||||
actions: [
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
final RelativeRect position = RelativeRect.fromLTRB(
|
||||||
|
MediaQuery.of(context).size.width,
|
||||||
|
MediaQuery.of(context).padding.top + kToolbarHeight,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
showMenu(context: context, position: position, items: [
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text("all"),
|
||||||
|
onTap: () => setState(() => logLevelToShow = "all")
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text("info"),
|
||||||
|
onTap: () => setState(() => logLevelToShow = "info")
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text("warning"),
|
||||||
|
onTap: () => setState(() => logLevelToShow = "warning")
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Text("error"),
|
||||||
|
onTap: () => setState(() => logLevelToShow = "error")
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
icon: const Icon(Icons.filter_list_outlined)
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
final RelativeRect position = RelativeRect.fromLTRB(
|
final RelativeRect position = RelativeRect.fromLTRB(
|
||||||
@@ -217,7 +251,7 @@ class _LogsPageState extends State<LogsPage> {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
Log.ignoreLimitation = true;
|
Log.ignoreLimitation = true;
|
||||||
context.showMessage(
|
context.showMessage(
|
||||||
message: "Only valid for this run");
|
message: "Only valid for this run".tl);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
@@ -232,9 +266,9 @@ class _LogsPageState extends State<LogsPage> {
|
|||||||
body: ListView.builder(
|
body: ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
controller: ScrollController(),
|
controller: ScrollController(),
|
||||||
itemCount: Log.logs.length,
|
itemCount: logToShow.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
index = Log.logs.length - index - 1;
|
index = logToShow.length - index - 1;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
child: SelectionArea(
|
child: SelectionArea(
|
||||||
@@ -253,7 +287,7 @@ class _LogsPageState extends State<LogsPage> {
|
|||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
|
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
|
||||||
child: Text(Log.logs[index].title),
|
child: Text(logToShow[index].title),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@@ -265,16 +299,16 @@ class _LogsPageState extends State<LogsPage> {
|
|||||||
Theme.of(context).colorScheme.error,
|
Theme.of(context).colorScheme.error,
|
||||||
Theme.of(context).colorScheme.errorContainer,
|
Theme.of(context).colorScheme.errorContainer,
|
||||||
Theme.of(context).colorScheme.primaryContainer
|
Theme.of(context).colorScheme.primaryContainer
|
||||||
][Log.logs[index].level.index],
|
][logToShow[index].level.index],
|
||||||
borderRadius:
|
borderRadius:
|
||||||
const BorderRadius.all(Radius.circular(16)),
|
const BorderRadius.all(Radius.circular(16)),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
|
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
|
||||||
child: Text(
|
child: Text(
|
||||||
Log.logs[index].level.name,
|
logToShow[index].level.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Log.logs[index].level.index == 0
|
color: logToShow[index].level.index == 0
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.black),
|
: Colors.black),
|
||||||
),
|
),
|
||||||
@@ -282,14 +316,14 @@ class _LogsPageState extends State<LogsPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Text(Log.logs[index].content),
|
Text(logToShow[index].content),
|
||||||
Text(Log.logs[index].time
|
Text(logToShow[index].time
|
||||||
.toString()
|
.toString()
|
||||||
.replaceAll(RegExp(r"\.\w+"), "")),
|
.replaceAll(RegExp(r"\.\w+"), "")),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Clipboard.setData(
|
Clipboard.setData(
|
||||||
ClipboardData(text: Log.logs[index].content));
|
ClipboardData(text: logToShow[index].content));
|
||||||
},
|
},
|
||||||
child: Text("Copy".tl),
|
child: Text("Copy".tl),
|
||||||
),
|
),
|
||||||
|
@@ -18,8 +18,8 @@ class DebugPageState extends State<DebugPage> {
|
|||||||
slivers: [
|
slivers: [
|
||||||
SliverAppbar(title: Text("Debug".tl)),
|
SliverAppbar(title: Text("Debug".tl)),
|
||||||
_CallbackSetting(
|
_CallbackSetting(
|
||||||
title: "Reload Configs",
|
title: "Reload Configs".tl,
|
||||||
actionTitle: "Reload",
|
actionTitle: "Reload".tl,
|
||||||
callback: () {
|
callback: () {
|
||||||
ComicSourceManager().reload();
|
ComicSourceManager().reload();
|
||||||
},
|
},
|
||||||
|
@@ -52,6 +52,10 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
|||||||
title: "Show history on comic tile".tl,
|
title: "Show history on comic tile".tl,
|
||||||
settingKey: "showHistoryStatusOnTile",
|
settingKey: "showHistoryStatusOnTile",
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
|
_SwitchSetting(
|
||||||
|
title: "Reverse default chapter order".tl,
|
||||||
|
settingKey: "reverseChapterOrder",
|
||||||
|
).toSliver(),
|
||||||
_PopupWindowSetting(
|
_PopupWindowSetting(
|
||||||
title: "Keyword blocking".tl,
|
title: "Keyword blocking".tl,
|
||||||
builder: () => const _ManageBlockingWordView(),
|
builder: () => const _ManageBlockingWordView(),
|
||||||
|
@@ -1,9 +1,16 @@
|
|||||||
part of 'settings_page.dart';
|
part of 'settings_page.dart';
|
||||||
|
|
||||||
class ReaderSettings extends StatefulWidget {
|
class ReaderSettings extends StatefulWidget {
|
||||||
const ReaderSettings({super.key, this.onChanged});
|
const ReaderSettings({
|
||||||
|
super.key,
|
||||||
|
this.onChanged,
|
||||||
|
this.comicId,
|
||||||
|
this.comicSource,
|
||||||
|
});
|
||||||
|
|
||||||
final void Function(String key)? onChanged;
|
final void Function(String key)? onChanged;
|
||||||
|
final String? comicId;
|
||||||
|
final String? comicSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ReaderSettings> createState() => _ReaderSettingsState();
|
State<ReaderSettings> createState() => _ReaderSettingsState();
|
||||||
@@ -21,6 +28,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
onChanged: () {
|
onChanged: () {
|
||||||
widget.onChanged?.call("enableTapToTurnPages");
|
widget.onChanged?.call("enableTapToTurnPages");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
_SwitchSetting(
|
_SwitchSetting(
|
||||||
title: "Reverse tap to turn Pages".tl,
|
title: "Reverse tap to turn Pages".tl,
|
||||||
@@ -28,6 +37,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
onChanged: () {
|
onChanged: () {
|
||||||
widget.onChanged?.call("reverseTapToTurnPages");
|
widget.onChanged?.call("reverseTapToTurnPages");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
_SwitchSetting(
|
_SwitchSetting(
|
||||||
title: "Page animation".tl,
|
title: "Page animation".tl,
|
||||||
@@ -35,6 +46,15 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
onChanged: () {
|
onChanged: () {
|
||||||
widget.onChanged?.call("enablePageAnimation");
|
widget.onChanged?.call("enablePageAnimation");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
|
).toSliver(),
|
||||||
|
_SwitchSetting(
|
||||||
|
title: "Enable comic specific settings".tl,
|
||||||
|
settingKey: "enableComicSpecificSettings",
|
||||||
|
onChanged: () {
|
||||||
|
widget.onChanged?.call("enableComicSpecificSettings");
|
||||||
|
},
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
SelectSetting(
|
SelectSetting(
|
||||||
title: "Reading mode".tl,
|
title: "Reading mode".tl,
|
||||||
@@ -58,6 +78,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
}
|
}
|
||||||
widget.onChanged?.call("readerMode");
|
widget.onChanged?.call("readerMode");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
_SliderSetting(
|
_SliderSetting(
|
||||||
title: "Auto page turning interval".tl,
|
title: "Auto page turning interval".tl,
|
||||||
@@ -66,8 +88,11 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
min: 1,
|
min: 1,
|
||||||
max: 20,
|
max: 20,
|
||||||
onChanged: () {
|
onChanged: () {
|
||||||
|
setState(() {});
|
||||||
widget.onChanged?.call("autoPageTurningInterval");
|
widget.onChanged?.call("autoPageTurningInterval");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
SliverAnimatedVisibility(
|
SliverAnimatedVisibility(
|
||||||
visible: appdata.settings['readerMode']!.startsWith('gallery'),
|
visible: appdata.settings['readerMode']!.startsWith('gallery'),
|
||||||
@@ -80,8 +105,11 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
min: 1,
|
min: 1,
|
||||||
max: 5,
|
max: 5,
|
||||||
onChanged: () {
|
onChanged: () {
|
||||||
|
setState(() {});
|
||||||
widget.onChanged?.call("readerScreenPicNumberForLandscape");
|
widget.onChanged?.call("readerScreenPicNumberForLandscape");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverAnimatedVisibility(
|
SliverAnimatedVisibility(
|
||||||
@@ -97,8 +125,35 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
onChanged: () {
|
onChanged: () {
|
||||||
widget.onChanged?.call("readerScreenPicNumberForPortrait");
|
widget.onChanged?.call("readerScreenPicNumberForPortrait");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverAnimatedVisibility(
|
||||||
|
visible:
|
||||||
|
appdata.settings['readerMode']!.startsWith('gallery') &&
|
||||||
|
(appdata.settings['readerScreenPicNumberForLandscape'] > 1 ||
|
||||||
|
appdata.settings['readerScreenPicNumberForPortrait'] > 1),
|
||||||
|
child: _SwitchSetting(
|
||||||
|
title: "Show single image on first page".tl,
|
||||||
|
settingKey: "showSingleImageOnFirstPage",
|
||||||
|
onChanged: () {
|
||||||
|
widget.onChanged?.call("showSingleImageOnFirstPage");
|
||||||
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_SwitchSetting(
|
||||||
|
title: 'Double tap to zoom'.tl,
|
||||||
|
settingKey: 'enableDoubleTapToZoom',
|
||||||
|
onChanged: () {
|
||||||
|
setState(() {});
|
||||||
|
widget.onChanged?.call('enableDoubleTapToZoom');
|
||||||
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
|
).toSliver(),
|
||||||
_SwitchSetting(
|
_SwitchSetting(
|
||||||
title: 'Long press to zoom'.tl,
|
title: 'Long press to zoom'.tl,
|
||||||
settingKey: 'enableLongPressToZoom',
|
settingKey: 'enableLongPressToZoom',
|
||||||
@@ -106,6 +161,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
widget.onChanged?.call('enableLongPressToZoom');
|
widget.onChanged?.call('enableLongPressToZoom');
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
SliverAnimatedVisibility(
|
SliverAnimatedVisibility(
|
||||||
visible: appdata.settings['enableLongPressToZoom'] == true,
|
visible: appdata.settings['enableLongPressToZoom'] == true,
|
||||||
@@ -116,6 +173,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
"press": "Press position".tl,
|
"press": "Press position".tl,
|
||||||
"center": "Screen center".tl,
|
"center": "Screen center".tl,
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_SwitchSetting(
|
_SwitchSetting(
|
||||||
@@ -125,6 +184,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
onChanged: () {
|
onChanged: () {
|
||||||
widget.onChanged?.call('limitImageWidth');
|
widget.onChanged?.call('limitImageWidth');
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
if (App.isAndroid)
|
if (App.isAndroid)
|
||||||
_SwitchSetting(
|
_SwitchSetting(
|
||||||
@@ -133,6 +194,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
onChanged: () {
|
onChanged: () {
|
||||||
widget.onChanged?.call('enableTurnPageByVolumeKey');
|
widget.onChanged?.call('enableTurnPageByVolumeKey');
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
_SwitchSetting(
|
_SwitchSetting(
|
||||||
title: "Display time & battery info in reader".tl,
|
title: "Display time & battery info in reader".tl,
|
||||||
@@ -140,6 +203,17 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
onChanged: () {
|
onChanged: () {
|
||||||
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
|
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
|
).toSliver(),
|
||||||
|
_SwitchSetting(
|
||||||
|
title: "Show system status bar".tl,
|
||||||
|
settingKey: "showSystemStatusBar",
|
||||||
|
onChanged: () {
|
||||||
|
widget.onChanged?.call("showSystemStatusBar");
|
||||||
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
SelectSetting(
|
SelectSetting(
|
||||||
title: "Quick collect image".tl,
|
title: "Quick collect image".tl,
|
||||||
@@ -155,6 +229,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
help:
|
help:
|
||||||
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
|
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
|
||||||
.tl,
|
.tl,
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
_CallbackSetting(
|
_CallbackSetting(
|
||||||
title: "Custom Image Processing".tl,
|
title: "Custom Image Processing".tl,
|
||||||
@@ -167,6 +243,8 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
interval: 1,
|
interval: 1,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 16,
|
max: 16,
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
_SwitchSetting(
|
_SwitchSetting(
|
||||||
title: "Show Page Number".tl,
|
title: "Show Page Number".tl,
|
||||||
@@ -174,7 +252,39 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
|||||||
onChanged: () {
|
onChanged: () {
|
||||||
widget.onChanged?.call("showPageNumberInReader");
|
widget.onChanged?.call("showPageNumberInReader");
|
||||||
},
|
},
|
||||||
|
comicId: widget.comicId,
|
||||||
|
comicSource: widget.comicSource,
|
||||||
).toSliver(),
|
).toSliver(),
|
||||||
|
// reset button
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (widget.comicId == null) {
|
||||||
|
appdata.settings.resetAllComicReaderSettings();
|
||||||
|
} else {
|
||||||
|
var keys = appdata
|
||||||
|
.settings['comicSpecificSettings']["${widget.comicId}@${widget.comicSource}"]
|
||||||
|
?.keys;
|
||||||
|
appdata.settings.resetComicReaderSettings(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
);
|
||||||
|
if (keys != null) {
|
||||||
|
setState(() {});
|
||||||
|
for (var key in keys) {
|
||||||
|
widget.onChanged?.call(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
(widget.comicId == null
|
||||||
|
? "Clear specific reader settings for all comics"
|
||||||
|
: "Clear specific reader settings for this comic")
|
||||||
|
.tl,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -219,7 +329,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
child: Text("Reset".tl),
|
child: Text("Reset".tl),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
@@ -245,7 +355,7 @@ class __CustomImageProcessingState extends State<_CustomImageProcessing> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@@ -6,6 +6,8 @@ class _SwitchSetting extends StatefulWidget {
|
|||||||
required this.settingKey,
|
required this.settingKey,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
|
this.comicId,
|
||||||
|
this.comicSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@@ -16,6 +18,10 @@ class _SwitchSetting extends StatefulWidget {
|
|||||||
|
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
|
|
||||||
|
final String? comicId;
|
||||||
|
|
||||||
|
final String? comicSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_SwitchSetting> createState() => _SwitchSettingState();
|
State<_SwitchSetting> createState() => _SwitchSettingState();
|
||||||
}
|
}
|
||||||
@@ -23,16 +29,33 @@ class _SwitchSetting extends StatefulWidget {
|
|||||||
class _SwitchSettingState extends State<_SwitchSetting> {
|
class _SwitchSettingState extends State<_SwitchSetting> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
assert(appdata.settings[widget.settingKey] is bool);
|
var value = widget.comicId == null
|
||||||
|
? appdata.settings[widget.settingKey]
|
||||||
|
: appdata.settings.getReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(value is bool);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(widget.title),
|
title: Text(widget.title),
|
||||||
subtitle: widget.subtitle == null ? null : Text(widget.subtitle!),
|
subtitle: widget.subtitle == null ? null : Text(widget.subtitle!),
|
||||||
trailing: Switch(
|
trailing: Switch(
|
||||||
value: appdata.settings[widget.settingKey],
|
value: value,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
appdata.settings[widget.settingKey] = value;
|
if (widget.comicId == null) {
|
||||||
|
appdata.settings[widget.settingKey] = value;
|
||||||
|
} else {
|
||||||
|
appdata.settings.setReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingKey,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
appdata.saveData().then((_) {
|
appdata.saveData().then((_) {
|
||||||
widget.onChanged?.call();
|
widget.onChanged?.call();
|
||||||
@@ -51,6 +74,8 @@ class SelectSetting extends StatelessWidget {
|
|||||||
required this.optionTranslation,
|
required this.optionTranslation,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.help,
|
this.help,
|
||||||
|
this.comicId,
|
||||||
|
this.comicSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@@ -63,6 +88,10 @@ class SelectSetting extends StatelessWidget {
|
|||||||
|
|
||||||
final String? help;
|
final String? help;
|
||||||
|
|
||||||
|
final String? comicId;
|
||||||
|
|
||||||
|
final String? comicSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -76,6 +105,8 @@ class SelectSetting extends StatelessWidget {
|
|||||||
optionTranslation: optionTranslation,
|
optionTranslation: optionTranslation,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
help: help,
|
help: help,
|
||||||
|
comicId: comicId,
|
||||||
|
comicSource: comicSource,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return _EndSelectorSelectSetting(
|
return _EndSelectorSelectSetting(
|
||||||
@@ -84,6 +115,8 @@ class SelectSetting extends StatelessWidget {
|
|||||||
optionTranslation: optionTranslation,
|
optionTranslation: optionTranslation,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
help: help,
|
help: help,
|
||||||
|
comicId: comicId,
|
||||||
|
comicSource: comicSource,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,6 +132,8 @@ class _DoubleLineSelectSettings extends StatefulWidget {
|
|||||||
required this.optionTranslation,
|
required this.optionTranslation,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.help,
|
this.help,
|
||||||
|
this.comicId,
|
||||||
|
this.comicSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@@ -111,6 +146,10 @@ class _DoubleLineSelectSettings extends StatefulWidget {
|
|||||||
|
|
||||||
final String? help;
|
final String? help;
|
||||||
|
|
||||||
|
final String? comicId;
|
||||||
|
|
||||||
|
final String? comicSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_DoubleLineSelectSettings> createState() =>
|
State<_DoubleLineSelectSettings> createState() =>
|
||||||
_DoubleLineSelectSettingsState();
|
_DoubleLineSelectSettingsState();
|
||||||
@@ -119,6 +158,14 @@ class _DoubleLineSelectSettings extends StatefulWidget {
|
|||||||
class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
var value = widget.comicId == null
|
||||||
|
? appdata.settings[widget.settingKey]
|
||||||
|
: appdata.settings.getReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingKey,
|
||||||
|
);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -134,9 +181,9 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
return ContentDialog(
|
return ContentDialog(
|
||||||
title: "Help".tl,
|
title: "Help".tl,
|
||||||
content: Text(widget.help!)
|
content: Text(
|
||||||
.paddingHorizontal(16)
|
widget.help!,
|
||||||
.fixWidth(double.infinity),
|
).paddingHorizontal(16).fixWidth(double.infinity),
|
||||||
actions: [
|
actions: [
|
||||||
Button.filled(
|
Button.filled(
|
||||||
onPressed: context.pop,
|
onPressed: context.pop,
|
||||||
@@ -150,9 +197,7 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(widget.optionTranslation[value] ?? "None"),
|
||||||
widget.optionTranslation[appdata.settings[widget.settingKey]] ??
|
|
||||||
"None"),
|
|
||||||
trailing: const Icon(Icons.arrow_drop_down),
|
trailing: const Icon(Icons.arrow_drop_down),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
var renderBox = context.findRenderObject() as RenderBox;
|
var renderBox = context.findRenderObject() as RenderBox;
|
||||||
@@ -170,16 +215,27 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
|
|||||||
Offset.zero & MediaQuery.of(context).size,
|
Offset.zero & MediaQuery.of(context).size,
|
||||||
),
|
),
|
||||||
items: widget.optionTranslation.keys
|
items: widget.optionTranslation.keys
|
||||||
.map((key) => PopupMenuItem(
|
.map(
|
||||||
value: key,
|
(key) => PopupMenuItem(
|
||||||
height: App.isMobile ? 46 : 40,
|
value: key,
|
||||||
child: Text(widget.optionTranslation[key]!),
|
height: App.isMobile ? 46 : 40,
|
||||||
))
|
child: Text(widget.optionTranslation[key]!),
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
).then((value) {
|
).then((value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
appdata.settings[widget.settingKey] = value;
|
if (widget.comicId == null) {
|
||||||
|
appdata.settings[widget.settingKey] = value;
|
||||||
|
} else {
|
||||||
|
appdata.settings.setReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingKey,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
appdata.saveData();
|
appdata.saveData();
|
||||||
widget.onChanged?.call();
|
widget.onChanged?.call();
|
||||||
@@ -197,6 +253,8 @@ class _EndSelectorSelectSetting extends StatefulWidget {
|
|||||||
required this.optionTranslation,
|
required this.optionTranslation,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.help,
|
this.help,
|
||||||
|
this.comicId,
|
||||||
|
this.comicSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@@ -209,6 +267,10 @@ class _EndSelectorSelectSetting extends StatefulWidget {
|
|||||||
|
|
||||||
final String? help;
|
final String? help;
|
||||||
|
|
||||||
|
final String? comicId;
|
||||||
|
|
||||||
|
final String? comicSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_EndSelectorSelectSetting> createState() =>
|
State<_EndSelectorSelectSetting> createState() =>
|
||||||
_EndSelectorSelectSettingState();
|
_EndSelectorSelectSettingState();
|
||||||
@@ -218,6 +280,13 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var options = widget.optionTranslation;
|
var options = widget.optionTranslation;
|
||||||
|
var value = widget.comicId == null
|
||||||
|
? appdata.settings[widget.settingKey]
|
||||||
|
: appdata.settings.getReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingKey,
|
||||||
|
);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -233,9 +302,9 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
return ContentDialog(
|
return ContentDialog(
|
||||||
title: "Help".tl,
|
title: "Help".tl,
|
||||||
content: Text(widget.help!)
|
content: Text(
|
||||||
.paddingHorizontal(16)
|
widget.help!,
|
||||||
.fixWidth(double.infinity),
|
).paddingHorizontal(16).fixWidth(double.infinity),
|
||||||
actions: [
|
actions: [
|
||||||
Button.filled(
|
Button.filled(
|
||||||
onPressed: context.pop,
|
onPressed: context.pop,
|
||||||
@@ -250,12 +319,22 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: Select(
|
trailing: Select(
|
||||||
current: options[appdata.settings[widget.settingKey]],
|
current: options[value],
|
||||||
values: options.values.toList(),
|
values: options.values.toList(),
|
||||||
minWidth: 64,
|
minWidth: 64,
|
||||||
onTap: (index) {
|
onTap: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
appdata.settings[widget.settingKey] = options.keys.elementAt(index);
|
var value = options.keys.elementAt(index);
|
||||||
|
if (widget.comicId == null) {
|
||||||
|
appdata.settings[widget.settingKey] = value;
|
||||||
|
} else {
|
||||||
|
appdata.settings.setReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingKey,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
appdata.saveData();
|
appdata.saveData();
|
||||||
widget.onChanged?.call();
|
widget.onChanged?.call();
|
||||||
@@ -273,6 +352,8 @@ class _SliderSetting extends StatefulWidget {
|
|||||||
required this.min,
|
required this.min,
|
||||||
required this.max,
|
required this.max,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
|
this.comicId,
|
||||||
|
this.comicSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
@@ -287,6 +368,10 @@ class _SliderSetting extends StatefulWidget {
|
|||||||
|
|
||||||
final VoidCallback? onChanged;
|
final VoidCallback? onChanged;
|
||||||
|
|
||||||
|
final String? comicId;
|
||||||
|
|
||||||
|
final String? comicSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_SliderSetting> createState() => _SliderSettingState();
|
State<_SliderSetting> createState() => _SliderSettingState();
|
||||||
}
|
}
|
||||||
@@ -294,28 +379,52 @@ class _SliderSetting extends StatefulWidget {
|
|||||||
class _SliderSettingState extends State<_SliderSetting> {
|
class _SliderSettingState extends State<_SliderSetting> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
var value =
|
||||||
|
(widget.comicId == null
|
||||||
|
? appdata.settings[widget.settingsIndex]
|
||||||
|
: appdata.settings.getReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingsIndex,
|
||||||
|
))
|
||||||
|
.toDouble();
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(widget.title),
|
Text(widget.title),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(value.toString(), style: ts.s12),
|
||||||
appdata.settings[widget.settingsIndex].toString(),
|
|
||||||
style: ts.s12,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Slider(
|
subtitle: Slider(
|
||||||
value: appdata.settings[widget.settingsIndex].toDouble(),
|
value: value,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
if (value.toInt() == value) {
|
if (value.toInt() == value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
appdata.settings[widget.settingsIndex] = value.toInt();
|
if (widget.comicId == null) {
|
||||||
|
appdata.settings[widget.settingsIndex] = value.toInt();
|
||||||
|
} else {
|
||||||
|
appdata.settings.setReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingsIndex,
|
||||||
|
value.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
appdata.saveData();
|
appdata.saveData();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
appdata.settings[widget.settingsIndex] = value;
|
if (widget.comicId == null) {
|
||||||
|
appdata.settings[widget.settingsIndex] = value;
|
||||||
|
} else {
|
||||||
|
appdata.settings.setReaderSetting(
|
||||||
|
widget.comicId!,
|
||||||
|
widget.comicSource!,
|
||||||
|
widget.settingsIndex,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
appdata.saveData();
|
appdata.saveData();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -402,10 +511,11 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
|||||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black12,
|
color: Colors.black12,
|
||||||
blurRadius: 5,
|
blurRadius: 5,
|
||||||
offset: Offset(0, 2),
|
offset: Offset(0, 2),
|
||||||
spreadRadius: 2)
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onReorder: (reorderFunc) {
|
onReorder: (reorderFunc) {
|
||||||
@@ -435,7 +545,7 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
|||||||
label: Text("Add".tl),
|
label: Text("Add".tl),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
onPressed: showAddDialog,
|
onPressed: showAddDialog,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
body: view,
|
body: view,
|
||||||
);
|
);
|
||||||
@@ -445,12 +555,13 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
|||||||
Widget removeButton = Padding(
|
Widget removeButton = Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
keys.remove(key);
|
keys.remove(key);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.delete_outline)),
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -458,10 +569,7 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
|||||||
key: Key(key),
|
key: Key(key),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [removeButton, const Icon(Icons.drag_handle)],
|
||||||
removeButton,
|
|
||||||
const Icon(Icons.drag_handle),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -477,64 +585,66 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return StatefulBuilder(builder: (context, setState) {
|
return StatefulBuilder(
|
||||||
return ContentDialog(
|
builder: (context, setState) {
|
||||||
title: "Add".tl,
|
return ContentDialog(
|
||||||
content: Column(
|
title: "Add".tl,
|
||||||
mainAxisSize: MainAxisSize.min,
|
content: Column(
|
||||||
children: canAdd.entries
|
mainAxisSize: MainAxisSize.min,
|
||||||
.map(
|
children: canAdd.entries
|
||||||
(e) => CheckboxListTile(
|
.map(
|
||||||
value: selected.contains(e.key),
|
(e) => CheckboxListTile(
|
||||||
title: Text(e.value),
|
value: selected.contains(e.key),
|
||||||
key: Key(e.key),
|
title: Text(e.value),
|
||||||
onChanged: (value) {
|
key: Key(e.key),
|
||||||
setState(() {
|
onChanged: (value) {
|
||||||
if (value!) {
|
setState(() {
|
||||||
selected.add(e.key);
|
if (value!) {
|
||||||
} else {
|
selected.add(e.key);
|
||||||
selected.remove(e.key);
|
} else {
|
||||||
}
|
selected.remove(e.key);
|
||||||
});
|
}
|
||||||
},
|
});
|
||||||
),
|
},
|
||||||
)
|
),
|
||||||
.toList(),
|
)
|
||||||
),
|
.toList(),
|
||||||
actions: [
|
|
||||||
if (selected.length < canAdd.length)
|
|
||||||
TextButton(
|
|
||||||
child: Text("Select All".tl),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
selected = canAdd.keys.toList();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else
|
|
||||||
TextButton(
|
|
||||||
child: Text("Deselect All".tl),
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
selected.clear();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: selected.isNotEmpty
|
|
||||||
? () {
|
|
||||||
this.setState(() {
|
|
||||||
keys.addAll(selected);
|
|
||||||
});
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
child: Text("Add".tl),
|
|
||||||
),
|
),
|
||||||
],
|
actions: [
|
||||||
);
|
if (selected.length < canAdd.length)
|
||||||
});
|
TextButton(
|
||||||
|
child: Text("Select All".tl),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
selected = canAdd.keys.toList();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextButton(
|
||||||
|
child: Text("Deselect All".tl),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
selected.clear();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: selected.isNotEmpty
|
||||||
|
? () {
|
||||||
|
this.setState(() {
|
||||||
|
keys.addAll(selected);
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
child: Text("Add".tl),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,7 @@ import 'package:url_launcher/url_launcher_string.dart';
|
|||||||
import 'package:venera/components/components.dart';
|
import 'package:venera/components/components.dart';
|
||||||
import 'package:venera/foundation/app.dart';
|
import 'package:venera/foundation/app.dart';
|
||||||
import 'package:venera/foundation/appdata.dart';
|
import 'package:venera/foundation/appdata.dart';
|
||||||
import 'package:venera/network/app_dio.dart';
|
import 'package:venera/network/proxy.dart';
|
||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/utils/ext.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
import 'dart:io' as io;
|
import 'dart:io' as io;
|
||||||
@@ -308,7 +308,7 @@ class DesktopWebview {
|
|||||||
useWindowPositionAndSize: true,
|
useWindowPositionAndSize: true,
|
||||||
userDataFolderWindows: "${App.dataPath}\\webview",
|
userDataFolderWindows: "${App.dataPath}\\webview",
|
||||||
title: "webview",
|
title: "webview",
|
||||||
proxy: AppDio.proxy,
|
proxy: await getProxy(),
|
||||||
));
|
));
|
||||||
_webview!.addOnWebMessageReceivedCallback(onMessage);
|
_webview!.addOnWebMessageReceivedCallback(onMessage);
|
||||||
_webview!.setOnNavigation((s) {
|
_webview!.setOnNavigation((s) {
|
||||||
|
@@ -112,7 +112,7 @@ abstract class CBZ {
|
|||||||
var ext = e.path.split('.').last;
|
var ext = e.path.split('.').last;
|
||||||
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
|
return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext);
|
||||||
});
|
});
|
||||||
if(files.isEmpty) {
|
if (files.isEmpty) {
|
||||||
cache.deleteSync(recursive: true);
|
cache.deleteSync(recursive: true);
|
||||||
throw Exception('No images found in the archive');
|
throw Exception('No images found in the archive');
|
||||||
}
|
}
|
||||||
@@ -141,8 +141,7 @@ abstract class CBZ {
|
|||||||
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
|
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
|
||||||
);
|
);
|
||||||
dest.createSync();
|
dest.createSync();
|
||||||
coverFile.copyMem(
|
coverFile.copyMem(FilePath.join(dest.path, 'cover.${coverFile.extension}'));
|
||||||
FilePath.join(dest.path, 'cover.${coverFile.extension}'));
|
|
||||||
if (metaData.chapters == null) {
|
if (metaData.chapters == null) {
|
||||||
for (var i = 0; i < files.length; i++) {
|
for (var i = 0; i < files.length; i++) {
|
||||||
var src = files[i];
|
var src = files[i];
|
||||||
@@ -233,17 +232,19 @@ abstract class CBZ {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var cover = comic.coverFile;
|
var cover = comic.coverFile;
|
||||||
await cover
|
await cover.copyMem(
|
||||||
.copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
|
FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
|
||||||
|
final metaData = ComicMetaData(
|
||||||
|
title: comic.title,
|
||||||
|
author: comic.subtitle,
|
||||||
|
tags: comic.tags,
|
||||||
|
chapters: chapters,
|
||||||
|
);
|
||||||
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
|
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
|
||||||
jsonEncode(
|
jsonEncode(metaData),
|
||||||
ComicMetaData(
|
);
|
||||||
title: comic.title,
|
await File(FilePath.join(cache.path, 'ComicInfo.xml')).writeAsString(
|
||||||
author: comic.subtitle,
|
_buildComicInfoXml(metaData),
|
||||||
tags: comic.tags,
|
|
||||||
chapters: chapters,
|
|
||||||
).toJson(),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
var cbz = File(outFilePath);
|
var cbz = File(outFilePath);
|
||||||
if (cbz.existsSync()) cbz.deleteSync();
|
if (cbz.existsSync()) cbz.deleteSync();
|
||||||
@@ -252,7 +253,54 @@ abstract class CBZ {
|
|||||||
return cbz;
|
return cbz;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _buildComicInfoXml(ComicMetaData data) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
buffer.writeln('<?xml version="1.0" encoding="utf-8"?>');
|
||||||
|
buffer.writeln('<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">');
|
||||||
|
|
||||||
|
buffer.writeln(' <Title>${_escapeXml(data.title)}</Title>');
|
||||||
|
buffer.writeln(' <Series>${_escapeXml(data.title)}</Series>');
|
||||||
|
|
||||||
|
if (data.author.isNotEmpty) {
|
||||||
|
buffer.writeln(' <Writer>${_escapeXml(data.author)}</Writer>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.tags.isNotEmpty) {
|
||||||
|
var tags = data.tags;
|
||||||
|
if (tags.length > 5) {
|
||||||
|
tags = tags.sublist(0, 5);
|
||||||
|
}
|
||||||
|
buffer.writeln(' <Genre>${_escapeXml(tags.join(', '))}</Genre>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.chapters != null && data.chapters!.isNotEmpty) {
|
||||||
|
final chaptersInfo = data.chapters!.map((chapter) =>
|
||||||
|
'${_escapeXml(chapter.title)}: ${chapter.start}-${chapter.end}'
|
||||||
|
).join('; ');
|
||||||
|
buffer.writeln(' <Notes>Chapters: $chaptersInfo</Notes>');
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln(' <Manga>Unknown</Manga>');
|
||||||
|
buffer.writeln(' <BlackAndWhite>Unknown</BlackAndWhite>');
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
buffer.writeln(' <Year>${now.year}</Year>');
|
||||||
|
|
||||||
|
buffer.writeln('</ComicInfo>');
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _escapeXml(String text) {
|
||||||
|
return text
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
static _compress(String src, String dst) async {
|
static _compress(String src, String dst) async {
|
||||||
await ZipFile.compressFolderAsync(src, dst, 4);
|
await ZipFile.compressFolderAsync(src, dst, 4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -11,7 +11,6 @@ import 'package:venera/network/app_dio.dart';
|
|||||||
import 'package:venera/utils/data.dart';
|
import 'package:venera/utils/data.dart';
|
||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/utils/ext.dart';
|
||||||
import 'package:webdav_client/webdav_client.dart' hide File;
|
import 'package:webdav_client/webdav_client.dart' hide File;
|
||||||
import 'package:rhttp/rhttp.dart' as rhttp;
|
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
|
|
||||||
import 'io.dart';
|
import 'io.dart';
|
||||||
@@ -23,10 +22,12 @@ class DataSync with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
LocalFavoritesManager().addListener(onDataChanged);
|
LocalFavoritesManager().addListener(onDataChanged);
|
||||||
ComicSourceManager().addListener(onDataChanged);
|
ComicSourceManager().addListener(onDataChanged);
|
||||||
Future.delayed(const Duration(seconds: 1), () {
|
if (App.isDesktop) {
|
||||||
var controller = WindowFrame.of(App.rootContext);
|
Future.delayed(const Duration(seconds: 1), () {
|
||||||
controller.addCloseListener(_handleWindowClose);
|
var controller = WindowFrame.of(App.rootContext);
|
||||||
});
|
controller.addCloseListener(_handleWindowClose);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onDataChanged() {
|
void onDataChanged() {
|
||||||
@@ -119,19 +120,11 @@ class DataSync with ChangeNotifier {
|
|||||||
String user = config[1];
|
String user = config[1];
|
||||||
String pass = config[2];
|
String pass = config[2];
|
||||||
|
|
||||||
var proxy = await AppDio.getProxy();
|
|
||||||
|
|
||||||
var client = newClient(
|
var client = newClient(
|
||||||
url,
|
url,
|
||||||
user: user,
|
user: user,
|
||||||
password: pass,
|
password: pass,
|
||||||
adapter: RHttpAdapter(
|
adapter: RHttpAdapter(),
|
||||||
rhttp.ClientSettings(
|
|
||||||
proxySettings:
|
|
||||||
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
|
|
||||||
userAgent: "venera v${App.version}",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -192,19 +185,11 @@ class DataSync with ChangeNotifier {
|
|||||||
String user = config[1];
|
String user = config[1];
|
||||||
String pass = config[2];
|
String pass = config[2];
|
||||||
|
|
||||||
var proxy = await AppDio.getProxy();
|
|
||||||
|
|
||||||
var client = newClient(
|
var client = newClient(
|
||||||
url,
|
url,
|
||||||
user: user,
|
user: user,
|
||||||
password: pass,
|
password: pass,
|
||||||
adapter: RHttpAdapter(
|
adapter: RHttpAdapter(),
|
||||||
rhttp.ClientSettings(
|
|
||||||
proxySettings:
|
|
||||||
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
|
|
||||||
userAgent: "venera v${App.version}",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@@ -107,4 +107,15 @@ abstract class MapOrNull{
|
|||||||
static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? i){
|
static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? i){
|
||||||
return i == null ? null : Map<K, V>.from(i);
|
return i == null ? null : Map<K, V>.from(i);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FutureExt<T> on Future<T>{
|
||||||
|
/// Wrap the future to make sure it will return at least the duration.
|
||||||
|
Future<T> minTime(Duration duration) async {
|
||||||
|
var res = await Future.wait([
|
||||||
|
this,
|
||||||
|
Future.delayed(duration),
|
||||||
|
]);
|
||||||
|
return res[0];
|
||||||
|
}
|
||||||
}
|
}
|
@@ -28,6 +28,8 @@ final _resolver = MimeTypeResolver()
|
|||||||
..addMagicNumber([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed')
|
..addMagicNumber([0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 'application/x-7z-compressed')
|
||||||
// rar
|
// rar
|
||||||
..addMagicNumber([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar')
|
..addMagicNumber([0x52, 0x61, 0x72, 0x21, 0x1A, 0x07], 'application/vnd.rar')
|
||||||
|
// avif
|
||||||
|
..addMagicNumber([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66], 'image/avif')
|
||||||
;
|
;
|
||||||
|
|
||||||
FileType detectFileType(List<int> data) {
|
FileType detectFileType(List<int> data) {
|
||||||
|
@@ -132,15 +132,15 @@ extension DirectoryExtension on Directory {
|
|||||||
|
|
||||||
/// Sanitize the file name. Remove invalid characters and trim the file name.
|
/// Sanitize the file name. Remove invalid characters and trim the file name.
|
||||||
String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
|
String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
|
||||||
if (fileName.endsWith('.')) {
|
while (fileName.endsWith('.')) {
|
||||||
fileName = fileName.substring(0, fileName.length - 1);
|
fileName = fileName.substring(0, fileName.length - 1);
|
||||||
}
|
}
|
||||||
var maxLength = 255;
|
var length = maxLength ?? 255;
|
||||||
if (dir != null) {
|
if (dir != null) {
|
||||||
if (!dir.endsWith('/') && !dir.endsWith('\\')) {
|
if (!dir.endsWith('/') && !dir.endsWith('\\')) {
|
||||||
dir = "$dir/";
|
dir = "$dir/";
|
||||||
}
|
}
|
||||||
maxLength -= dir.length;
|
length -= dir.length;
|
||||||
}
|
}
|
||||||
final invalidChars = RegExp(r'[<>:"/\\|?*]');
|
final invalidChars = RegExp(r'[<>:"/\\|?*]');
|
||||||
final sanitizedFileName = fileName.replaceAll(invalidChars, ' ');
|
final sanitizedFileName = fileName.replaceAll(invalidChars, ' ');
|
||||||
@@ -148,11 +148,11 @@ String sanitizeFileName(String fileName, {String? dir, int? maxLength}) {
|
|||||||
if (trimmedFileName.isEmpty) {
|
if (trimmedFileName.isEmpty) {
|
||||||
throw Exception('Invalid File Name: Empty length.');
|
throw Exception('Invalid File Name: Empty length.');
|
||||||
}
|
}
|
||||||
if (maxLength <= 0) {
|
if (length <= 0) {
|
||||||
throw Exception('Invalid File Name: Max length is less than 0.');
|
throw Exception('Invalid File Name: Max length is less than 0.');
|
||||||
}
|
}
|
||||||
if (trimmedFileName.length > maxLength) {
|
if (trimmedFileName.length > length) {
|
||||||
trimmedFileName = trimmedFileName.substring(0, maxLength);
|
trimmedFileName = trimmedFileName.substring(0, length);
|
||||||
}
|
}
|
||||||
return trimmedFileName;
|
return trimmedFileName;
|
||||||
}
|
}
|
||||||
|
67
lib/utils/opencc.dart
Normal file
67
lib/utils/opencc.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
abstract class OpenCC {
|
||||||
|
static late final Map<int, int> _s2t;
|
||||||
|
static late final Map<int, int> _t2s;
|
||||||
|
|
||||||
|
static Future<void> init() async {
|
||||||
|
var data = await rootBundle.load("assets/opencc.txt");
|
||||||
|
var txt = utf8.decode(data.buffer.asUint8List());
|
||||||
|
_s2t = <int, int>{};
|
||||||
|
_t2s = <int, int>{};
|
||||||
|
for (var line in txt.split('\n')) {
|
||||||
|
if (line.isEmpty || line.startsWith('#') || line.length != 2) continue;
|
||||||
|
var s = line.runes.elementAt(0);
|
||||||
|
var t = line.runes.elementAt(1);
|
||||||
|
_s2t[s] = t;
|
||||||
|
_t2s[t] = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hasChineseSimplified(String text) {
|
||||||
|
if (text != "监禁") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (var rune in text.runes) {
|
||||||
|
if (_s2t.containsKey(rune)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool hasChineseTraditional(String text) {
|
||||||
|
for (var rune in text.runes) {
|
||||||
|
if (_t2s.containsKey(rune)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String simplifiedToTraditional(String text) {
|
||||||
|
var sb = StringBuffer();
|
||||||
|
for (var rune in text.runes) {
|
||||||
|
if (_s2t.containsKey(rune)) {
|
||||||
|
sb.write(String.fromCharCodes([_s2t[rune]!]));
|
||||||
|
} else {
|
||||||
|
sb.write(String.fromCharCodes([rune]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String traditionalToSimplified(String text) {
|
||||||
|
var sb = StringBuffer();
|
||||||
|
for (var rune in text.runes) {
|
||||||
|
if (_t2s.containsKey(rune)) {
|
||||||
|
sb.write(String.fromCharCodes([_t2s[rune]!]));
|
||||||
|
} else {
|
||||||
|
sb.write(String.fromCharCodes([rune]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
|
import 'package:flutter_saf/flutter_saf.dart';
|
||||||
import 'package:venera/foundation/app.dart';
|
import 'package:venera/foundation/app.dart';
|
||||||
import 'package:venera/foundation/local.dart';
|
import 'package:venera/foundation/local.dart';
|
||||||
import 'package:venera/utils/image.dart';
|
import 'package:venera/utils/image.dart';
|
||||||
@@ -74,6 +75,9 @@ Future<Isolate> _runIsolate(
|
|||||||
return Isolate.spawn<SendPort>(
|
return Isolate.spawn<SendPort>(
|
||||||
(sendPort) => overrideIO(
|
(sendPort) => overrideIO(
|
||||||
() async {
|
() async {
|
||||||
|
if (App.isAndroid) {
|
||||||
|
await SAFTaskWorker().init();
|
||||||
|
}
|
||||||
var receivePort = ReceivePort();
|
var receivePort = ReceivePort();
|
||||||
sendPort.send(receivePort.sendPort);
|
sendPort.send(receivePort.sendPort);
|
||||||
|
|
||||||
|
@@ -35,8 +35,10 @@ extension TagsTranslation on String{
|
|||||||
/// 对tag进行处理后进行翻译: 代表'或'的分割符'|', namespace.
|
/// 对tag进行处理后进行翻译: 代表'或'的分割符'|', namespace.
|
||||||
static String _translateTags(String tag){
|
static String _translateTags(String tag){
|
||||||
if (tag.contains('|')) {
|
if (tag.contains('|')) {
|
||||||
var splits = tag.split(' | ');
|
var splits = tag.split('|');
|
||||||
return enTagsTranslations[splits[0]]??enTagsTranslations[splits[1]]??tag;
|
return enTagsTranslations[splits[0].trim()]
|
||||||
|
?? enTagsTranslations[splits[1].trim()]
|
||||||
|
?? tag;
|
||||||
} else if(tag.contains(':')) {
|
} else if(tag.contains(':')) {
|
||||||
var splits = tag.split(':');
|
var splits = tag.split(':');
|
||||||
if(_haveNamespace(splits[0])) {
|
if(_haveNamespace(splits[0])) {
|
||||||
|
72
pubspec.lock
72
pubspec.lock
@@ -170,6 +170,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
display_mode:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: display_mode
|
||||||
|
sha256: "8a381f3602a09dc4e96140a0df30808631468d6d0dfff7722f67b1f83757a7cc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.0.2"
|
||||||
dynamic_color:
|
dynamic_color:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -178,6 +186,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.7.0"
|
version: "1.7.0"
|
||||||
|
enough_convert:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: enough_convert
|
||||||
|
sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -308,18 +324,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: flutter_inappwebview
|
path: flutter_inappwebview
|
||||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||||
source: git
|
source: git
|
||||||
version: "6.2.0-beta.3"
|
version: "6.2.0-beta.3"
|
||||||
flutter_inappwebview_android:
|
flutter_inappwebview_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
path: flutter_inappwebview_android
|
path: flutter_inappwebview_android
|
||||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||||
source: git
|
source: git
|
||||||
version: "1.2.0-beta.3"
|
version: "1.2.0-beta.3"
|
||||||
flutter_inappwebview_internal_annotations:
|
flutter_inappwebview_internal_annotations:
|
||||||
@@ -334,45 +350,45 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
path: flutter_inappwebview_ios
|
path: flutter_inappwebview_ios
|
||||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||||
source: git
|
source: git
|
||||||
version: "1.2.0-beta.3"
|
version: "1.2.0-beta.3"
|
||||||
flutter_inappwebview_macos:
|
flutter_inappwebview_macos:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
path: flutter_inappwebview_macos
|
path: flutter_inappwebview_macos
|
||||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||||
source: git
|
source: git
|
||||||
version: "1.2.0-beta.3"
|
version: "1.2.0-beta.3"
|
||||||
flutter_inappwebview_platform_interface:
|
flutter_inappwebview_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
path: flutter_inappwebview_platform_interface
|
path: flutter_inappwebview_platform_interface
|
||||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||||
source: git
|
source: git
|
||||||
version: "1.4.0-beta.3"
|
version: "1.4.0-beta.3"
|
||||||
flutter_inappwebview_web:
|
flutter_inappwebview_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
path: flutter_inappwebview_web
|
path: flutter_inappwebview_web
|
||||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||||
source: git
|
source: git
|
||||||
version: "1.2.0-beta.3"
|
version: "1.2.0-beta.3"
|
||||||
flutter_inappwebview_windows:
|
flutter_inappwebview_windows:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
path: flutter_inappwebview_windows
|
path: flutter_inappwebview_windows
|
||||||
ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
resolved-ref: "0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676"
|
resolved-ref: "3ef899b3db57c911b080979f1392253b835f98ab"
|
||||||
url: "https://github.com/pichillilorenzo/flutter_inappwebview"
|
url: "https://github.com/venera-app/flutter_inappwebview"
|
||||||
source: git
|
source: git
|
||||||
version: "0.7.0-beta.3"
|
version: "0.7.0-beta.3"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
@@ -425,10 +441,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_rust_bridge
|
name: flutter_rust_bridge
|
||||||
sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611"
|
sha256: b416ff56002789e636244fb4cc449f587656eff995e5a7169457eb0593fcaddb
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.0"
|
version: "2.10.0"
|
||||||
flutter_saf:
|
flutter_saf:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -758,11 +774,11 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: rhttp
|
path: rhttp
|
||||||
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
|
||||||
resolved-ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
resolved-ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
|
||||||
url: "https://github.com/wgh136/rhttp"
|
url: "https://github.com/wgh136/rhttp"
|
||||||
source: git
|
source: git
|
||||||
version: "0.11.0"
|
version: "0.12.0"
|
||||||
screen_retriever:
|
screen_retriever:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1099,5 +1115,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.12"
|
version: "0.0.12"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.7.0 <4.0.0"
|
dart: ">=3.8.0 <4.0.0"
|
||||||
flutter: ">=3.29.2"
|
flutter: ">=3.32.6"
|
||||||
|
19
pubspec.yaml
19
pubspec.yaml
@@ -2,11 +2,11 @@ name: venera
|
|||||||
description: "A comic app."
|
description: "A comic app."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.4.0+140
|
version: 1.4.6+146
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.6.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
flutter: 3.29.2
|
flutter: 3.32.6
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
@@ -46,9 +46,9 @@ dependencies:
|
|||||||
ref: 7801fc582ecf5a7351632887891ecf309a7b2583
|
ref: 7801fc582ecf5a7351632887891ecf309a7b2583
|
||||||
flutter_inappwebview:
|
flutter_inappwebview:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/pichillilorenzo/flutter_inappwebview
|
url: https://github.com/venera-app/flutter_inappwebview
|
||||||
path: flutter_inappwebview
|
path: flutter_inappwebview
|
||||||
ref: 0aaf7a0bfc01d61a4d1453cefb57fb6783b6e676
|
ref: 3ef899b3db57c911b080979f1392253b835f98ab
|
||||||
app_links: ^6.4.0
|
app_links: ^6.4.0
|
||||||
sliver_tools: ^0.2.12
|
sliver_tools: ^0.2.12
|
||||||
flutter_file_dialog: ^3.0.2
|
flutter_file_dialog: ^3.0.2
|
||||||
@@ -58,10 +58,10 @@ dependencies:
|
|||||||
git:
|
git:
|
||||||
url: https://github.com/venera-app/lodepng_flutter
|
url: https://github.com/venera-app/lodepng_flutter
|
||||||
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
||||||
rhttp:
|
rhttp:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/wgh136/rhttp
|
url: https://github.com/wgh136/rhttp
|
||||||
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
ref: 1f0ff50336062c5f809c256726dc55cd30b9ce59
|
||||||
path: rhttp
|
path: rhttp
|
||||||
webdav_client:
|
webdav_client:
|
||||||
git:
|
git:
|
||||||
@@ -85,6 +85,8 @@ dependencies:
|
|||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
yaml: ^3.1.3
|
yaml: ^3.1.3
|
||||||
|
enough_convert: ^1.6.0
|
||||||
|
display_mode: ^0.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -101,6 +103,7 @@ flutter:
|
|||||||
- assets/app_icon.png
|
- assets/app_icon.png
|
||||||
- assets/tags.json
|
- assets/tags.json
|
||||||
- assets/tags_tw.json
|
- assets/tags_tw.json
|
||||||
|
- assets/opencc.txt
|
||||||
|
|
||||||
flutter_to_arch:
|
flutter_to_arch:
|
||||||
name: Venera
|
name: Venera
|
||||||
|
150
update_alt_store.py
Normal file
150
update_alt_store.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import json
|
||||||
|
import plistlib
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def prepare_description(text):
|
||||||
|
text = re.sub('<[^<]+?>', '', text) # Remove HTML tags
|
||||||
|
text = re.sub(r'#{1,6}\s?', '', text) # Remove markdown header tags
|
||||||
|
text = re.sub(r'\*{2}', '', text) # Remove all occurrences of two consecutive asterisks
|
||||||
|
text = re.sub(r'(?<=\r|\n)-', '•', text) # Only replace - with • if it is preceded by \r or \n
|
||||||
|
text = re.sub(r'`', '"', text) # Replace ` with "
|
||||||
|
text = re.sub(r'\r\n\r\n', '\r \n', text) # Replace \r\n\r\n with \r \n (avoid incorrect display of the description regarding paragraphs)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def fetch_latest_release(repo_url):
|
||||||
|
api_url = f"https://api.github.com/repos/{repo_url}/releases"
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.get(api_url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
release = response.json()
|
||||||
|
return release
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"Error fetching releases: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_file_size(url):
|
||||||
|
try:
|
||||||
|
response = requests.head(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
return int(response.headers.get('Content-Length', 0))
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print(f"Error getting file size: {e}")
|
||||||
|
return 194586
|
||||||
|
|
||||||
|
def update_json_file_release(json_file, latest_release):
|
||||||
|
if isinstance(latest_release, list) and latest_release:
|
||||||
|
latest_release = latest_release[0]
|
||||||
|
else:
|
||||||
|
print("Error getting latest release")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(json_file, "r") as file:
|
||||||
|
data = json.load(file)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Error reading JSON file: {e}")
|
||||||
|
data = {"apps": []}
|
||||||
|
raise
|
||||||
|
|
||||||
|
app = data["apps"][0]
|
||||||
|
|
||||||
|
full_version = latest_release["tag_name"]
|
||||||
|
tag = latest_release["tag_name"]
|
||||||
|
# Extract version like 1.4.5 from tag, which may be like 'v1.4.5'
|
||||||
|
version_match = re.search(r"(\d+\.\d+\.\d+)", full_version)
|
||||||
|
if version_match:
|
||||||
|
version = version_match.group(1)
|
||||||
|
else:
|
||||||
|
print("Error: Could not parse version from tag_name.")
|
||||||
|
return
|
||||||
|
version_date = latest_release["published_at"]
|
||||||
|
date_obj = datetime.strptime(version_date, "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
version_date = date_obj.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
description = latest_release["body"]
|
||||||
|
description = prepare_description(description)
|
||||||
|
|
||||||
|
assets = latest_release.get("assets", [])
|
||||||
|
download_url = None
|
||||||
|
size = None
|
||||||
|
for asset in assets:
|
||||||
|
# venera-ios-1.4.5+145.ipa
|
||||||
|
if asset["name"] == f"venera-ios-{version}+{version.replace('.', '')}.ipa":
|
||||||
|
download_url = asset["browser_download_url"]
|
||||||
|
size = asset["size"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if download_url is None or size is None:
|
||||||
|
print("Error: IPA file not found in release assets.")
|
||||||
|
return
|
||||||
|
|
||||||
|
version_entry = {
|
||||||
|
"version": version,
|
||||||
|
"date": version_date,
|
||||||
|
"localizedDescription": description,
|
||||||
|
"downloadURL": download_url,
|
||||||
|
"size": size
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate_entries = [item for item in app["versions"] if item["version"] == version]
|
||||||
|
if duplicate_entries:
|
||||||
|
app["versions"].remove(duplicate_entries[0])
|
||||||
|
|
||||||
|
app["versions"].insert(0, version_entry)
|
||||||
|
|
||||||
|
app.update({
|
||||||
|
"version": version,
|
||||||
|
"versionDate": version_date,
|
||||||
|
"versionDescription": description,
|
||||||
|
"downloadURL": download_url,
|
||||||
|
"size": size
|
||||||
|
})
|
||||||
|
|
||||||
|
if "news" not in data:
|
||||||
|
data["news"] = []
|
||||||
|
|
||||||
|
news_identifier = f"release-{full_version}"
|
||||||
|
date_string = date_obj.strftime("%d/%m/%y")
|
||||||
|
news_entry = {
|
||||||
|
"appID": "com.github.wgh136.venera",
|
||||||
|
"caption": f"Update of Venera just got released!",
|
||||||
|
"date": latest_release["published_at"],
|
||||||
|
"identifier": news_identifier,
|
||||||
|
"notify": True,
|
||||||
|
"tintColor": "#0784FC",
|
||||||
|
"title": f"{full_version} - Venera {date_string}",
|
||||||
|
"url": f"https://github.com/venera-app/venera/releases/tag/{tag}"
|
||||||
|
}
|
||||||
|
|
||||||
|
news_entry_exists = any(item["identifier"] == news_identifier for item in data["news"])
|
||||||
|
if not news_entry_exists:
|
||||||
|
data["news"].append(news_entry)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(json_file, "w") as file:
|
||||||
|
json.dump(data, file, indent=2)
|
||||||
|
print("JSON file updated successfully.")
|
||||||
|
except IOError as e:
|
||||||
|
print(f"Error writing to JSON file: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def main():
|
||||||
|
repo_url = "venera-app/venera"
|
||||||
|
is_nightly = "NIGHTLY_LINK" in os.environ
|
||||||
|
|
||||||
|
try:
|
||||||
|
fetched_data_latest = fetch_latest_release(repo_url)
|
||||||
|
json_file = "alt_store.json"
|
||||||
|
update_json_file_release(json_file, fetched_data_latest)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An error occurred: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
@@ -10,11 +10,16 @@
|
|||||||
#include <flutter/event_stream_handler_functions.h>
|
#include <flutter/event_stream_handler_functions.h>
|
||||||
#include <flutter/standard_method_codec.h>
|
#include <flutter/standard_method_codec.h>
|
||||||
#include "flutter/generated_plugin_registrant.h"
|
#include "flutter/generated_plugin_registrant.h"
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
#define _CRT_SECURE_NO_WARNINGS
|
#define _CRT_SECURE_NO_WARNINGS
|
||||||
|
|
||||||
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&& mouseEvents = nullptr;
|
std::unique_ptr<flutter::EventSink<flutter::EncodableValue>>&& mouseEvents = nullptr;
|
||||||
|
|
||||||
|
std::atomic<bool> mainThreadAlive(true);
|
||||||
|
std::atomic<std::chrono::steady_clock::time_point> lastHeartbeat(std::chrono::steady_clock::now());
|
||||||
|
std::thread* monitorThread = nullptr;
|
||||||
|
|
||||||
char* wideCharToMultiByte(wchar_t* pWCStrKey)
|
char* wideCharToMultiByte(wchar_t* pWCStrKey)
|
||||||
{
|
{
|
||||||
size_t pSize = WideCharToMultiByte(CP_OEMCP, 0, pWCStrKey, wcslen(pWCStrKey), NULL, 0, NULL, NULL);
|
size_t pSize = WideCharToMultiByte(CP_OEMCP, 0, pWCStrKey, wcslen(pWCStrKey), NULL, 0, NULL, NULL);
|
||||||
@@ -45,6 +50,22 @@ FlutterWindow::FlutterWindow(const flutter::DartProject& project)
|
|||||||
|
|
||||||
FlutterWindow::~FlutterWindow() {}
|
FlutterWindow::~FlutterWindow() {}
|
||||||
|
|
||||||
|
void monitorUIThread() {
|
||||||
|
const auto timeout = std::chrono::seconds(5);
|
||||||
|
|
||||||
|
while (mainThreadAlive.load()) {
|
||||||
|
auto now = std::chrono::steady_clock::now();
|
||||||
|
auto duration = now - lastHeartbeat.load();
|
||||||
|
|
||||||
|
if (duration > timeout) {
|
||||||
|
std::cerr << "The UI thread is dead. Terminate the application.";
|
||||||
|
std::exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool FlutterWindow::OnCreate() {
|
bool FlutterWindow::OnCreate() {
|
||||||
if (!Win32Window::OnCreate()) {
|
if (!Win32Window::OnCreate()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -77,7 +98,20 @@ bool FlutterWindow::OnCreate() {
|
|||||||
else
|
else
|
||||||
result->Success(flutter::EncodableValue("No Proxy"));
|
result->Success(flutter::EncodableValue("No Proxy"));
|
||||||
delete(res);
|
delete(res);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
#ifdef NDEBUG
|
||||||
|
else if (call.method_name() == "heartBeat") {
|
||||||
|
|
||||||
|
if (monitorThread == nullptr) {
|
||||||
|
monitorThread = new std::thread{ monitorUIThread };
|
||||||
|
}
|
||||||
|
lastHeartbeat = std::chrono::steady_clock::now();
|
||||||
|
result->Success();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
result->Success(); // Default response for unhandled method calls
|
||||||
});
|
});
|
||||||
|
|
||||||
flutter::EventChannel<> channel2(
|
flutter::EventChannel<> channel2(
|
||||||
@@ -163,6 +197,10 @@ void FlutterWindow::OnDestroy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Win32Window::OnDestroy();
|
Win32Window::OnDestroy();
|
||||||
|
if (monitorThread != nullptr) {
|
||||||
|
mainThreadAlive = false;
|
||||||
|
monitorThread->join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void mouse_side_button_listener(unsigned int input)
|
void mouse_side_button_listener(unsigned int input)
|
||||||
|
Reference in New Issue
Block a user