Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ada8f3af75 | ||
|
|
fb940d3982 | ||
|
|
8e8729c25f | ||
|
|
ffdd063ca1 | ||
|
|
dfd5c6c70e | ||
|
|
3068aab681 | ||
|
|
e402587f8f | ||
|
|
00b1be879d | ||
|
|
e63bd49a4c | ||
|
|
8131170eb1 | ||
|
|
dc1abd3806 | ||
|
|
468fbd50da | ||
|
|
f2011f9b6f | ||
|
|
1e53a9b389 | ||
|
|
dc2504b67f | ||
|
|
2ae3e3b64c | ||
|
|
e6bdaf1172 | ||
|
|
36a66d9199 | ||
|
|
fb6fbb8df9 | ||
|
|
ef8449dc02 | ||
|
|
b59f8e9ade | ||
|
|
a49506f4a0 | ||
|
|
4ce3d25eed | ||
|
|
81b166bee1 | ||
|
|
37a587859a | ||
|
|
b7544630bb | ||
|
|
05c7a0fa39 | ||
|
|
eee7655f80 | ||
|
|
9390813c48 | ||
|
|
d032fbdd67 | ||
|
|
adcd73a590 | ||
|
|
87b405bf1f | ||
|
|
d8306e2c80 | ||
|
|
c03fe9b51e | ||
|
|
9099014d28 | ||
|
|
d7b3872486 | ||
|
|
4ae2af0bef | ||
|
|
c9bcd46b77 | ||
|
|
e531653634 | ||
|
|
a29e4c72b1 | ||
|
|
9e1f7ca051 | ||
|
|
fbbc347411 | ||
|
|
ea364536a4 | ||
|
|
575fc9e94c | ||
|
|
703c63125a | ||
|
|
b205b5e120 | ||
|
|
e27f36d2c8 | ||
|
|
d582eddc03 | ||
|
|
3b83d8744c | ||
|
|
68a5217b1c | ||
|
|
c01082b822 | ||
|
|
bb95c86320 | ||
|
|
d398926aeb | ||
|
|
7b23efb0ce | ||
|
|
7818ff197c | ||
|
|
95bfc4f8c5 | ||
|
|
c750016034 | ||
|
|
6298e501cd | ||
|
|
7c39b95025 | ||
|
|
02c1e630d1 | ||
|
|
8f3b0cc536 | ||
|
|
d0c5ef8e8a | ||
|
|
d628024637 | ||
|
|
fe1249630b | ||
|
|
cdbe3cf728 | ||
|
|
f5e36bc0cf | ||
|
|
4f9bfe13cb | ||
|
|
959da21b9d | ||
|
|
fae2b1f416 | ||
|
|
a054633d0a | ||
|
|
a4619547f1 | ||
|
|
5d53febb15 | ||
|
|
3da1587924 | ||
|
|
971d0307fe | ||
|
|
705d3afb78 | ||
|
|
bc34b4b9c1 | ||
|
|
a2ac01fcdc | ||
|
|
645ebddd4f | ||
|
|
d5d8a828ef | ||
|
|
96d4193d2d | ||
|
|
18e4afdff9 | ||
|
|
19a702ffc4 | ||
|
|
cfd44c256f | ||
|
|
4cea8d0090 | ||
|
|
30e166b29c | ||
|
|
c50f3d8c5b | ||
|
|
d9249eeedb | ||
|
|
51699cafe6 | ||
|
|
ce97976bdd | ||
|
|
7b8a12e3eb | ||
|
|
7276c37c54 | ||
|
|
4ec7de611c | ||
|
|
0295c40b02 | ||
|
|
035fa44523 | ||
|
|
60c6618db1 | ||
|
|
f35643b2b6 | ||
|
|
b25661c436 | ||
|
|
cea06f0c7e | ||
|
|
7de6a821d9 | ||
|
|
13be4e4eb1 | ||
|
|
691e7c3ba7 | ||
|
|
31aea1cdbf | ||
|
|
97d5642427 | ||
|
|
c68272c1a4 | ||
|
|
a69dc994b3 | ||
|
|
d79a71c764 | ||
|
|
35183de477 | ||
|
|
21b9fe3cb2 | ||
|
|
5762b0bfc3 | ||
|
|
3473e91747 | ||
|
|
6277d7d41d | ||
|
|
f22da2b771 | ||
|
|
3466bbd64a | ||
|
|
49fc87621c | ||
|
|
7e558f4478 | ||
|
|
307624ca9a | ||
|
|
40e66b919a | ||
|
|
cf2e0aadd5 | ||
|
|
1b523f6a78 | ||
|
|
6a9f6bb645 | ||
|
|
45602b05c6 | ||
|
|
8dc842e125 | ||
|
|
9dfc3a49fe | ||
|
|
97d4a61140 | ||
|
|
8b596e2274 | ||
|
|
123b86830b | ||
|
|
e11f5615a1 | ||
|
|
31fb2ed2b1 | ||
|
|
9c12d11c85 | ||
|
|
aea0e885c3 | ||
|
|
f34ff71145 | ||
|
|
820c16e86b | ||
|
|
a335348b1a | ||
|
|
7f134e1995 | ||
|
|
9e13c1c3db | ||
|
|
61161cef0f | ||
|
|
9ff74e789c | ||
|
|
45183df640 | ||
|
|
22f11994f5 | ||
|
|
bc7aa832b7 | ||
|
|
b32f78ea9f | ||
|
|
78002dac3a | ||
|
|
aca70354e7 | ||
|
|
d7a3471b9b | ||
|
|
e81092d42c | ||
|
|
d06df3c819 | ||
|
|
050bfea8f2 | ||
|
|
7b84c74abf | ||
|
|
009c49cd89 | ||
|
|
ca023f73f4 | ||
|
|
456dfd2aac | ||
|
|
0c0291127c | ||
|
|
fe0c74de23 | ||
|
|
df1efa7604 | ||
|
|
73122fc517 | ||
|
|
b506ba277c | ||
|
|
ea3aed8fae | ||
|
|
481bb8e7d4 | ||
|
|
0ed8c40dbf | ||
|
|
d267d7c3b0 | ||
|
|
a04206f600 | ||
|
|
8d64a6f1b2 | ||
|
|
b3f614e150 | ||
|
|
988fae56fd | ||
|
|
fa439fc62d | ||
|
|
c58b2d6156 | ||
|
|
62c56b7918 | ||
|
|
73882ab55d | ||
|
|
490ea2b521 | ||
|
|
7116871652 | ||
|
|
cca374c774 | ||
|
|
3e0dbf92d0 | ||
|
|
2747ae674c | ||
|
|
e85ec5aaba | ||
|
|
d4894e066b | ||
|
|
71a4e84d7c | ||
|
|
d6c36146ba | ||
|
|
9013778f58 | ||
|
|
1af03dd145 | ||
|
|
b83e0fd092 | ||
|
|
1f8d786d05 | ||
|
|
5d87a4ada0 | ||
|
|
4d4fb1e398 | ||
|
|
05eea54f06 | ||
|
|
a00d9ef765 | ||
|
|
aa6ddac9c5 | ||
|
|
9a6f17c745 | ||
|
|
980977aaf3 | ||
|
|
3ed0afaf91 | ||
|
|
250adb4b33 | ||
|
|
0baf09cb53 | ||
|
|
d04a914b9b | ||
|
|
59775cb27c | ||
|
|
fb76cd7b1b | ||
|
|
52fb5306b6 | ||
|
|
74f7d95718 | ||
|
|
bcc1b78c7c | ||
|
|
3978abea87 | ||
|
|
556b726349 | ||
|
|
e0f16f31e6 | ||
|
|
b39e24467c | ||
|
|
65613f07ba | ||
|
|
666a7e4d77 | ||
|
|
cf08bfdd9b | ||
|
|
1f47d56c55 | ||
|
|
1a7dde848f | ||
|
|
c8207308a8 | ||
|
|
f23020cc6f | ||
|
|
555e487f1e | ||
|
|
107bb0b15d | ||
|
|
87a2de393e | ||
|
|
a7e4412454 | ||
|
|
7dce4cfa8e | ||
|
|
2b30aca47c | ||
|
|
0073d2862b | ||
|
|
b182c97185 | ||
|
|
a32119bada | ||
|
|
a928cfd066 | ||
|
|
8c7a371336 | ||
|
|
6f61eb4946 | ||
|
|
1096f05344 | ||
|
|
26f96d3e38 | ||
|
|
4a02189a59 | ||
|
|
7a5bab41f6 | ||
|
|
84c32fb28d |
No files matched your search
+12
-15
@@ -21,7 +21,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -42,6 +42,9 @@ jobs:
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
|
||||
|
||||
- name: Spotless Check
|
||||
run: ./gradlew spotlessCheck
|
||||
|
||||
@@ -57,8 +60,11 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -101,12 +107,6 @@ jobs:
|
||||
name: default-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload x86
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: default-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/default/release/app-default-x86-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -131,8 +131,11 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android && rustup component add rustfmt clippy
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -175,12 +178,6 @@ jobs:
|
||||
name: marshmallow-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload x86
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: marshmallow-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallow/release/app-marshmallow-x86-release.apk
|
||||
|
||||
- name: Upload mapping
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -18,8 +18,11 @@ jobs:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Install NDK
|
||||
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;27.0.11718014"
|
||||
|
||||
- name: Install Rust Toolchain
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android i686-linux-android x86_64-linux-android
|
||||
run: rustup update && rustup target add armv7-linux-androideabi aarch64-linux-android x86_64-linux-android
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -43,7 +46,6 @@ jobs:
|
||||
mv app/build/outputs/apk/default/release/app-default-universal-release.apk EhViewer-${{ github.ref_name }}-default-universal.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-x86_64.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-x86-release.apk EhViewer-${{ github.ref_name }}-default-x86.apk
|
||||
mv app/build/outputs/apk/default/release/app-default-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/defaultRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/defaultRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
|
||||
@@ -51,7 +53,6 @@ jobs:
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-x86.apk
|
||||
mv app/build/outputs/apk/marshmallow/release/app-marshmallow-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/marshmallowRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/marshmallowRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
|
||||
@@ -64,7 +65,6 @@ jobs:
|
||||
EhViewer-${{ github.ref_name }}-default-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-default-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-default-x86.apk
|
||||
EhViewer-${{ github.ref_name }}-default-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-default-native-debug-symbols.zip
|
||||
@@ -72,7 +72,6 @@ jobs:
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-x86.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-native-debug-symbols.zip
|
||||
@@ -0,0 +1,29 @@
|
||||
name: 'Update Chrome Version'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Chrome Version
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd chrome-for-testing
|
||||
curl -OLf https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_STABLE
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config push.autoSetupRemote true
|
||||
git switch -c chore/chrome
|
||||
if git commit -am "Update Chrome version for default User-Agent"; then
|
||||
git push
|
||||
gh pr create -f
|
||||
gh pr merge chore/chrome --auto -dr
|
||||
fi
|
||||
@@ -1,5 +1,66 @@
|
||||
# 变更日志
|
||||
|
||||
## [1.11.5] - 2024-05-19
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 不再支持 32 位 x86
|
||||
|
||||
### 新功能
|
||||
|
||||
* 支持按作者对下载分组
|
||||
* 由于 E 站不再强制要求人机验证,重新支持账号密码登录
|
||||
|
||||
### 改进
|
||||
|
||||
* 在登录时加载收藏名称 [#1077](https://github.com/FooIbar/EhViewer/issues/1077)
|
||||
* 优化归档和种子界面 UI
|
||||
* 禁止在 cookie 界面截图以防止泄露
|
||||
* 在缩略图模式下显示画廊页数
|
||||
* 记住最低评分和页数搜索参数
|
||||
* 更新 User-Agent 以缓解 IP 封禁 [#1182](https://github.com/FooIbar/EhViewer/issues/1182)
|
||||
* 账号密码登录支持自动填充
|
||||
* 更新中文(台灣)翻译
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 非触摸模式下无法退出搜索界面 [#1060](https://github.com/FooIbar/EhViewer/issues/1060)
|
||||
* 某些设备上无法安装更新 [#1067](https://github.com/FooIbar/EhViewer/issues/1067)
|
||||
* 某些设备上崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
|
||||
* 搜索记录中存在重复条目时崩溃 [#1130](https://github.com/FooIbar/EhViewer/issues/1130)
|
||||
* 条漫模式下缩小后点按区域偏移 [#127](https://github.com/FooIbar/EhViewer/issues/127)
|
||||
* 多窗口模式下点按区域偏移
|
||||
* 点击下载通知时未清除状态
|
||||
* 保存未完成加载的图片时崩溃 [#1154](https://github.com/FooIbar/EhViewer/issues/1154)
|
||||
* 历史记录界面无法在项目上滑动打开抽屉 [#464](https://github.com/FooIbar/EhViewer/issues/464)
|
||||
* 导入数据失败时显示导入成功 [#1174](https://github.com/FooIbar/EhViewer/issues/1174)
|
||||
* 某些条件下收藏界面崩溃 [#1190](https://github.com/FooIbar/EhViewer/issues/1190)
|
||||
* 反转点按区域功能失效 [#1217](https://github.com/FooIbar/EhViewer/issues/1217)
|
||||
|
||||
### 已知问题
|
||||
|
||||
* 某些设备上从后台返回时某些 UI 消失,可通过关闭列表项目动画部分缓解 [#1184](https://github.com/FooIbar/EhViewer/issues/1184)
|
||||
|
||||
|
||||
## [1.11.4] - 2024-04-21
|
||||
|
||||
### 新功能
|
||||
|
||||
* 更新前自动备份数据
|
||||
|
||||
### 改进
|
||||
|
||||
* 在 设置-高级 中加入了禁用列表项目动画的选项,该功能在部分设备上导致崩溃/卡顿 [#996](https://github.com/FooIbar/EhViewer/issues/996) [#1023](https://github.com/FooIbar/EhViewer/issues/1023)
|
||||
* 下载列表多选模式下避免误触 [#1016](https://github.com/FooIbar/EhViewer/issues/1016)
|
||||
* 杂项 UI 优化
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 重命名下载标签后删除该标签时崩溃 [#1008](https://github.com/FooIbar/EhViewer/issues/1008)
|
||||
* 搜索栏中某些输入法长按退格无法连续删除 [#606](https://github.com/FooIbar/EhViewer/issues/606)
|
||||
* 评论/屏蔽/搜索建议/下载标签/快速搜索列表卡顿 [#1041](https://github.com/FooIbar/EhViewer/issues/1041)
|
||||
|
||||
|
||||
## [1.11.3] - 2024-04-06
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -85,13 +85,13 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
|
||||
Here is the libraries
|
||||
|
||||
- [Arrow](https://arrow-kt.io/)
|
||||
- [AOSP & AndroidX](http://source.android.com/)
|
||||
- [AOSP & AndroidX](https://source.android.com/)
|
||||
- [Kotlin & KotlinX](https://kotlinlang.org/)
|
||||
- [Material Icons](https://github.com/google/material-design-icons)
|
||||
- [Ktor](https://ktor.io/)
|
||||
- [Coil](https://coil-kt.github.io/coil/)
|
||||
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
|
||||
- [libarchive](http://www.libarchive.org/)
|
||||
- [libarchive](https://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
|
||||
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
|
||||
|
||||
+30
-26
@@ -1,8 +1,6 @@
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode.MERGE
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule.GROUP
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
val isRelease: Boolean
|
||||
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
|
||||
@@ -10,6 +8,7 @@ val isRelease: Boolean
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
@@ -20,14 +19,15 @@ plugins {
|
||||
|
||||
android {
|
||||
compileSdk = 34
|
||||
ndkVersion = "26.2.11394342"
|
||||
ndkVersion = "27.0.11718014-beta1"
|
||||
androidResources.generateLocaleConfig = true
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
if (isRelease) {
|
||||
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||
include("arm64-v8a", "x86_64", "armeabi-v7a")
|
||||
isUniversalApk = true
|
||||
} else {
|
||||
include("arm64-v8a", "x86_64")
|
||||
@@ -48,22 +48,21 @@ android {
|
||||
commandLine = "git rev-parse --short=7 HEAD".split(' ')
|
||||
}.standardOutput.asText.get().trim()
|
||||
|
||||
val buildTime by lazy {
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm").withZone(ZoneOffset.UTC)
|
||||
formatter.format(Instant.now())
|
||||
}
|
||||
|
||||
val repoName = providers.exec {
|
||||
commandLine = "git remote get-url origin".split(' ')
|
||||
}.standardOutput.asText.get().trim().removePrefix("https://github.com/").removePrefix("git@github.com:")
|
||||
.removeSuffix(".git")
|
||||
|
||||
val chromeVersion = rootProject.layout.projectDirectory.file("chrome-for-testing/LATEST_RELEASE_STABLE").asFile
|
||||
.readText().substringBefore('.')
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 180054
|
||||
versionName = "1.11.3"
|
||||
versionCode = 180056
|
||||
versionName = "1.11.5"
|
||||
// versionNameSuffix = "-SNAPSHOT"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
@@ -83,9 +82,15 @@ android {
|
||||
buildConfigField("String", "RAW_VERSION_NAME", "\"$versionName${versionNameSuffix.orEmpty()}\"")
|
||||
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
|
||||
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
|
||||
buildConfigField("String", "CHROME_VERSION", "\"$chromeVersion\"")
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "api"
|
||||
@@ -114,13 +119,12 @@ android {
|
||||
// https://kotlinlang.org/docs/compiler-reference.html#progressive
|
||||
"-progressive",
|
||||
"-Xjvm-default=all",
|
||||
"-Xlambdas=indy",
|
||||
"-Xcontext-receivers",
|
||||
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
"-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
|
||||
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
|
||||
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
@@ -131,9 +135,6 @@ android {
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"-opt-in=splitties.experimental.ExperimentalSplittiesApi",
|
||||
"-opt-in=splitties.preferences.DataStorePreferencesPreview",
|
||||
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true",
|
||||
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:nonSkippingGroupOptimization=true",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,11 +157,14 @@ android {
|
||||
isShrinkResources = true
|
||||
proguardFiles("proguard-rules.pro")
|
||||
signingConfig = signConfig
|
||||
buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
|
||||
buildConfigField("long", "BUILD_TIME", "${Instant.now().epochSecond}")
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
buildConfigField("String", "BUILD_TIME", "\"\"")
|
||||
buildConfigField("long", "BUILD_TIME", "0")
|
||||
lint {
|
||||
abortOnError = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,13 +174,14 @@ android {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
}
|
||||
|
||||
namespace = "com.hippo.ehviewer"
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
enableNonSkippingGroupOptimization = true
|
||||
enableStrongSkippingMode = true
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) {
|
||||
it.packaging.resources.excludes.addAll(
|
||||
@@ -206,13 +211,13 @@ dependencies {
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.graphics.path)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/navigation
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/paging
|
||||
implementation(libs.androidx.paging.compose)
|
||||
|
||||
@@ -274,7 +279,6 @@ kotlin {
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("room.generateKotlin", "true")
|
||||
arg("compose-destinations.codeGenPackageName", "com.hippo.ehviewer.ui")
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
-5
@@ -11,16 +11,15 @@
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
# Workaround for https://issuetracker.google.com/331556916
|
||||
-keepclassmembers class androidx.biometric.BiometricManager {
|
||||
public int canAuthenticate(int);
|
||||
}
|
||||
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
|
||||
# https://issuetracker.google.com/222232895
|
||||
-dontwarn androidx.window.extensions.**
|
||||
-dontwarn androidx.window.sidecar.Sidecar*
|
||||
|
||||
-keepattributes LineNumberTable
|
||||
|
||||
-allowaccessmodification
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 23,
|
||||
"identityHash": "7d1cfc4e9a9d506a73eb0520ae414d25",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "GALLERIES",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TOKEN` TEXT NOT NULL, `TITLE` TEXT, `TITLE_JPN` TEXT, `THUMB` TEXT, `CATEGORY` INTEGER NOT NULL, `POSTED` TEXT, `UPLOADER` TEXT, `RATING` REAL NOT NULL, `SIMPLE_TAGS` TEXT, `PAGES` INTEGER NOT NULL DEFAULT 0, `SIMPLE_LANGUAGE` TEXT, `FAVORITE_SLOT` INTEGER NOT NULL, PRIMARY KEY(`GID`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "token",
|
||||
"columnName": "TOKEN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "TITLE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "titleJpn",
|
||||
"columnName": "TITLE_JPN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbKey",
|
||||
"columnName": "THUMB",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "category",
|
||||
"columnName": "CATEGORY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "posted",
|
||||
"columnName": "POSTED",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "UPLOADER",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "rating",
|
||||
"columnName": "RATING",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "simpleTags",
|
||||
"columnName": "SIMPLE_TAGS",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "pages",
|
||||
"columnName": "PAGES",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "simpleLanguage",
|
||||
"columnName": "SIMPLE_LANGUAGE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "favoriteSlot",
|
||||
"columnName": "FAVORITE_SLOT",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "DOWNLOAD_LABELS",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`LABEL` TEXT NOT NULL, `POSITION` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "LABEL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "POSITION",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_DOWNLOAD_LABELS_LABEL",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"LABEL"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_DOWNLOAD_LABELS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "DOWNLOADS",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `STATE` INTEGER NOT NULL, `LEGACY` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, `LABEL` TEXT, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`LABEL`) REFERENCES `DOWNLOAD_LABELS`(`LABEL`) ON UPDATE CASCADE ON DELETE SET NULL )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "state",
|
||||
"columnName": "STATE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "legacy",
|
||||
"columnName": "LEGACY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "time",
|
||||
"columnName": "TIME",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "label",
|
||||
"columnName": "LABEL",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_DOWNLOADS_LABEL",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"LABEL"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_DOWNLOADS_LABEL` ON `${TABLE_NAME}` (`LABEL`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "GALLERIES",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "DOWNLOAD_LABELS",
|
||||
"onDelete": "SET NULL",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"LABEL"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"LABEL"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "DOWNLOAD_DIRNAME",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `DIRNAME` TEXT NOT NULL, PRIMARY KEY(`GID`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dirname",
|
||||
"columnName": "DIRNAME",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "DOWNLOAD_ARTISTS",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `ARTIST` TEXT NOT NULL, PRIMARY KEY(`GID`, `ARTIST`), FOREIGN KEY(`GID`) REFERENCES `DOWNLOADS`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artist",
|
||||
"columnName": "ARTIST",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID",
|
||||
"ARTIST"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "DOWNLOADS",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "FILTER",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`MODE` INTEGER NOT NULL, `TEXT` TEXT NOT NULL, `ENABLE` INTEGER NOT NULL, `_id` INTEGER, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "mode",
|
||||
"columnName": "MODE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "text",
|
||||
"columnName": "TEXT",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "enable",
|
||||
"columnName": "ENABLE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "HISTORY",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "time",
|
||||
"columnName": "TIME",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "GALLERIES",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "LOCAL_FAVORITES",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `TIME` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "time",
|
||||
"columnName": "TIME",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "GALLERIES",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "PROGRESS",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`GID` INTEGER NOT NULL, `PAGE` INTEGER NOT NULL, PRIMARY KEY(`GID`), FOREIGN KEY(`GID`) REFERENCES `GALLERIES`(`GID`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "gid",
|
||||
"columnName": "GID",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "page",
|
||||
"columnName": "PAGE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"GID"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "GALLERIES",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"GID"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"GID"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "QUICK_SEARCH",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `NAME` TEXT NOT NULL, `MODE` INTEGER NOT NULL, `CATEGORY` INTEGER NOT NULL, `KEYWORD` TEXT, `ADVANCE_SEARCH` INTEGER NOT NULL, `MIN_RATING` INTEGER NOT NULL, `PAGE_FROM` INTEGER NOT NULL, `PAGE_TO` INTEGER NOT NULL, `POSITION` INTEGER NOT NULL, PRIMARY KEY(`_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "NAME",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mode",
|
||||
"columnName": "MODE",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "category",
|
||||
"columnName": "CATEGORY",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "keyword",
|
||||
"columnName": "KEYWORD",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "advanceSearch",
|
||||
"columnName": "ADVANCE_SEARCH",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "minRating",
|
||||
"columnName": "MIN_RATING",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pageFrom",
|
||||
"columnName": "PAGE_FROM",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pageTo",
|
||||
"columnName": "PAGE_TO",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "POSITION",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"ftsVersion": "FTS4",
|
||||
"ftsOptions": {
|
||||
"tokenizer": "simple",
|
||||
"tokenizerArgs": [],
|
||||
"contentTable": "GALLERIES",
|
||||
"languageIdColumnName": "",
|
||||
"matchInfo": "FTS4",
|
||||
"notIndexedColumns": [],
|
||||
"prefixSizes": [],
|
||||
"preferredOrder": "ASC"
|
||||
},
|
||||
"contentSyncTriggers": [
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_UPDATE BEFORE UPDATE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_BEFORE_DELETE BEFORE DELETE ON `GALLERIES` BEGIN DELETE FROM `GALLERIES_FTS` WHERE `docid`=OLD.`rowid`; END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_UPDATE AFTER UPDATE ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END",
|
||||
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_GALLERIES_FTS_AFTER_INSERT AFTER INSERT ON `GALLERIES` BEGIN INSERT INTO `GALLERIES_FTS`(`docid`, `TITLE`, `TITLE_JPN`, `SIMPLE_TAGS`) VALUES (NEW.`rowid`, NEW.`TITLE`, NEW.`TITLE_JPN`, NEW.`SIMPLE_TAGS`); END"
|
||||
],
|
||||
"tableName": "GALLERIES_FTS",
|
||||
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`TITLE` TEXT NOT NULL, `TITLE_JPN` TEXT NOT NULL, `SIMPLE_TAGS` TEXT, content=`GALLERIES`)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "TITLE",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "titleJpn",
|
||||
"columnName": "TITLE_JPN",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "simpleTags",
|
||||
"columnName": "SIMPLE_TAGS",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": []
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d1cfc4e9a9d506a73eb0520ae414d25')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="@bool/large_heap"
|
||||
android:localeConfig="@xml/locale_config"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
From 2fa97f75b64c05da506968fa9a1711716f2d45a0 Mon Sep 17 00:00:00 2001
|
||||
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
|
||||
Date: Tue, 26 Sep 2023 00:08:03 +0800
|
||||
Subject: [PATCH 1/2] Hide symbols
|
||||
|
||||
---
|
||||
CMakeLists.txt | 4 ++--
|
||||
1 file changed, 2 insertions(+), 2 deletions(-)
|
||||
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index ec97e4c7..4d859341 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -2129,10 +2129,10 @@ SET(OLD_CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS}")
|
||||
SET(CMAKE_REQUIRED_FLAGS "${CMAKE_REQUIRED_FLAGS} -fvisibility=hidden -Werror")
|
||||
CHECK_C_SOURCE_COMPILES("void __attribute__((visibility(\"default\"))) foo(void);
|
||||
int main() { return 0; }" HAVE_VISIBILITY_ATTR)
|
||||
-IF (HAVE_VISIBILITY_ATTR)
|
||||
+IF (0)
|
||||
SET(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fvisibility=hidden")
|
||||
ADD_DEFINITIONS(-D__LIBARCHIVE_ENABLE_VISIBILITY)
|
||||
-ENDIF(HAVE_VISIBILITY_ATTR)
|
||||
+ENDIF(0)
|
||||
SET(CMAKE_REQUIRED_FLAGS "${OLD_CMAKE_REQUIRED_FLAGS}")
|
||||
|
||||
# Generate "config.h" from "build/cmake/config.h.in"
|
||||
--
|
||||
2.34.1
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
From 2dcc8be937499022c37678eb6bf4bd026b6fcbb0 Mon Sep 17 00:00:00 2001
|
||||
From: FooIbar <118464521+FooIbar@users.noreply.github.com>
|
||||
Date: Tue, 26 Sep 2023 00:09:25 +0800
|
||||
Subject: [PATCH 2/2] Insert link libs
|
||||
|
||||
---
|
||||
CMakeLists.txt | 2 +-
|
||||
1 file changed, 1 insertion(+), 1 deletion(-)
|
||||
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index 4d859341..1cd1cd47 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -438,7 +438,7 @@ IF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
|
||||
# # endif
|
||||
ENDIF(DEFINED __GNUWIN32PATH AND EXISTS "${__GNUWIN32PATH}")
|
||||
|
||||
-SET(ADDITIONAL_LIBS "")
|
||||
+SET(ADDITIONAL_LIBS ${LIBARCHIVE_CUSTOM_LIBS})
|
||||
#
|
||||
# Find ZLIB
|
||||
#
|
||||
--
|
||||
2.34.1
|
||||
|
||||
@@ -20,7 +20,7 @@ option(BUILD_TESTING OFF)
|
||||
|
||||
FetchContent_Declare(
|
||||
liblzma
|
||||
GIT_REPOSITORY https://git.tukaani.org/xz.git
|
||||
GIT_REPOSITORY https://github.com/tukaani-project/xz.git
|
||||
GIT_TAG v5.6.1
|
||||
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Don-t-build-other-binaries.patch || cd .
|
||||
)
|
||||
@@ -69,8 +69,9 @@ SET(LIBARCHIVE_CUSTOM_LIBS "nettle" "liblzma")
|
||||
|
||||
FetchContent_Declare(
|
||||
libarchive
|
||||
GIT_REPOSITORY https://github.com/FooIbar/libarchive.git
|
||||
GIT_TAG v3.7.2
|
||||
GIT_REPOSITORY https://github.com/libarchive/libarchive.git
|
||||
GIT_TAG v3.7.4
|
||||
PATCH_COMMAND git apply ${CMAKE_CURRENT_LIST_DIR}/0001-Hide-symbols.patch ${CMAKE_CURRENT_LIST_DIR}/0002-Insert-link-libs.patch || cd .
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(libarchive)
|
||||
@@ -79,11 +80,12 @@ include_directories(${libarchive_SOURCE_DIR}/libarchive)
|
||||
FetchContent_Declare(
|
||||
Corrosion
|
||||
GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
|
||||
GIT_TAG 0a3bdf452995803c334d79dc9a2affbfad51b720
|
||||
GIT_TAG v0.5
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(Corrosion)
|
||||
corrosion_import_crate(MANIFEST_PATH ../rust/Cargo.toml)
|
||||
corrosion_add_target_local_rustflags(ehviewer_rust "-Clink-args=-Wl,-z,max-page-size=16384")
|
||||
|
||||
# Build and link our app's native lib
|
||||
add_library(${PROJECT_NAME} SHARED archive.c border.c gifutils.c natsort/strnatcmp.c)
|
||||
|
||||
@@ -54,8 +54,9 @@ static archive_ctx **ctx_pool;
|
||||
|
||||
static void *mempool = NULL;
|
||||
static size_t *mempoolofs = NULL;
|
||||
static int page_size = 0;
|
||||
|
||||
#define PAGE_ALIGN(x) ((x + ~PAGE_MASK) & PAGE_MASK)
|
||||
#define PAGE_ALIGN(x) ((x + page_size - 1) & ~(page_size - 1))
|
||||
|
||||
#define MEMPOOL_ADDR_BY_SORTED_IDX(x) (mempool + (index ? mempoolofs[index - 1] : 0))
|
||||
#define MEMPOOL_SIZE (mempoolofs[entryCount - 1])
|
||||
@@ -282,6 +283,7 @@ JNIEXPORT jint JNICALL
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd, jlong size, jboolean sort_entries) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
page_size = getpagesize();
|
||||
archive_ctx *ctx = NULL;
|
||||
archiveAddr = mmap(0, size, PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
if (archiveAddr == MAP_FAILED) {
|
||||
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.gestures
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.node.LayoutModifierNode
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.node.requireLayoutDirection
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.platform.debugInspectorInfo
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
fun <T> Modifier.anchoredHorizontalDraggable(
|
||||
state: AnchoredDraggableState<T>,
|
||||
enableDragFromStartToEnd: Boolean = true,
|
||||
enableDragFromEndToStart: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
startDragImmediately: Boolean = state.isAnimationRunning,
|
||||
): Modifier = this then AnchoredHorizontalDraggableElement(
|
||||
state = state,
|
||||
canDrag = { (x, y) ->
|
||||
if (x > 0) {
|
||||
enableDragFromStartToEnd
|
||||
} else {
|
||||
enableDragFromEndToStart
|
||||
} && x.absoluteValue > y.absoluteValue * 2f
|
||||
},
|
||||
enabled = enabled,
|
||||
startDragImmediately = startDragImmediately,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private class AnchoredHorizontalDraggableElement<T>(
|
||||
private val state: AnchoredDraggableState<T>,
|
||||
private val canDrag: (Offset) -> Boolean,
|
||||
private val enabled: Boolean,
|
||||
private val startDragImmediately: Boolean,
|
||||
) : ModifierNodeElement<AnchoredHorizontalDraggableNode<T>>() {
|
||||
override fun create() = AnchoredHorizontalDraggableNode(
|
||||
state,
|
||||
canDrag,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
)
|
||||
|
||||
override fun update(node: AnchoredHorizontalDraggableNode<T>) {
|
||||
node.update(
|
||||
state,
|
||||
canDrag,
|
||||
enabled,
|
||||
startDragImmediately,
|
||||
)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + canDrag.hashCode()
|
||||
result = 31 * result + enabled.hashCode()
|
||||
result = 31 * result + startDragImmediately.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
if (other !is AnchoredHorizontalDraggableElement<*>) return false
|
||||
|
||||
if (state != other.state) return false
|
||||
if (canDrag != other.canDrag) return false
|
||||
if (enabled != other.enabled) return false
|
||||
if (startDragImmediately != other.startDragImmediately) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
name = "anchoredDraggable"
|
||||
properties["state"] = state
|
||||
properties["canDrag"] = canDrag
|
||||
properties["enabled"] = enabled
|
||||
properties["startDragImmediately"] = startDragImmediately
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private class AnchoredHorizontalDraggableNode<T>(
|
||||
private var state: AnchoredDraggableState<T>,
|
||||
private var canDrag: (Offset) -> Boolean,
|
||||
enabled: Boolean,
|
||||
private var startDragImmediately: Boolean,
|
||||
) : HorizontalDragGestureNode(
|
||||
enabled = enabled,
|
||||
) {
|
||||
|
||||
private val isReverseDirection: Boolean
|
||||
get() = requireLayoutDirection() == LayoutDirection.Rtl
|
||||
|
||||
override fun canDrag(change: PointerInputChange): Boolean {
|
||||
return canDrag(change.positionChange().reverseIfNeeded())
|
||||
}
|
||||
|
||||
override suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit) {
|
||||
state.anchoredDrag {
|
||||
forEachDelta { dragDelta ->
|
||||
dragTo(state.newOffsetForDelta(dragDelta.delta.reverseIfNeeded().x))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDragStarted(startedPosition: Offset) { }
|
||||
|
||||
override fun onDragStopped(velocity: Velocity) {
|
||||
if (!isAttached) return
|
||||
coroutineScope.launch {
|
||||
state.settle(velocity.reverseIfNeeded().x)
|
||||
}
|
||||
}
|
||||
|
||||
override fun startDragImmediately(): Boolean = startDragImmediately
|
||||
|
||||
fun update(
|
||||
state: AnchoredDraggableState<T>,
|
||||
canDrag: (Offset) -> Boolean,
|
||||
enabled: Boolean,
|
||||
startDragImmediately: Boolean,
|
||||
) {
|
||||
var resetPointerInputHandling = false
|
||||
|
||||
if (this.state != state) {
|
||||
this.state = state
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
|
||||
this.canDrag = canDrag
|
||||
this.startDragImmediately = startDragImmediately
|
||||
|
||||
update(
|
||||
enabled = enabled,
|
||||
shouldResetPointerInputHandling = resetPointerInputHandling,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Velocity.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
|
||||
private fun Offset.reverseIfNeeded() = if (isReverseDirection) this * -1f else this * 1f
|
||||
}
|
||||
|
||||
internal fun <T> AnchoredDraggableState<T>.newOffsetForDelta(delta: Float) =
|
||||
((if (offset.isNaN()) 0f else offset) + delta)
|
||||
.coerceIn(anchors.minAnchor(), anchors.maxAnchor())
|
||||
|
||||
/**
|
||||
* This Modifier allows configuring an [AnchoredDraggableState]'s anchors based on this layout
|
||||
* node's size and offsetting it.
|
||||
* It considers lookahead and reports the appropriate size and measurement for the appropriate
|
||||
* phase.
|
||||
*
|
||||
* @param state The state the anchors should be attached to
|
||||
* @param orientation The orientation the component should be offset in
|
||||
* @param anchors Lambda to calculate the anchors based on this layout's size and the incoming
|
||||
* constraints. These can be useful to avoid subcomposition.
|
||||
*/
|
||||
internal fun <T> Modifier.draggableAnchors(
|
||||
state: AnchoredDraggableState<T>,
|
||||
orientation: Orientation,
|
||||
anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
) = this then DraggableAnchorsElement(state, anchors, orientation)
|
||||
|
||||
private class DraggableAnchorsElement<T>(
|
||||
private val state: AnchoredDraggableState<T>,
|
||||
private val anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
private val orientation: Orientation,
|
||||
) : ModifierNodeElement<DraggableAnchorsNode<T>>() {
|
||||
|
||||
override fun create() = DraggableAnchorsNode(state, anchors, orientation)
|
||||
|
||||
override fun update(node: DraggableAnchorsNode<T>) {
|
||||
node.state = state
|
||||
node.anchors = anchors
|
||||
node.orientation = orientation
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
if (other !is DraggableAnchorsElement<*>) return false
|
||||
|
||||
if (state != other.state) return false
|
||||
if (anchors !== other.anchors) return false
|
||||
if (orientation != other.orientation) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = state.hashCode()
|
||||
result = 31 * result + anchors.hashCode()
|
||||
result = 31 * result + orientation.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
debugInspectorInfo {
|
||||
properties["state"] = state
|
||||
properties["anchors"] = anchors
|
||||
properties["orientation"] = orientation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DraggableAnchorsNode<T>(
|
||||
var state: AnchoredDraggableState<T>,
|
||||
var anchors: (size: IntSize, constraints: Constraints) -> Pair<DraggableAnchors<T>, T>,
|
||||
var orientation: Orientation,
|
||||
) : Modifier.Node(), LayoutModifierNode {
|
||||
private var didLookahead: Boolean = false
|
||||
|
||||
override fun onDetach() {
|
||||
didLookahead = false
|
||||
}
|
||||
|
||||
override fun MeasureScope.measure(
|
||||
measurable: Measurable,
|
||||
constraints: Constraints,
|
||||
): MeasureResult {
|
||||
val placeable = measurable.measure(constraints)
|
||||
// If we are in a lookahead pass, we only want to update the anchors here and not in
|
||||
// post-lookahead. If there is no lookahead happening (!isLookingAhead && !didLookahead),
|
||||
// update the anchors in the main pass.
|
||||
if (!isLookingAhead || !didLookahead) {
|
||||
val size = IntSize(placeable.width, placeable.height)
|
||||
val newAnchorResult = anchors(size, constraints)
|
||||
state.updateAnchors(newAnchorResult.first, newAnchorResult.second)
|
||||
}
|
||||
didLookahead = isLookingAhead || didLookahead
|
||||
return layout(placeable.width, placeable.height) {
|
||||
// In a lookahead pass, we use the position of the current target as this is where any
|
||||
// ongoing animations would move. If the component is in a settled state, lookahead
|
||||
// and post-lookahead will converge.
|
||||
val offset = if (isLookingAhead) {
|
||||
state.anchors.positionOf(state.targetValue)
|
||||
} else {
|
||||
state.requireOffset()
|
||||
}
|
||||
val xOffset = if (orientation == Orientation.Horizontal) offset else 0f
|
||||
val yOffset = if (orientation == Orientation.Vertical) offset else 0f
|
||||
placeable.placeRelative(xOffset.roundToInt(), yOffset.roundToInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2020 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.gestures
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* A Gesture detector that waits for pointer down and touch slop in the horizontal direction and
|
||||
* then calls [onDrag] for each drag event.
|
||||
* It follows the touch slop detection of [awaitHorizontalTouchSlopOrCancellation] but will consume
|
||||
* the position change automatically once the touch slop has been crossed, the amount of drag over
|
||||
* the touch slop is reported as the first drag event [onDrag] after the slop is crossed.
|
||||
* If [shouldAwaitTouchSlop] returns true the touch slop recognition phase will be ignored
|
||||
* and the drag gesture will be recognized immediately.The first [onDrag] in this case will report
|
||||
* an [Offset.Zero].
|
||||
*
|
||||
* [onDragStart] is called when the touch slop has been passed and includes an [Offset] representing
|
||||
* the last known pointer position relative to the containing element as well as the initial
|
||||
* down event that triggered this gesture detection cycle. The [Offset] can be outside
|
||||
* the actual bounds of the element itself meaning the numbers can be negative or larger than the
|
||||
* element bounds if the touch target is smaller than the
|
||||
* [ViewConfiguration.minimumTouchTargetSize].
|
||||
*
|
||||
* [onDragEnd] is called after all pointers are up with the event change of the up event
|
||||
* and [onDragCancel] is called if another gesture has consumed pointer input,
|
||||
* canceling this gesture.
|
||||
*
|
||||
* @param onDragStart A lambda to be called when the drag gesture starts, it contains information
|
||||
* about the last known [PointerInputChange] relative to the containing element and the post slop
|
||||
* delta.
|
||||
* @param onDragEnd A lambda to be called when the gesture ends. It contains information about the
|
||||
* up [PointerInputChange] that finished the gesture.
|
||||
* @param onDragCancel A lambda to be called when the gesture is cancelled either by an error or
|
||||
* when it was consumed.
|
||||
* @param shouldAwaitTouchSlop Indicates if touch slop detection should be skipped.
|
||||
* @param onDrag A lambda to be called for each delta event in the gesture. It contains information
|
||||
* about the [PointerInputChange] and the movement offset.
|
||||
*/
|
||||
internal suspend fun PointerInputScope.detectHorizontalDragGestures(
|
||||
canDrag: (PointerInputChange) -> Boolean,
|
||||
onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit,
|
||||
onDragEnd: () -> Unit,
|
||||
onDragCancel: () -> Unit,
|
||||
shouldAwaitTouchSlop: () -> Boolean,
|
||||
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit,
|
||||
) {
|
||||
awaitEachGesture {
|
||||
val initialDown =
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
val awaitTouchSlop = shouldAwaitTouchSlop()
|
||||
|
||||
if (!awaitTouchSlop) {
|
||||
initialDown.consume()
|
||||
}
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var drag: PointerInputChange?
|
||||
var overSlop = Offset.Zero
|
||||
|
||||
if (awaitTouchSlop) {
|
||||
do {
|
||||
drag = awaitHorizontalTouchSlopOrCancellation(
|
||||
down.id,
|
||||
) { change, over ->
|
||||
if (canDrag.invoke(change)) {
|
||||
change.consume()
|
||||
overSlop = Offset(over, 0f)
|
||||
} else {
|
||||
throw CancellationException()
|
||||
}
|
||||
}
|
||||
} while (drag != null && !drag.isConsumed)
|
||||
} else {
|
||||
drag = initialDown
|
||||
}
|
||||
|
||||
if (drag != null) {
|
||||
onDragStart.invoke(drag, overSlop)
|
||||
onDrag(drag, overSlop)
|
||||
val dragEnd = horizontalDrag(
|
||||
pointerId = drag.id,
|
||||
onDrag = {
|
||||
onDrag(it, it.positionChange())
|
||||
it.consume()
|
||||
},
|
||||
)
|
||||
if (dragEnd) {
|
||||
onDragEnd()
|
||||
} else {
|
||||
onDragCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
* Copyright 2019 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.foundation.gestures
|
||||
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragCancelled
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragDelta
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragStarted
|
||||
import androidx.compose.foundation.gestures.HorizontalDragEvent.DragStopped
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.PointerEvent
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.PointerInputChange
|
||||
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
|
||||
import androidx.compose.ui.node.DelegatingNode
|
||||
import androidx.compose.ui.node.PointerInputModifierNode
|
||||
import androidx.compose.ui.node.currentValueOf
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A node that performs drag gesture recognition and event propagation.
|
||||
*/
|
||||
internal abstract class HorizontalDragGestureNode(
|
||||
enabled: Boolean,
|
||||
) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode {
|
||||
|
||||
protected var enabled = enabled
|
||||
private set
|
||||
|
||||
private var channel: Channel<HorizontalDragEvent>? = null
|
||||
private var isListeningForEvents = false
|
||||
|
||||
abstract fun canDrag(change: PointerInputChange): Boolean
|
||||
|
||||
/**
|
||||
* Responsible for the dragging behavior between the start and the end of the drag. It
|
||||
* continually invokes `forEachDelta` to process incoming events. In return, `forEachDelta`
|
||||
* calls `dragBy` method to process each individual delta.
|
||||
*/
|
||||
abstract suspend fun drag(forEachDelta: suspend ((dragDelta: DragDelta) -> Unit) -> Unit)
|
||||
|
||||
/**
|
||||
* Passes the action needed when a drag starts. This gives the ability to pass the desired
|
||||
* behavior from other nodes implementing AbstractDraggableNode
|
||||
*/
|
||||
abstract fun onDragStarted(startedPosition: Offset)
|
||||
|
||||
/**
|
||||
* Passes the action needed when a drag stops. This gives the ability to pass the desired
|
||||
* behavior from other nodes implementing AbstractDraggableNode
|
||||
*/
|
||||
abstract fun onDragStopped(velocity: Velocity)
|
||||
|
||||
/**
|
||||
* If touch slop recognition should be skipped. If this is true, this node will start
|
||||
* recognizing drag events immediately without waiting for touch slop.
|
||||
*/
|
||||
abstract fun startDragImmediately(): Boolean
|
||||
|
||||
private fun startListeningForEvents() {
|
||||
isListeningForEvents = true
|
||||
|
||||
/**
|
||||
* To preserve the original behavior we had (before the Modifier.Node migration) we need to
|
||||
* scope the DragStopped and DragCancel methods to the node's coroutine scope instead of using
|
||||
* the one provided by the pointer input modifier, this is to ensure that even when the pointer
|
||||
* input scope is reset we will continue any coroutine scope scope that we started from these
|
||||
* methods while the pointer input scope was active.
|
||||
*/
|
||||
coroutineScope.launch {
|
||||
while (isActive) {
|
||||
var event = channel?.receive()
|
||||
if (event !is DragStarted) continue
|
||||
onDragStarted(event.startPoint)
|
||||
try {
|
||||
drag { processDelta ->
|
||||
while (event !is DragStopped && event !is DragCancelled) {
|
||||
(event as? DragDelta)?.let(processDelta)
|
||||
event = channel?.receive()
|
||||
}
|
||||
}
|
||||
if (event is DragStopped) {
|
||||
onDragStopped((event as DragStopped).velocity)
|
||||
} else if (event is DragCancelled) {
|
||||
onDragStopped(Velocity.Zero)
|
||||
}
|
||||
} catch (c: CancellationException) {
|
||||
onDragStopped(Velocity.Zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var pointerInputNode: SuspendingPointerInputModifierNode? = null
|
||||
|
||||
override fun onDetach() {
|
||||
isListeningForEvents = false
|
||||
}
|
||||
|
||||
override fun onPointerEvent(
|
||||
pointerEvent: PointerEvent,
|
||||
pass: PointerEventPass,
|
||||
bounds: IntSize,
|
||||
) {
|
||||
if (enabled && pointerInputNode == null) {
|
||||
pointerInputNode = delegate(initializePointerInputNode())
|
||||
}
|
||||
pointerInputNode?.onPointerEvent(pointerEvent, pass, bounds)
|
||||
}
|
||||
|
||||
private fun initializePointerInputNode(): SuspendingPointerInputModifierNode {
|
||||
return SuspendingPointerInputModifierNode {
|
||||
// re-create tracker when pointer input block restarts. This lazily creates the tracker
|
||||
// only when it is need.
|
||||
val velocityTracker = VelocityTracker()
|
||||
val onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit =
|
||||
{ startEvent, initialDelta ->
|
||||
if (!isListeningForEvents) {
|
||||
if (channel == null) {
|
||||
channel = Channel(capacity = Channel.UNLIMITED)
|
||||
}
|
||||
startListeningForEvents()
|
||||
}
|
||||
val xSign = sign(startEvent.position.x)
|
||||
val ySign = sign(startEvent.position.y)
|
||||
val adjustedStart = startEvent.position -
|
||||
Offset(initialDelta.x * xSign, initialDelta.y * ySign)
|
||||
|
||||
channel?.trySend(DragStarted(adjustedStart))
|
||||
}
|
||||
|
||||
val onDragEnd: () -> Unit = {
|
||||
val maximumVelocity = currentValueOf(LocalViewConfiguration)
|
||||
.maximumFlingVelocity
|
||||
val velocity = velocityTracker.calculateVelocity(
|
||||
Velocity(maximumVelocity, maximumVelocity),
|
||||
)
|
||||
velocityTracker.resetTracking()
|
||||
channel?.trySend(DragStopped(velocity))
|
||||
}
|
||||
|
||||
val onDragCancel: () -> Unit = {
|
||||
channel?.trySend(DragCancelled)
|
||||
}
|
||||
|
||||
val shouldAwaitTouchSlop: () -> Boolean = {
|
||||
!startDragImmediately()
|
||||
}
|
||||
|
||||
val onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit =
|
||||
{ change, delta ->
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
channel?.trySend(DragDelta(delta))
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
try {
|
||||
detectHorizontalDragGestures(
|
||||
canDrag = ::canDrag,
|
||||
onDragStart = onDragStart,
|
||||
onDragEnd = onDragEnd,
|
||||
onDragCancel = onDragCancel,
|
||||
shouldAwaitTouchSlop = shouldAwaitTouchSlop,
|
||||
onDrag = onDrag,
|
||||
)
|
||||
} catch (cancellation: CancellationException) {
|
||||
channel?.trySend(DragCancelled)
|
||||
if (!isActive) throw cancellation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelPointerInput() {
|
||||
pointerInputNode?.onCancelPointerInput()
|
||||
}
|
||||
|
||||
fun update(
|
||||
enabled: Boolean = this.enabled,
|
||||
shouldResetPointerInputHandling: Boolean = false,
|
||||
) {
|
||||
var resetPointerInputHandling = shouldResetPointerInputHandling
|
||||
|
||||
if (this.enabled != enabled) {
|
||||
this.enabled = enabled
|
||||
if (!enabled) {
|
||||
pointerInputNode?.let { undelegate(it) }
|
||||
pointerInputNode = null
|
||||
}
|
||||
resetPointerInputHandling = true
|
||||
}
|
||||
|
||||
if (resetPointerInputHandling) {
|
||||
pointerInputNode?.resetPointerInputHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HorizontalDragEvent {
|
||||
class DragStarted(val startPoint: Offset) : HorizontalDragEvent()
|
||||
class DragStopped(val velocity: Velocity) : HorizontalDragEvent()
|
||||
object DragCancelled : HorizontalDragEvent()
|
||||
class DragDelta(val delta: Offset) : HorizontalDragEvent()
|
||||
}
|
||||
@@ -10,11 +10,8 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
|
||||
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.horizontalDrag
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -42,17 +39,12 @@ import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.TileMode
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import androidx.compose.ui.input.pointer.util.addPointerInputChange
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
|
||||
@@ -63,7 +55,6 @@ import kotlinx.coroutines.launch
|
||||
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
|
||||
private const val DrawerPositionalThreshold = 0.5f
|
||||
private val DrawerVelocityThreshold = 400.dp
|
||||
private val ContainerWidth = 360.0.dp
|
||||
|
||||
@Suppress("NotCloseable")
|
||||
@Stable
|
||||
@@ -256,60 +247,30 @@ fun ModalSideDrawer(
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
var minValue by remember { mutableFloatStateOf(with(density) { ContainerWidth.toPx() }) }
|
||||
val maxValue = 0f
|
||||
var maxValue by remember { mutableFloatStateOf(with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() }) }
|
||||
val minValue = 0f
|
||||
|
||||
SideEffect {
|
||||
drawerState.density = density
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at minValue
|
||||
DrawerValue.Open at maxValue
|
||||
DrawerValue.Closed at maxValue
|
||||
DrawerValue.Open at minValue
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val velocityTracker = remember { VelocityTracker() }
|
||||
val dragModifier = if (gesturesEnabled) {
|
||||
Modifier.pointerInput(isRtl) {
|
||||
val multiplier = if (isRtl) -1 else 1
|
||||
awaitEachGesture {
|
||||
fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
var ignore = false
|
||||
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
|
||||
val overSlop = over * multiplier
|
||||
val canConsume = canConsume(overSlop)
|
||||
if (canConsume && !ignore) {
|
||||
velocityTracker.addPointerInputChange(change)
|
||||
change.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
|
||||
} else {
|
||||
ignore = true
|
||||
}
|
||||
}
|
||||
if (drag != null) {
|
||||
horizontalDrag(drag.id) {
|
||||
velocityTracker.addPointerInputChange(it)
|
||||
drag.consume()
|
||||
drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
|
||||
}
|
||||
val velocity = velocityTracker.calculateVelocity()
|
||||
velocityTracker.resetTracking()
|
||||
scope.launch {
|
||||
drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier.fillMaxSize().then(dragModifier)) {
|
||||
Box(
|
||||
modifier.fillMaxSize().anchoredHorizontalDraggable(
|
||||
state = drawerState.anchoredDraggableState,
|
||||
enableDragFromStartToEnd = drawerState.isOpen,
|
||||
enableDragFromEndToStart = drawerState.isClosed,
|
||||
enabled = gesturesEnabled,
|
||||
),
|
||||
) {
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
val step = calculateFraction(maxValue, minValue, drawerState.currentOffset)
|
||||
lerp(0, 10, step).dp
|
||||
}
|
||||
}.collectAsState(0.dp)
|
||||
@@ -331,7 +292,7 @@ fun ModalSideDrawer(
|
||||
}
|
||||
},
|
||||
fraction = {
|
||||
calculateFraction(minValue, maxValue, drawerState.requireOffset())
|
||||
calculateFraction(maxValue, minValue, drawerState.requireOffset())
|
||||
},
|
||||
color = scrimColor,
|
||||
)
|
||||
@@ -349,7 +310,7 @@ fun ModalSideDrawer(
|
||||
val scaledHeight = (multiplierY * placeable.height).roundToInt()
|
||||
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
|
||||
layout(scaledWidth, scaledHeight) {
|
||||
reMeasured.placeRelative(-(scaledWidth - placeable.width), 0)
|
||||
reMeasured.placeRelative(placeable.width - scaledWidth, 0)
|
||||
}
|
||||
}.background(
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
|
||||
@@ -361,14 +322,20 @@ fun ModalSideDrawer(
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
IntOffset(lerp(0f, minValue * 0.05f, predictiveState).roundToInt(), 0)
|
||||
IntOffset(lerp(0f, maxValue * 0.05f, predictiveState).roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.onGloballyPositioned {
|
||||
val w = it.size.width
|
||||
if (w != 0) {
|
||||
minValue = it.size.width.toFloat()
|
||||
Modifier.onSizeChanged {
|
||||
val width = it.width
|
||||
if (width != 0) {
|
||||
maxValue = width.toFloat()
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at maxValue
|
||||
DrawerValue.Open at minValue
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.compose.material3.fork
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.exponentialDecay
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredHorizontalDraggable
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.gestures.draggableAnchors
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.SwipeToDismissBoxDefaults
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
private val SnapAnimationSpec = SpringSpec<Float>()
|
||||
|
||||
// TODO: Update to M3 spec once it's available
|
||||
private val DecayAnimationSpec = exponentialDecay<Float>()
|
||||
|
||||
/**
|
||||
* State of the [SwipeToDismissBox] composable.
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param density The density that this state can use to convert values to and from dp.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param positionalThreshold The positional threshold to be used when calculating the target state
|
||||
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
|
||||
* the start of a transition. It will be, depending on the direction of the interaction, added or
|
||||
* subtracted from/to the origin offset. It should always be a positive value.
|
||||
*/
|
||||
class SwipeToDismissBoxState(
|
||||
initialValue: SwipeToDismissBoxValue,
|
||||
internal val density: Density,
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
) {
|
||||
internal val anchoredDraggableState =
|
||||
AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
snapAnimationSpec = SnapAnimationSpec,
|
||||
decayAnimationSpec = DecayAnimationSpec,
|
||||
confirmValueChange = confirmValueChange,
|
||||
positionalThreshold = positionalThreshold,
|
||||
velocityThreshold = { with(density) { DismissVelocityThreshold.toPx() } },
|
||||
)
|
||||
|
||||
internal val offset: Float get() = anchoredDraggableState.offset
|
||||
|
||||
/**
|
||||
* Require the current offset.
|
||||
*
|
||||
* @throws IllegalStateException If the offset has not been initialized yet
|
||||
*/
|
||||
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
/**
|
||||
* The current state value of the [SwipeToDismissBoxState].
|
||||
*/
|
||||
val currentValue: SwipeToDismissBoxValue get() = anchoredDraggableState.currentValue
|
||||
|
||||
/**
|
||||
* The target state. This is the closest state to the current offset (taking into account
|
||||
* positional thresholds). If no interactions like animations or drags are in progress, this
|
||||
* will be the current state.
|
||||
*/
|
||||
val targetValue: SwipeToDismissBoxValue get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* The fraction of the progress going from currentValue to targetValue, within [0f..1f] bounds.
|
||||
*/
|
||||
@get:FloatRange(from = 0.0, to = 1.0)
|
||||
val progress: Float get() = anchoredDraggableState.progress(anchoredDraggableState.settledValue, anchoredDraggableState.targetValue)
|
||||
|
||||
/**
|
||||
* The direction (if any) in which the composable has been or is being dismissed.
|
||||
*
|
||||
* Use this to change the background of the [SwipeToDismissBox] if you want different actions
|
||||
* on each side.
|
||||
*/
|
||||
val dismissDirection: SwipeToDismissBoxValue
|
||||
get() = when {
|
||||
offset == 0f || offset.isNaN() -> SwipeToDismissBoxValue.Settled
|
||||
offset > 0f -> SwipeToDismissBoxValue.StartToEnd
|
||||
else -> SwipeToDismissBoxValue.EndToStart
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state without any animation and suspend until it's set
|
||||
*
|
||||
* @param targetValue The new target value
|
||||
*/
|
||||
suspend fun snapTo(targetValue: SwipeToDismissBoxValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the component to the default position with animation and suspend until it if fully
|
||||
* reset or animation has been cancelled. This method will throw [CancellationException] if
|
||||
* the animation is interrupted
|
||||
*
|
||||
* @return the reason the reset animation ended
|
||||
*/
|
||||
suspend fun reset() = anchoredDraggableState.animateTo(
|
||||
targetValue = SwipeToDismissBoxValue.Settled,
|
||||
)
|
||||
|
||||
/**
|
||||
* Dismiss the component in the given [direction], with an animation and suspend. This method
|
||||
* will throw [CancellationException] if the animation is interrupted
|
||||
*
|
||||
* @param direction The dismiss direction.
|
||||
*/
|
||||
suspend fun dismiss(direction: SwipeToDismissBoxValue) {
|
||||
anchoredDraggableState.animateTo(targetValue = direction)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [SwipeToDismissBoxState].
|
||||
*/
|
||||
fun Saver(
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean,
|
||||
positionalThreshold: (totalDistance: Float) -> Float,
|
||||
density: Density,
|
||||
) = Saver<SwipeToDismissBoxState, SwipeToDismissBoxValue>(
|
||||
save = { it.currentValue },
|
||||
restore = {
|
||||
SwipeToDismissBoxState(
|
||||
it,
|
||||
density,
|
||||
confirmValueChange,
|
||||
positionalThreshold,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SwipeToDismissBoxState].
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param positionalThreshold The positional threshold to be used when calculating the target state
|
||||
* while a swipe is in progress and when settling after the swipe ends. This is the distance from
|
||||
* the start of a transition. It will be, depending on the direction of the interaction, added or
|
||||
* subtracted from/to the origin offset. It should always be a positive value.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSwipeToDismissBoxState(
|
||||
initialValue: SwipeToDismissBoxValue = SwipeToDismissBoxValue.Settled,
|
||||
confirmValueChange: (SwipeToDismissBoxValue) -> Boolean = { true },
|
||||
positionalThreshold: (totalDistance: Float) -> Float =
|
||||
SwipeToDismissBoxDefaults.positionalThreshold,
|
||||
): SwipeToDismissBoxState {
|
||||
val density = LocalDensity.current
|
||||
return rememberSaveable(
|
||||
saver = SwipeToDismissBoxState.Saver(
|
||||
confirmValueChange = confirmValueChange,
|
||||
density = density,
|
||||
positionalThreshold = positionalThreshold,
|
||||
),
|
||||
) {
|
||||
SwipeToDismissBoxState(initialValue, density, confirmValueChange, positionalThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that can be dismissed by swiping left or right.
|
||||
*
|
||||
* @param state The state of this component.
|
||||
* @param backgroundContent A composable that is stacked behind the [content] and is exposed when
|
||||
* the content is swiped. You can/should use the [state] to have different backgrounds on each side.
|
||||
* @param modifier Optional [Modifier] for this component.
|
||||
* @param enableDismissFromStartToEnd Whether SwipeToDismissBox can be dismissed from start to end.
|
||||
* @param enableDismissFromEndToStart Whether SwipeToDismissBox can be dismissed from end to start.
|
||||
* @param gesturesEnabled Whether swipe-to-dismiss can be interacted by gestures.
|
||||
* @param content The content that can be dismissed.
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeToDismissBox(
|
||||
state: SwipeToDismissBoxState,
|
||||
backgroundContent: @Composable RowScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enableDismissFromStartToEnd: Boolean = true,
|
||||
enableDismissFromEndToStart: Boolean = true,
|
||||
gesturesEnabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier
|
||||
.anchoredHorizontalDraggable(
|
||||
state = state.anchoredDraggableState,
|
||||
enableDragFromStartToEnd = enableDismissFromStartToEnd,
|
||||
enableDragFromEndToStart = enableDismissFromEndToStart,
|
||||
enabled = gesturesEnabled && state.currentValue == SwipeToDismissBoxValue.Settled,
|
||||
),
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
Row(
|
||||
content = backgroundContent,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
)
|
||||
Row(
|
||||
content = content,
|
||||
modifier = Modifier.draggableAnchors(
|
||||
state.anchoredDraggableState,
|
||||
Orientation.Horizontal,
|
||||
) { size, _ ->
|
||||
val width = size.width.toFloat()
|
||||
return@draggableAnchors DraggableAnchors {
|
||||
SwipeToDismissBoxValue.Settled at 0f
|
||||
if (enableDismissFromStartToEnd) {
|
||||
SwipeToDismissBoxValue.StartToEnd at width
|
||||
}
|
||||
if (enableDismissFromEndToStart) {
|
||||
SwipeToDismissBoxValue.EndToStart at -width
|
||||
}
|
||||
} to state.targetValue
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val DismissVelocityThreshold = 125.dp
|
||||
+14
-62
@@ -56,7 +56,8 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
// Current quickscale state
|
||||
private val quickScaleThreshold: Float
|
||||
private val quickScaleThreshold =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20F, context.resources.displayMetrics)
|
||||
|
||||
// Long click handler
|
||||
private var longClickJob: Job? = null
|
||||
@@ -85,9 +86,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
@ScaleType
|
||||
private var minimumScaleType = SCALE_TYPE_CENTER_INSIDE
|
||||
|
||||
// Whether to crop borders.
|
||||
private var cropBorders = false
|
||||
|
||||
// Gesture detection settings
|
||||
private var panEnabled = true
|
||||
|
||||
@@ -186,15 +184,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
var isReady = false
|
||||
private set
|
||||
|
||||
/**
|
||||
* Call to find whether the main image (base layer tiles where relevant) have been loaded. Before
|
||||
* this event the view is blank unless a preview was provided.
|
||||
*
|
||||
* @return true if the main image (not the preview) has been loaded and is ready to display.
|
||||
*/
|
||||
var isImageLoaded = false
|
||||
private set
|
||||
|
||||
// Event listener
|
||||
private var onImageEventListener: OnImageEventListener? = null
|
||||
|
||||
@@ -218,11 +207,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
private val mtrx = Matrix()
|
||||
|
||||
init {
|
||||
setMinimumDpi(160)
|
||||
setDoubleTapZoomDpi(160)
|
||||
setGestureDetector(context)
|
||||
quickScaleThreshold =
|
||||
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20F, context.resources.displayMetrics)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,7 +255,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
sHeight = 0
|
||||
srcRect.setEmpty()
|
||||
isReady = false
|
||||
isImageLoaded = false
|
||||
bitmap = null
|
||||
bitmapIsCached = false
|
||||
}
|
||||
@@ -768,19 +752,13 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the base layer of tiles or full size bitmap is ready.
|
||||
*/
|
||||
private val isBaseLayerReady: Boolean
|
||||
get() = bitmap != null
|
||||
|
||||
/**
|
||||
* Check whether view and image dimensions are known and either a preview, full size image or
|
||||
* base layer tiles are loaded. First time, send ready event to listener. The next draw will
|
||||
* display an image.
|
||||
*/
|
||||
private fun checkReady(): Boolean {
|
||||
val ready = width > 0 && height > 0 && sWidth > 0 && sHeight > 0 && (bitmap != null || isBaseLayerReady)
|
||||
val ready = width > 0 && height > 0 && sWidth > 0 && sHeight > 0 && bitmap != null
|
||||
if (!isReady && ready) {
|
||||
preDraw()
|
||||
isReady = true
|
||||
@@ -789,19 +767,6 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
return ready
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether either the full size bitmap or base layer tiles are loaded. First time, send image
|
||||
* loaded event to listener.
|
||||
*/
|
||||
private fun checkImageLoaded(): Boolean {
|
||||
val imageLoaded = isBaseLayerReady
|
||||
if (!isImageLoaded && imageLoaded) {
|
||||
preDraw()
|
||||
isImageLoaded = true
|
||||
}
|
||||
return imageLoaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets scale and translate ready for the next draw.
|
||||
*/
|
||||
@@ -832,13 +797,14 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* is set so one dimension fills the view and the image is centered on the other dimension. Used to calculate what the target of an
|
||||
* animation should be.
|
||||
*
|
||||
* @param _center Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached.
|
||||
* @param maybeCenter Whether the image should be centered in the dimension it's too small to fill. While animating this can be false to avoid changes in direction as bounds are reached.
|
||||
* @param sat The scale we want and the translation we're aiming for. The values are adjusted to be valid.
|
||||
*/
|
||||
private fun fitToBounds(_center: Boolean, sat: ScaleAndTranslate) {
|
||||
var center = _center
|
||||
if (panLimit == PAN_LIMIT_OUTSIDE && isReady) {
|
||||
center = false
|
||||
private fun fitToBounds(maybeCenter: Boolean, sat: ScaleAndTranslate) {
|
||||
val center = if (panLimit == PAN_LIMIT_OUTSIDE && isReady) {
|
||||
false
|
||||
} else {
|
||||
maybeCenter
|
||||
}
|
||||
val vTranslate = sat.vTranslate
|
||||
val scale = limitedScale(sat.scale)
|
||||
@@ -920,21 +886,9 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
this.bitmap = bitmap
|
||||
sWidth = srcRect.width()
|
||||
sHeight = srcRect.height()
|
||||
val ready = checkReady()
|
||||
val imageLoaded = checkImageLoaded()
|
||||
if (ready || imageLoaded) {
|
||||
invalidate()
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set border crop of non-filled (white or black) content.
|
||||
*
|
||||
* @param cropBorders Whether to crop image borders.
|
||||
*/
|
||||
fun setCropBorders(cropBorders: Boolean) {
|
||||
this.cropBorders = cropBorders
|
||||
checkReady()
|
||||
invalidate()
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -987,7 +941,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param sTarget target object for result. The same instance is also returned.
|
||||
* @return a coordinate representing the corresponding source coordinate. This is the same instance passed to the sTarget param.
|
||||
*/
|
||||
fun viewToSourceCoord(vx: Float, vy: Float, sTarget: PointF = PointF()): PointF? {
|
||||
private fun viewToSourceCoord(vx: Float, vy: Float, sTarget: PointF = PointF()): PointF? {
|
||||
if (vTranslate == null) {
|
||||
return null
|
||||
}
|
||||
@@ -1027,7 +981,7 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* @param vTarget target object for result. The same instance is also returned.
|
||||
* @return view coordinates. This is the same instance passed to the vTarget param.
|
||||
*/
|
||||
fun sourceToViewCoord(sx: Float, sy: Float, vTarget: PointF = PointF()): PointF? {
|
||||
private fun sourceToViewCoord(sx: Float, sy: Float, vTarget: PointF = PointF()): PointF? {
|
||||
if (vTranslate == null) {
|
||||
return null
|
||||
}
|
||||
@@ -1704,7 +1658,5 @@ open class SubsamplingScaleImageView @JvmOverloads constructor(
|
||||
* State change originated from a double tap zoom anim.
|
||||
*/
|
||||
const val ORIGIN_DOUBLE_TAP_ZOOM = 4
|
||||
|
||||
private val TAG = SubsamplingScaleImageView::class.java.simpleName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2022 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.google.accompanist.systemuicontroller
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
||||
/**
|
||||
* A class which provides easy-to-use utilities for updating the System UI bar
|
||||
* colors within Jetpack Compose.
|
||||
*/
|
||||
@Stable
|
||||
interface SystemUiController {
|
||||
|
||||
/**
|
||||
* Control for the behavior of the system bars. This value should be one of the
|
||||
* [WindowInsetsControllerCompat] behavior constants:
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_TOUCH] (Deprecated),
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_DEFAULT] and
|
||||
* [WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE].
|
||||
*/
|
||||
var systemBarsBehavior: Int
|
||||
|
||||
/**
|
||||
* Property which holds the status bar visibility. If set to true, show the status bar,
|
||||
* otherwise hide the status bar.
|
||||
*/
|
||||
var isStatusBarVisible: Boolean
|
||||
|
||||
/**
|
||||
* Property which holds the navigation bar visibility. If set to true, show the navigation bar,
|
||||
* otherwise hide the navigation bar.
|
||||
*/
|
||||
var isNavigationBarVisible: Boolean
|
||||
|
||||
/**
|
||||
* Property which holds the status & navigation bar visibility. If set to true, show both bars,
|
||||
* otherwise hide both bars.
|
||||
*/
|
||||
var isSystemBarsVisible: Boolean
|
||||
get() = isNavigationBarVisible && isStatusBarVisible
|
||||
set(value) {
|
||||
isStatusBarVisible = value
|
||||
isNavigationBarVisible = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers a [SystemUiController] for the given [window].
|
||||
*
|
||||
* If no [window] is provided, an attempt to find the correct [Window] is made.
|
||||
*
|
||||
* First, if the [LocalView]'s parent is a [DialogWindowProvider], then that dialog's [Window] will
|
||||
* be used.
|
||||
*
|
||||
* Second, we attempt to find [Window] for the [Activity] containing the [LocalView].
|
||||
*
|
||||
* If none of these are found (such as may happen in a preview), then the functionality of the
|
||||
* returned [SystemUiController] will be degraded, but won't throw an exception.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSystemUiController(
|
||||
window: Window? = findWindow(),
|
||||
): SystemUiController {
|
||||
val view = LocalView.current
|
||||
return remember(view, window) { AndroidSystemUiController(view, window) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun findWindow(): Window? =
|
||||
(LocalView.current.parent as? DialogWindowProvider)?.window
|
||||
?: LocalView.current.context.findWindow()
|
||||
|
||||
private tailrec fun Context.findWindow(): Window? =
|
||||
when (this) {
|
||||
is Activity -> window
|
||||
is ContextWrapper -> baseContext.findWindow()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper class for setting the navigation and status bar colors for a [View], gracefully
|
||||
* degrading behavior based upon API level.
|
||||
*
|
||||
* Typically you would use [rememberSystemUiController] to remember an instance of this.
|
||||
*/
|
||||
internal class AndroidSystemUiController(
|
||||
private val view: View,
|
||||
window: Window?,
|
||||
) : SystemUiController {
|
||||
private val windowInsetsController = window?.let {
|
||||
WindowCompat.getInsetsController(it, view)
|
||||
}
|
||||
|
||||
override var systemBarsBehavior: Int
|
||||
get() = windowInsetsController?.systemBarsBehavior ?: 0
|
||||
set(value) {
|
||||
windowInsetsController?.systemBarsBehavior = value
|
||||
}
|
||||
|
||||
override var isStatusBarVisible: Boolean
|
||||
get() {
|
||||
return ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(WindowInsetsCompat.Type.statusBars()) == true
|
||||
}
|
||||
set(value) {
|
||||
if (value) {
|
||||
windowInsetsController?.show(WindowInsetsCompat.Type.statusBars())
|
||||
} else {
|
||||
windowInsetsController?.hide(WindowInsetsCompat.Type.statusBars())
|
||||
}
|
||||
}
|
||||
|
||||
override var isNavigationBarVisible: Boolean
|
||||
get() {
|
||||
return ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(WindowInsetsCompat.Type.navigationBars()) == true
|
||||
}
|
||||
set(value) {
|
||||
if (value) {
|
||||
windowInsetsController?.show(WindowInsetsCompat.Type.navigationBars())
|
||||
} else {
|
||||
windowInsetsController?.hide(WindowInsetsCompat.Type.navigationBars())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,12 +46,14 @@ import com.hippo.ehviewer.coil.MergeInterceptor
|
||||
import com.hippo.ehviewer.dailycheck.checkDawn
|
||||
import com.hippo.ehviewer.dao.SearchDatabase
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.ktbuilder.diskCache
|
||||
import com.hippo.ehviewer.ktbuilder.imageLoader
|
||||
import com.hippo.ehviewer.ktor.CronetEngine
|
||||
import com.hippo.ehviewer.legacy.cleanObsoleteCache
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.lockObserver
|
||||
import com.hippo.ehviewer.ui.tools.dataStateFlow
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
@@ -103,13 +105,13 @@ class EhApplication : Application(), SingletonImageLoader.Factory {
|
||||
super.onCreate()
|
||||
System.loadLibrary("ehviewer")
|
||||
lifecycleScope.launchIO {
|
||||
launch { EhTagDatabase }
|
||||
launch { EhDB }
|
||||
dataStateFlow.value
|
||||
launch {
|
||||
EhTagDatabase
|
||||
}
|
||||
launch {
|
||||
EhDB
|
||||
}
|
||||
launch {
|
||||
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode.key !in Settings.prefs) {
|
||||
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
|
||||
}
|
||||
DownloadManager.readMetadataFromLocal()
|
||||
}
|
||||
launch {
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.net.Uri
|
||||
import arrow.fx.coroutines.release
|
||||
import arrow.fx.coroutines.resource
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadArtist
|
||||
import com.hippo.ehviewer.dao.DownloadDirname
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.DownloadLabel
|
||||
@@ -32,6 +33,7 @@ import com.hippo.ehviewer.dao.QuickSearch
|
||||
import com.hippo.ehviewer.dao.Schema17to18
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.util.sendTo
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.asUniFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import splitties.arch.room.roomDb
|
||||
@@ -112,8 +114,11 @@ object EhDB {
|
||||
dao.insertOrIgnore(downloadDirnameList)
|
||||
}
|
||||
|
||||
val downloadsCount
|
||||
get() = db.downloadsDao().count()
|
||||
val downloadsCountByLabel
|
||||
get() = db.downloadsDao().countByLabel()
|
||||
|
||||
val downloadsCountByArtist
|
||||
get() = db.downloadsDao().countByArtist()
|
||||
|
||||
suspend fun getAllDownloadLabelList() = db.downloadLabelDao().list()
|
||||
|
||||
@@ -141,6 +146,14 @@ object EhDB {
|
||||
dao.fill(raw.position)
|
||||
}
|
||||
|
||||
suspend fun putDownloadArtist(gid: Long, artists: List<DownloadArtist>) {
|
||||
if (artists.isNotEmpty()) {
|
||||
val dao = db.downloadArtistDao()
|
||||
dao.deleteByGid(gid)
|
||||
dao.insert(artists)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeLocalFavorites(galleryInfo: BaseGalleryInfo) {
|
||||
db.localFavoritesDao().deleteByKey(galleryInfo.gid)
|
||||
deleteGalleryInfo(galleryInfo)
|
||||
@@ -260,10 +273,10 @@ object EhDB {
|
||||
db.filterDao().update(filter)
|
||||
}
|
||||
|
||||
fun exportDB(context: Context, uri: Uri) {
|
||||
fun exportDB(context: Context, file: UniFile) {
|
||||
db.query("PRAGMA wal_checkpoint(FULL)", null).use { it.moveToNext() }
|
||||
val dbFile = context.getDatabasePath(DB_NAME)
|
||||
dbFile.asUniFile() sendTo uri.asUniFile()
|
||||
dbFile.asUniFile() sendTo file
|
||||
}
|
||||
|
||||
suspend fun importDB(context: Context, uri: Uri) {
|
||||
@@ -278,49 +291,39 @@ object EhDB {
|
||||
it.close()
|
||||
context.deleteDatabase(tempDBName)
|
||||
} use { oldDB ->
|
||||
runCatching {
|
||||
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
|
||||
db.galleryDao().insertOrIgnore(oldDB.galleryDao().list())
|
||||
|
||||
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
|
||||
|
||||
val downloadLabelList = oldDB.downloadLabelDao().list()
|
||||
DownloadManager.addDownloadLabel(downloadLabelList)
|
||||
|
||||
oldDB.downloadDirnameDao().list().let {
|
||||
importDownloadDirname(it)
|
||||
}
|
||||
runCatching {
|
||||
db.progressDao().insertOrIgnore(oldDB.progressDao().list())
|
||||
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
importHistoryInfo(historyInfoList)
|
||||
|
||||
val quickSearchList = oldDB.quickSearchDao().list()
|
||||
val currentQuickSearchList = db.quickSearchDao().list()
|
||||
val offset = currentQuickSearchList.size
|
||||
val importList = quickSearchList.filter { newQS ->
|
||||
currentQuickSearchList.none { it.name == newQS.name }
|
||||
}.onEachIndexed { index, q -> q.position = index + offset }
|
||||
importQuickSearch(importList)
|
||||
|
||||
oldDB.localFavoritesDao().list().let {
|
||||
importLocalFavorites(it)
|
||||
}
|
||||
runCatching {
|
||||
val downloadLabelList = oldDB.downloadLabelDao().list()
|
||||
DownloadManager.addDownloadLabel(downloadLabelList)
|
||||
}
|
||||
runCatching {
|
||||
oldDB.downloadDirnameDao().list().let {
|
||||
importDownloadDirname(it)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
}
|
||||
runCatching {
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
importHistoryInfo(historyInfoList)
|
||||
}
|
||||
runCatching {
|
||||
val quickSearchList = oldDB.quickSearchDao().list()
|
||||
val currentQuickSearchList = db.quickSearchDao().list()
|
||||
val offset = currentQuickSearchList.size
|
||||
val importList = quickSearchList.filter { newQS ->
|
||||
currentQuickSearchList.none { it.name == newQS.name }
|
||||
}.onEachIndexed { index, q -> q.position = index + offset }
|
||||
importQuickSearch(importList)
|
||||
}
|
||||
runCatching {
|
||||
oldDB.localFavoritesDao().list().let {
|
||||
importLocalFavorites(it)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
val filterList = oldDB.filterDao().list()
|
||||
val currentFilterList = db.filterDao().list()
|
||||
filterList.forEach {
|
||||
if (it !in currentFilterList) addFilter(it)
|
||||
}
|
||||
|
||||
val filterList = oldDB.filterDao().list()
|
||||
val currentFilterList = db.filterDao().list()
|
||||
filterList.forEach {
|
||||
if (it !in currentFilterList) addFilter(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.download.SortMode
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.OrientationType
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
|
||||
import java.util.Locale
|
||||
@@ -110,12 +111,15 @@ object Settings : DataStorePreferences(null) {
|
||||
val listThumbSize = intPref("list_tile_size", 40)
|
||||
val languageFilter = intPref("language_filter", -1)
|
||||
val downloadSortMode = intPref("download_sort_mode", SortMode.Default.flag)
|
||||
val downloadFilterMode = intPref("download_filter_mode", DownloadsFilterMode.Default.flag)
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val blackDarkTheme = boolPref("black_dark_theme", false)
|
||||
val gridView = boolPref("grid_view", false)
|
||||
val showGalleryPages = boolPref("show_gallery_pages", true)
|
||||
val qSSaveProgress = boolPref("qs_save_progress", true)
|
||||
val security = boolPref("require_unlock", false)
|
||||
val animateItems = boolPref("animate_items", true)
|
||||
val displayName = stringOrNullPref("display_name", null)
|
||||
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
|
||||
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
@@ -142,7 +146,8 @@ object Settings : DataStorePreferences(null) {
|
||||
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
|
||||
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
|
||||
var enabledSecurity by boolPref("enable_secure", false)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", false)
|
||||
var backupBeforeUpdate by boolPref("backup_before_update", true)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", AppConfig.isSnapshot)
|
||||
var mediaScan by boolPref("media_scan", false).observed { updateWhenKeepMediaStatusChanges() }
|
||||
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
|
||||
var saveParseErrorBody by boolPref("save_parse_error_body", true)
|
||||
@@ -158,21 +163,13 @@ object Settings : DataStorePreferences(null) {
|
||||
var defaultFavSlot by intPref("default_favorite_slot", -2)
|
||||
var securityDelay by intPref("require_unlock_delay", 0)
|
||||
var clipboardTextHashCode by intPref("clipboard_text_hash_code", 0)
|
||||
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
|
||||
var requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
|
||||
var updateIntervalDays by intPref("update_interval_days", 7)
|
||||
var lastDawnDays by intPref("last_dawn_days", 0)
|
||||
var recentToplist by stringPref("recent_toplist", "11")
|
||||
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
|
||||
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
|
||||
var displayName by stringOrNullPref("display_name", null)
|
||||
var avatar by stringOrNullPref("avatar", null)
|
||||
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
|
||||
var lastUpdateTime by longPref("last_update_time", 0)
|
||||
var favDialogTheta by floatPref("fav_select_dialog_delta", 0F)
|
||||
|
||||
// TODO: Remove this after swipe gestures are correctly handled in compose
|
||||
var touchSlopFactor by intPref("touch_slop", 3)
|
||||
var lastUpdateTime by longPref("last_update_time", BuildConfig.BUILD_TIME)
|
||||
|
||||
// Tachiyomi Reader
|
||||
var newReader by boolPref("new_compose_reader", false)
|
||||
@@ -201,7 +198,8 @@ object Settings : DataStorePreferences(null) {
|
||||
val invertedColors = boolPref("pref_inverted_colors", false)
|
||||
val readerWebtoonNav = intPref("reader_navigation_mode_webtoon", 0)
|
||||
val readerPagerNav = intPref("reader_navigation_mode_pager", 0)
|
||||
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon", 0)
|
||||
val readerPagerNavInverted = intPref("reader_tapping_inverted_2", 0)
|
||||
val readerWebtoonNavInverted = intPref("reader_tapping_inverted_webtoon_2", 0)
|
||||
val webtoonSidePadding = intPref("webtoon_side_padding", 0)
|
||||
val navigateToPan = boolPref("navigate_pan", true)
|
||||
val imageScaleType = intPref("pref_image_scale_type_key", 1)
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.hippo.ehviewer
|
||||
|
||||
import android.app.UiModeManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
@@ -27,15 +26,6 @@ fun <T, R : Settings.Delegate<T>> R.observed(func: (Unit) -> Unit) = apply { col
|
||||
fun <T, R : Settings.Delegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { flowGetter().collect { flow.emit(Unit) } } }
|
||||
fun <T, R : PrefDelegate<T>> R.emitTo(flow: MutableSharedFlow<Unit>) = apply { collectScope.launch { changesFlow().collect { flow.emit(Unit) } } }
|
||||
|
||||
fun updateWhenLocaleChanges() {
|
||||
val newValue = Settings.language
|
||||
if ("system" == newValue) {
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
|
||||
} else {
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(newValue))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWhenKeepMediaStatusChanges() {
|
||||
collectScope.launchIO {
|
||||
runCatching {
|
||||
|
||||
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.client
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import arrow.core.partially2
|
||||
import arrow.core.right
|
||||
import arrow.fx.coroutines.parMap
|
||||
import arrow.fx.coroutines.parZip
|
||||
@@ -44,8 +45,10 @@ import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
import com.hippo.ehviewer.client.parser.ProfileParser
|
||||
import com.hippo.ehviewer.client.parser.RateGalleryResult
|
||||
import com.hippo.ehviewer.client.parser.SignInParser
|
||||
import com.hippo.ehviewer.client.parser.TorrentParser
|
||||
import com.hippo.ehviewer.client.parser.TorrentResult
|
||||
import com.hippo.ehviewer.client.parser.UserConfigParser
|
||||
import com.hippo.ehviewer.client.parser.VoteCommentResult
|
||||
import com.hippo.ehviewer.client.parser.VoteTagParser
|
||||
import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
@@ -62,6 +65,7 @@ import io.ktor.utils.io.pool.useInstance
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.ceil
|
||||
import kotlin.system.measureTimeMillis
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.SerializationException
|
||||
@@ -80,8 +84,6 @@ private const val MAX_REQUEST_SIZE = 25
|
||||
private const val MAX_SEQUENTIAL_REQUESTS = 5
|
||||
private const val REQUEST_INTERVAL = 5000L
|
||||
|
||||
private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
|
||||
fun Either<String, ByteBuffer>.saveParseError(e: Throwable) {
|
||||
val dir = AppConfig.externalParseErrorDir ?: return
|
||||
val file = File(dir, ReadableTime.getFilenamableTime(System.currentTimeMillis()) + ".txt")
|
||||
@@ -177,16 +179,20 @@ object EhEngine {
|
||||
return ehRequest(url, referer).fetchUsingAsByteBuffer(TorrentParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.fetchUsingAsText(ArchiveParser::parse)
|
||||
.apply { funds = funds ?: ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) }
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String): ArchiveParser.Result {
|
||||
val funds = if (EhUtils.isExHentai) getFunds() else null
|
||||
return ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
.fetchUsingAsText(ArchiveParser::parse.partially2(funds))
|
||||
}
|
||||
|
||||
suspend fun getImageLimits() = parZip(
|
||||
{ ehRequest(EhUrl.URL_HOME).fetchUsingAsByteBuffer(HomeParser::parse) },
|
||||
{ ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds) },
|
||||
{ getFunds() },
|
||||
{ limits, funds -> HomeParser.Result(limits, funds) },
|
||||
)
|
||||
|
||||
private suspend fun getFunds() = ehRequest(EhUrl.URL_FUNDS).fetchUsingAsText(HomeParser::parseFunds)
|
||||
|
||||
suspend fun getNews(parse: Boolean) = ehRequest(EhUrl.URL_NEWS, EhUrl.REFERER_E)
|
||||
.fetchUsingAsText { if (parse) EventPaneParser.parse(this) else null }
|
||||
|
||||
@@ -197,12 +203,12 @@ object EhEngine {
|
||||
|
||||
suspend fun getUConfig(url: String = EhUrl.uConfigUrl) {
|
||||
runSuspendCatching {
|
||||
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
|
||||
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
|
||||
}.onFailure { throwable ->
|
||||
// It may get redirected when accessing ex for the first time
|
||||
if (url == EhUrl.URL_UCONFIG_EX) {
|
||||
logcat(throwable)
|
||||
ehRequest(url).fetchUsingAsText { check(U_CONFIG_TEXT in this) { "Unable to load config from $url!" } }
|
||||
ehRequest(url).fetchUsingAsByteBuffer(UserConfigParser::parse)
|
||||
} else {
|
||||
throw throwable
|
||||
}
|
||||
@@ -234,6 +240,22 @@ object EhEngine {
|
||||
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer).fetchUsingAsByteBuffer(FavoritesParser::parse)
|
||||
.apply { galleryInfoList.fillInfo(url) }
|
||||
|
||||
suspend fun signIn(username: String, password: String): String {
|
||||
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
|
||||
val url = EhUrl.API_SIGN_IN
|
||||
val origin = "https://forums.e-hentai.org"
|
||||
return ehRequest(url, referer, origin) {
|
||||
formBody {
|
||||
append("referer", referer)
|
||||
append("b", "")
|
||||
append("bt", "")
|
||||
append("UserName", username)
|
||||
append("PassWord", password)
|
||||
append("CookieDate", "1")
|
||||
}
|
||||
}.fetchUsingAsText(SignInParser::parse)
|
||||
}
|
||||
|
||||
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
if (id == -1L) {
|
||||
@@ -431,13 +453,13 @@ object EhEngine {
|
||||
}
|
||||
|
||||
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
|
||||
galleryList.chunked(MAX_SEQUENTIAL_REQUESTS).forEachIndexed { index, chunk ->
|
||||
if (index != 0) {
|
||||
delay(REQUEST_INTERVAL)
|
||||
}
|
||||
chunk.parMap { (gid, token) ->
|
||||
galleryList.forEach { (gid, token) ->
|
||||
// https://github.com/FooIbar/EhViewer/issues/1190
|
||||
// Workaround for duplicate items when sorting by favorited time
|
||||
val timeTaken = measureTimeMillis {
|
||||
modifyFavorites(gid, token, dstCat)
|
||||
}
|
||||
delay(1000 - timeTaken)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhApplication.Companion.ktorClient
|
||||
import com.hippo.ehviewer.EhApplication.Companion.noRedirectKtorClient
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -50,7 +51,7 @@ inline fun <reified T> String.parseAs(): T = json.decodeFromString(this)
|
||||
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36"
|
||||
const val CHROME_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${BuildConfig.CHROME_VERSION}.0.0.0 Mobile Safari/537.36"
|
||||
const val CHROME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
|
||||
const val CHROME_ACCEPT_LANGUAGE = "en-US,en;q=0.5"
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ object EhUrl {
|
||||
const val API_E = "https://api.e-hentai.org/api.php"
|
||||
const val URL_UCONFIG_E = HOST_E + "uconfig.php"
|
||||
const val URL_MY_TAGS_E = HOST_E + "mytags"
|
||||
const val API_SIGN_IN = "https://forums.e-hentai.org/index.php?act=Login&CODE=01"
|
||||
const val URL_POPULAR_E = "https://e-hentai.org/popular"
|
||||
const val URL_POPULAR_EX = "https://exhentai.org/popular"
|
||||
const val URL_IMAGE_SEARCH_E = "https://upload.e-hentai.org/image_lookup.php"
|
||||
|
||||
@@ -153,8 +153,7 @@ object EhUtils {
|
||||
|
||||
fun signOut() {
|
||||
EhCookieStore.signOut()
|
||||
Settings.avatar = null
|
||||
Settings.displayName = null
|
||||
Settings.displayName.value = null
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
Settings.needSignIn = true
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ object ArchiveParser {
|
||||
private const val ERROR_INSUFFICIENT_FUNDS =
|
||||
"You do not have enough funds to download this archive."
|
||||
|
||||
fun parse(body: String): Result {
|
||||
fun parse(body: String, maybeFunds: Funds?): Result {
|
||||
val m = PATTERN_HATH_FORM.find(body)!!
|
||||
val paramOr = m.groupValues[1]
|
||||
val archiveList = ArrayList<Archive>()
|
||||
@@ -59,14 +59,12 @@ object ArchiveParser {
|
||||
val item = Archive(res, name, size, cost, true)
|
||||
archiveList.add(item)
|
||||
}
|
||||
val result = Result(paramOr, archiveList, null)
|
||||
PATTERN_CURRENT_FUNDS.find(body)?.groupValues?.run {
|
||||
val funds = maybeFunds ?: PATTERN_CURRENT_FUNDS.find(body)!!.groupValues.run {
|
||||
val fundsGP = ParserUtils.parseInt(get(1), 0)
|
||||
val fundsC = ParserUtils.parseInt(get(2), 0)
|
||||
val funds = HomeParser.Funds(fundsGP, fundsC)
|
||||
result.funds = funds
|
||||
Funds("%,d".format(fundsGP), "%,d".format(fundsC))
|
||||
}
|
||||
return result
|
||||
return Result(paramOr, archiveList, funds)
|
||||
}
|
||||
|
||||
fun parseArchiveUrl(body: String): String? {
|
||||
@@ -80,13 +78,13 @@ object ArchiveParser {
|
||||
// TODO: Check more errors
|
||||
}
|
||||
|
||||
class Archive(
|
||||
val res: String,
|
||||
val name: String,
|
||||
val size: String,
|
||||
val cost: String,
|
||||
val isHAtH: Boolean,
|
||||
)
|
||||
|
||||
class Result(val paramOr: String, val archiveList: List<Archive>, var funds: HomeParser.Funds?)
|
||||
data class Result(val paramOr: String, val archiveList: List<Archive>, val funds: Funds)
|
||||
}
|
||||
|
||||
class Archive(
|
||||
val res: String,
|
||||
val name: String,
|
||||
val size: String,
|
||||
val cost: String,
|
||||
val isHAtH: Boolean,
|
||||
)
|
||||
@@ -25,18 +25,18 @@ object HomeParser {
|
||||
PATTERN_FUNDS.find(body)?.groupValues?.run {
|
||||
val fundsC = ParserUtils.parseInt(get(1), 0)
|
||||
val fundsGP = ParserUtils.parseInt(get(2), 0) * 1000
|
||||
return Funds(fundsGP, fundsC)
|
||||
return Funds("%,d+".format(fundsGP), "%,d".format(fundsC))
|
||||
}
|
||||
throw ParseException("Parse funds error")
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Funds(val fundsGP: Int, val fundsC: Int) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class Result(val limits: Limits, val funds: Funds) : Parcelable
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Funds(val gp: String, val credit: String) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class Limits(val current: Int, val maximum: Int, val resetCost: Int) : Parcelable
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.util.toEpochMillis
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import com.hippo.ehviewer.util.toLocalDateTime
|
||||
import com.hippo.ehviewer.util.toLongOrDefault
|
||||
@@ -36,11 +37,14 @@ object ParserUtils {
|
||||
minute()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun formatDate(time: Long): String {
|
||||
return formatter.format(time.toLocalDateTime())
|
||||
}
|
||||
|
||||
fun parseDate(str: String): Long {
|
||||
return formatter.parse(str).toEpochMillis()
|
||||
}
|
||||
|
||||
fun trim(str: String?): String {
|
||||
return str?.unescapeXml()?.trim() ?: ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2016 Hippo Seven
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
|
||||
object SignInParser {
|
||||
private val NAME_PATTERN = Regex("<p>You are now logged in as: (.+?)<")
|
||||
private val ERROR_PATTERN = Regex(
|
||||
"<h4>The error returned was:</h4>\\s*<p>(.+?)</p>" +
|
||||
"|<span class=\"postcolor\">(.+?)</span>",
|
||||
)
|
||||
|
||||
fun parse(body: String): String {
|
||||
return NAME_PATTERN.find(body)?.let {
|
||||
it.groupValues[1]
|
||||
} ?: ERROR_PATTERN.find(body)?.let {
|
||||
throw EhException(it.groupValues[1].ifEmpty { it.groupValues[2] })
|
||||
} ?: throw ParseException("Can't parse sign in")
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,18 @@ object TorrentParser {
|
||||
|
||||
@Serializable
|
||||
data class Torrent(
|
||||
val outdated: Boolean,
|
||||
val posted: String,
|
||||
val size: String,
|
||||
val seeds: Int,
|
||||
val peers: Int,
|
||||
val downloads: Int,
|
||||
val uploader: String,
|
||||
val url: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
fun Torrent.format() = "[$posted] $name [$size] [↑$seeds ↓$peers ✓$downloads]"
|
||||
fun Torrent.format() = "↑$seeds ↓$peers ✓$downloads"
|
||||
|
||||
typealias TorrentResult = List<Torrent>
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
object UserConfigParser {
|
||||
private external fun parseFavCat(body: ByteBuffer, size: Int = body.limit()): Int
|
||||
|
||||
fun parse(body: ByteBuffer) = runCatching {
|
||||
val favCat = unmarshalParsingAs<Array<String>>(body, ::parseFavCat)
|
||||
Settings.favCat = favCat
|
||||
}.onFailure {
|
||||
throw ParseException("Can't parse user config", it)
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class,
|
||||
BaseGalleryInfo::class, DownloadLabel::class, DownloadEntity::class, DownloadDirname::class, DownloadArtist::class,
|
||||
Filter::class, HistoryInfo::class, LocalFavoriteInfo::class, ProgressInfo::class, QuickSearch::class,
|
||||
GalleryEntityFts::class,
|
||||
],
|
||||
version = 22,
|
||||
version = 23,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 4, to = 5, spec = Schema4to5::class),
|
||||
@@ -32,11 +32,13 @@ import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
AutoMigration(from = 19, to = 20),
|
||||
AutoMigration(from = 20, to = 21, spec = Schema20to21::class),
|
||||
AutoMigration(from = 21, to = 22, spec = Schema21to22::class),
|
||||
AutoMigration(from = 22, to = 23),
|
||||
],
|
||||
)
|
||||
@TypeConverters(FilterModeConverter::class)
|
||||
abstract class EhDatabase : RoomDatabase() {
|
||||
abstract fun galleryDao(): GalleryDao
|
||||
abstract fun downloadArtistDao(): DownloadArtistDao
|
||||
abstract fun downloadDirnameDao(): DownloadDirnameDao
|
||||
abstract fun downloadLabelDao(): DownloadLabelDao
|
||||
abstract fun downloadsDao(): DownloadsDao
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
|
||||
@Entity(
|
||||
tableName = "DOWNLOAD_ARTISTS",
|
||||
primaryKeys = ["GID", "ARTIST"],
|
||||
foreignKeys = [
|
||||
ForeignKey(DownloadEntity::class, ["GID"], ["GID"], onDelete = ForeignKey.CASCADE),
|
||||
],
|
||||
)
|
||||
data class DownloadArtist(
|
||||
@ColumnInfo(name = "GID")
|
||||
val gid: Long,
|
||||
|
||||
@ColumnInfo(name = "ARTIST")
|
||||
val artist: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(gid: Long, artists: Iterable<String>) = artists.map { DownloadArtist(gid, it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.hippo.ehviewer.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
|
||||
@Dao
|
||||
interface DownloadArtistDao {
|
||||
@Insert
|
||||
suspend fun insert(downloadArtists: List<DownloadArtist>)
|
||||
|
||||
@Query("DELETE FROM DOWNLOAD_ARTISTS WHERE GID = :gid")
|
||||
suspend fun deleteByGid(gid: Long)
|
||||
}
|
||||
@@ -67,9 +67,13 @@ data class DownloadInfo(
|
||||
@ColumnInfo(name = "DIRNAME")
|
||||
val dirname: String?,
|
||||
|
||||
@Relation(parentColumn = "GID", entityColumn = "GID")
|
||||
var artistInfoList: List<DownloadArtist> = emptyList(),
|
||||
|
||||
@Embedded
|
||||
val downloadInfo: DownloadEntity = DownloadEntity(galleryInfo.gid),
|
||||
) : GalleryInfo by galleryInfo, AbstractDownloadInfo by downloadInfo {
|
||||
|
||||
companion object {
|
||||
const val STATE_INVALID = -1
|
||||
const val STATE_NONE = 0
|
||||
@@ -79,3 +83,6 @@ data class DownloadInfo(
|
||||
const val STATE_FAILED = 4
|
||||
}
|
||||
}
|
||||
|
||||
val DownloadInfo.artists: List<String>
|
||||
get() = artistInfoList.map { it.artist }
|
||||
@@ -14,7 +14,11 @@ import kotlinx.coroutines.flow.Flow
|
||||
interface DownloadsDao {
|
||||
@Suppress("ktlint:standard:annotation")
|
||||
@Query("SELECT LABEL, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_LABELS USING(LABEL) GROUP BY LABEL")
|
||||
fun count(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
|
||||
fun countByLabel(): Flow<Map<@MapColumn("LABEL") String?, @MapColumn("COUNT") Int>>
|
||||
|
||||
@Suppress("ktlint:standard:annotation")
|
||||
@Query("SELECT ARTIST, COUNT(*) AS COUNT FROM DOWNLOADS LEFT JOIN DOWNLOAD_ARTISTS USING(GID) GROUP BY ARTIST ORDER BY COUNT DESC, ARTIST")
|
||||
fun countByArtist(): Flow<Map<@MapColumn("ARTIST") String?, @MapColumn("COUNT") Int>>
|
||||
|
||||
@Query("SELECT * FROM DOWNLOADS ORDER BY TIME")
|
||||
suspend fun list(): List<DownloadEntity>
|
||||
|
||||
@@ -12,11 +12,11 @@ interface SearchDao {
|
||||
@Query("DELETE FROM suggestions WHERE `query` = :query")
|
||||
suspend fun deleteQuery(query: String)
|
||||
|
||||
@Query("SELECT * FROM suggestions WHERE `query` LIKE :prefix || '%' ORDER BY date DESC LIMIT :limit")
|
||||
suspend fun rawSuggestions(prefix: String, limit: Int): Array<Search>
|
||||
@Query("SELECT DISTINCT `query` FROM suggestions WHERE `query` LIKE :prefix || '%' ORDER BY date DESC LIMIT :limit")
|
||||
suspend fun rawSuggestions(prefix: String, limit: Int): List<String>
|
||||
|
||||
@Query("SELECT * FROM suggestions ORDER BY date DESC LIMIT :limit")
|
||||
suspend fun list(limit: Int): Array<Search>
|
||||
@Query("SELECT DISTINCT `query` FROM suggestions ORDER BY date DESC LIMIT :limit")
|
||||
suspend fun list(limit: Int): List<String>
|
||||
|
||||
@Insert
|
||||
suspend fun insert(search: Search)
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadArtist
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.DownloadLabel
|
||||
import com.hippo.ehviewer.image.Image
|
||||
@@ -174,7 +175,6 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
info = DownloadInfo(galleryInfo, galleryInfo.putToDownloadDir())
|
||||
info.label = label
|
||||
info.state = DownloadInfo.STATE_WAIT
|
||||
|
||||
// Add to all download list and map
|
||||
allInfoList.insertWith(info, comparator())
|
||||
mAllInfoMap[galleryInfo.gid] = info
|
||||
@@ -266,6 +266,7 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
|
||||
// Save to
|
||||
EhDB.putDownloadInfo(info)
|
||||
EhDB.putDownloadArtist(info.gid, info.artistInfoList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,26 +531,63 @@ object DownloadManager : OnSpiderListener, CoroutineScope {
|
||||
}
|
||||
|
||||
suspend fun readMetadataFromLocal() {
|
||||
val list = allInfoList.filter { it.pages == 0 || it.simpleTags == null }.parMapNotNull(concurrency = 5) { info ->
|
||||
val list = allInfoList.mapNotNull {
|
||||
val updateGallery = it.pages == 0 || it.simpleTags == null
|
||||
val updateArtist = it.artistInfoList.isEmpty()
|
||||
if (updateGallery || updateArtist) {
|
||||
Triple(it, updateGallery, updateArtist)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.parMapNotNull(concurrency = 5) { (info, updateGallery, updateArtist) ->
|
||||
info.downloadDir?.run {
|
||||
val comicInfo = findFile(COMIC_INFO_FILE)?.let { readComicInfo(it) }
|
||||
if (comicInfo != null) {
|
||||
info.pages = comicInfo.pageCount
|
||||
info.simpleTags = comicInfo.toSimpleTags()
|
||||
info.galleryInfo
|
||||
val galleryInfo = if (updateGallery) {
|
||||
info.pages = comicInfo.pageCount
|
||||
info.simpleTags = comicInfo.toSimpleTags()
|
||||
info.galleryInfo
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val artistList = if (updateArtist && comicInfo.penciller != null) {
|
||||
info.artistInfoList = DownloadArtist.from(info.gid, comicInfo.penciller)
|
||||
info.gid to info.artistInfoList
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (galleryInfo != null || artistList != null) {
|
||||
galleryInfo to artistList
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else if (info.pages == 0) {
|
||||
findFile(SPIDER_INFO_FILENAME)?.let {
|
||||
val galleryInfo = findFile(SPIDER_INFO_FILENAME)?.let {
|
||||
readCompatFromUniFile(it)?.run {
|
||||
info.pages = pages
|
||||
info.galleryInfo
|
||||
}
|
||||
}
|
||||
|
||||
galleryInfo?.let { it to null }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
EhDB.updateGalleryInfo(list)
|
||||
|
||||
val galleryInfoList = mutableListOf<BaseGalleryInfo>()
|
||||
list.forEach { (info, artists) ->
|
||||
info?.let {
|
||||
galleryInfoList.add(it)
|
||||
}
|
||||
artists?.let { (gid, updateList) ->
|
||||
EhDB.putDownloadArtist(gid, updateList)
|
||||
}
|
||||
}
|
||||
if (galleryInfoList.isNotEmpty()) EhDB.updateGalleryInfo(galleryInfoList)
|
||||
}
|
||||
|
||||
val isIdle: Boolean
|
||||
|
||||
@@ -34,7 +34,6 @@ import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.ui.screen.DownloadsFragment
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.SimpleHandler
|
||||
@@ -187,7 +186,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
clearIntent.action = ACTION_CLEAR
|
||||
val piClear = PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
val bundle = Bundle()
|
||||
bundle.putString(DownloadsFragment.KEY_ACTION, DownloadsFragment.ACTION_CLEAR_DOWNLOAD_SERVICE)
|
||||
bundle.putString(KEY_ACTION, ACTION_CLEAR_DOWNLOAD_SERVICE)
|
||||
val activityIntent = Intent(this, MainActivity::class.java)
|
||||
activityIntent.action = ACTION_START_DOWNLOADSCENE
|
||||
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
|
||||
@@ -243,7 +242,7 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
}
|
||||
ensureDownloadingBuilder()
|
||||
val bundle = Bundle()
|
||||
bundle.putLong(DownloadsFragment.KEY_GID, info.gid)
|
||||
bundle.putLong(KEY_GID, info.gid)
|
||||
val activityIntent = Intent(this, MainActivity::class.java)
|
||||
activityIntent.action = ACTION_START_DOWNLOADSCENE
|
||||
activityIntent.putExtra(ACTION_START_DOWNLOADSCENE_ARGS, bundle)
|
||||
@@ -540,6 +539,8 @@ class DownloadService : Service(), DownloadManager.DownloadListener, CoroutineSc
|
||||
const val KEY_LABEL = "label"
|
||||
const val KEY_GID = "gid"
|
||||
const val KEY_GID_LIST = "gid_list"
|
||||
const val KEY_ACTION = "action"
|
||||
const val ACTION_CLEAR_DOWNLOAD_SERVICE = "clear_download_service"
|
||||
private const val ID_DOWNLOADING = 1
|
||||
private const val ID_DOWNLOADED = 2
|
||||
private const val ID_509 = 3
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.hippo.ehviewer.download
|
||||
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.dao.artists
|
||||
|
||||
enum class DownloadsFilterMode(
|
||||
val flag: Int,
|
||||
val take: (info: DownloadInfo, label: String?) -> Boolean,
|
||||
) {
|
||||
CUSTOM(flag = 0, take = { info, label ->
|
||||
label == "" || info.label == label
|
||||
}),
|
||||
ARTIST(flag = 1, take = { info, label ->
|
||||
if (null == label) {
|
||||
info.artistInfoList.isEmpty()
|
||||
} else {
|
||||
label == "" || info.artists.contains(label)
|
||||
}
|
||||
}),
|
||||
;
|
||||
|
||||
companion object {
|
||||
val Default = ARTIST
|
||||
fun from(flag: Int) = DownloadsFilterMode.entries[flag]
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import com.hippo.ehviewer.spider.SpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.obtainSpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.Companion.releaseSpiderQueen
|
||||
import com.hippo.ehviewer.spider.SpiderQueen.OnSpiderListener
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
|
||||
class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
@@ -43,11 +42,11 @@ class EhPageLoader(private val mGalleryInfo: GalleryInfo, startPage: Int) :
|
||||
}
|
||||
|
||||
override val title by lazy {
|
||||
FileUtils.sanitizeFilename(EhUtils.getSuitableTitle(mGalleryInfo))
|
||||
EhUtils.getSuitableTitle(mGalleryInfo)
|
||||
}
|
||||
|
||||
override fun getImageExtension(index: Int): String {
|
||||
return mSpiderQueen.getExtension(index)!!
|
||||
override fun getImageExtension(index: Int): String? {
|
||||
return mSpiderQueen.getExtension(index)
|
||||
}
|
||||
|
||||
override fun save(index: Int, file: UniFile): Boolean {
|
||||
|
||||
@@ -17,6 +17,7 @@ package com.hippo.ehviewer.gallery
|
||||
|
||||
import androidx.annotation.CallSuper
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.PageLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -59,16 +60,15 @@ abstract class PageLoader2(private val gid: Long, var startPage: Int) : PageLoad
|
||||
|
||||
protected abstract val title: String
|
||||
|
||||
protected abstract fun getImageExtension(index: Int): String
|
||||
protected abstract fun getImageExtension(index: Int): String?
|
||||
|
||||
fun getImageFilename(index: Int): String {
|
||||
return "$title - ${index + 1}.${getImageExtension(index)}"
|
||||
fun getImageFilename(index: Int): String? = getImageExtension(index)?.let {
|
||||
FileUtils.sanitizeFilename("$title - ${index + 1}.${it.lowercase()}")
|
||||
}
|
||||
|
||||
abstract fun save(index: Int, file: UniFile): Boolean
|
||||
|
||||
fun saveToDir(index: Int, dir: UniFile): UniFile? {
|
||||
val filename = getImageFilename(index)
|
||||
return (dir / filename).takeIf { save(index, it) }
|
||||
fun saveToDir(index: Int, dir: UniFile): UniFile? = getImageFilename(index)?.let { filename ->
|
||||
(dir / filename).takeIf { save(index, it) }
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ fun GalleryInfo.getComicInfo(): ComicInfo {
|
||||
}
|
||||
return ComicInfo(
|
||||
series = title,
|
||||
alternateSeries = titleJpn.takeUnless { it.isNullOrBlank() },
|
||||
alternateSeries = titleJpn?.ifBlank { null },
|
||||
writer = groups.ifEmpty { null },
|
||||
penciller = artists.ifEmpty { null },
|
||||
genre = otherTags.ifEmpty { null },
|
||||
|
||||
@@ -32,6 +32,8 @@ import com.hippo.ehviewer.client.executeSafely
|
||||
import com.hippo.ehviewer.client.getImageKey
|
||||
import com.hippo.ehviewer.coil.read
|
||||
import com.hippo.ehviewer.coil.suspendEdit
|
||||
import com.hippo.ehviewer.dao.DownloadArtist
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.download.tempDownloadDir
|
||||
import com.hippo.ehviewer.image.UniFileSource
|
||||
@@ -325,7 +327,13 @@ class SpiderDen(val info: GalleryInfo) {
|
||||
EhEngine.fillGalleryListByApi(listOf(info))
|
||||
}
|
||||
}
|
||||
info.getComicInfo().write(it)
|
||||
info.getComicInfo().apply {
|
||||
write(it)
|
||||
DownloadManager.getDownloadInfo(gid)?.let { downloadInfo ->
|
||||
downloadInfo.artistInfoList = DownloadArtist.from(gid, penciller.orEmpty())
|
||||
EhDB.putDownloadArtist(gid, downloadInfo.artistInfoList)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ suspend fun DialogState.startDownload(
|
||||
add(it.label)
|
||||
}
|
||||
}
|
||||
val (selected, checked) = showSelectItemWithCheckBox(
|
||||
val (selected, checked) = awaitSelectItemWithCheckBox(
|
||||
items,
|
||||
title = R.string.download,
|
||||
checkBoxText = R.string.remember_download_label,
|
||||
@@ -206,7 +206,7 @@ suspend fun DialogState.modifyFavorites(galleryInfo: BaseGalleryInfo): Boolean {
|
||||
add(localFav)
|
||||
addAll(cloudFav)
|
||||
}
|
||||
val (slot, note) = showSelectItemWithIconAndTextField(
|
||||
val (slot, note) = awaitSelectItemWithIconAndTextField(
|
||||
items,
|
||||
title = R.string.add_favorites_dialog_title,
|
||||
hint = R.string.favorite_note,
|
||||
@@ -311,7 +311,7 @@ suspend fun doGalleryInfoAction(info: BaseGalleryInfo) {
|
||||
add(Icons.AutoMirrored.Default.DriveFileMove to R.string.download_move_dialog_title)
|
||||
}
|
||||
}
|
||||
val selected = showSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
|
||||
val selected = awaitSelectItemWithIcon(items, EhUtils.getSuitableTitle(info))
|
||||
with(findActivity<MainActivity>()) {
|
||||
when (selected) {
|
||||
0 -> {
|
||||
@@ -410,7 +410,7 @@ suspend fun DialogState.showMoveDownloadLabel(info: GalleryInfo) {
|
||||
add(it.label)
|
||||
}
|
||||
}
|
||||
val selected = showSelectItem(labels, R.string.download_move_dialog_title)
|
||||
val selected = awaitSelectItem(labels, R.string.download_move_dialog_title)
|
||||
val downloadInfo = DownloadManager.getDownloadInfo(info.gid) ?: return
|
||||
val label = if (selected == 0) null else labels[selected]
|
||||
DownloadManager.changeLabel(listOf(downloadInfo), label)
|
||||
@@ -424,19 +424,19 @@ suspend fun DialogState.showMoveDownloadLabelList(list: Collection<DownloadInfo>
|
||||
add(it.label)
|
||||
}
|
||||
}
|
||||
val selected = showSelectItem(labels, R.string.download_move_dialog_title)
|
||||
val selected = awaitSelectItem(labels, R.string.download_move_dialog_title)
|
||||
val label = if (selected == 0) null else labels[selected]
|
||||
DownloadManager.changeLabel(list, label)
|
||||
return label
|
||||
}
|
||||
|
||||
suspend fun DialogState.showDatePicker(): String? {
|
||||
suspend fun DialogState.awaitSelectDate(): String? {
|
||||
val initial = LocalDate(2007, 3, 21)
|
||||
val yesterday = Clock.System.todayIn(TimeZone.UTC).minus(1, DateTimeUnit.DAY)
|
||||
val initialMillis = initial.toEpochMillis()
|
||||
val yesterdayMillis = yesterday.toEpochMillis()
|
||||
val dateRange = initialMillis..yesterdayMillis
|
||||
val dateMillis = showDatePicker(
|
||||
val dateMillis = awaitSelectDate(
|
||||
title = R.string.go_to,
|
||||
yearRange = initial.year..yesterday.year,
|
||||
selectableDates = object : SelectableDates {
|
||||
|
||||
@@ -16,16 +16,26 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.util.isAtLeastR
|
||||
import eu.kanade.tachiyomi.util.view.setSecureScreen
|
||||
|
||||
abstract class EhActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
// https://issuetracker.google.com/204791558
|
||||
// Fix system bars insets still exist in fullscreen mode on API < 30
|
||||
@Suppress("DEPRECATION")
|
||||
if (!isAtLeastR) {
|
||||
with(window.decorView) {
|
||||
systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_STABLE.inv()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Whatshot
|
||||
import androidx.compose.material3.DrawerDefaults
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.DrawerState2
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -64,9 +65,9 @@ import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.material3.rememberDrawerState2
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -76,6 +77,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.currentCompositeKeyHash
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -92,9 +94,9 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -127,12 +129,10 @@ import com.hippo.ehviewer.ui.destinations.WhatshotScreenDestination
|
||||
import com.hippo.ehviewer.ui.screen.asDst
|
||||
import com.hippo.ehviewer.ui.screen.asDstWith
|
||||
import com.hippo.ehviewer.ui.screen.navWithUrl
|
||||
import com.hippo.ehviewer.ui.screen.navigateTo
|
||||
import com.hippo.ehviewer.ui.settings.showNewVersion
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LabeledCheckbox
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
@@ -147,6 +147,7 @@ import com.hippo.unifile.sha1
|
||||
import com.ramcosta.composedestinations.DestinationsNavHost
|
||||
import com.ramcosta.composedestinations.spec.DirectionDestinationSpec
|
||||
import com.ramcosta.composedestinations.utils.currentDestinationAsState
|
||||
import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -218,6 +219,7 @@ class MainActivity : EhActivity() {
|
||||
val snackbarState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = rememberNavController()
|
||||
val navigator = navController.rememberDestinationsNavigator()
|
||||
fun closeDrawer(callback: () -> Unit = {}) = scope.launch {
|
||||
navDrawerState.close()
|
||||
callback()
|
||||
@@ -236,7 +238,7 @@ class MainActivity : EhActivity() {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
navController.navigateTo(DownloadScreenDestination)
|
||||
navigator.navigate(DownloadScreenDestination)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +262,7 @@ class MainActivity : EhActivity() {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val url = intent.data?.toString()
|
||||
if (url != null && !navController.navWithUrl(url)) {
|
||||
if (url != null && !navigator.navWithUrl(url)) {
|
||||
val new = dialogState.awaitInputText(initial = url, title = cannotParse)
|
||||
addTextToClipboard(new)
|
||||
}
|
||||
@@ -269,14 +271,14 @@ class MainActivity : EhActivity() {
|
||||
val type = intent.type
|
||||
if ("text/plain" == type) {
|
||||
val keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (keyword != null && !navController.navWithUrl(keyword)) {
|
||||
navController.navigateTo(ListUrlBuilder(mKeyword = keyword).asDst())
|
||||
if (keyword != null && !navigator.navWithUrl(keyword)) {
|
||||
navigator.navigate(ListUrlBuilder(mKeyword = keyword).asDst())
|
||||
}
|
||||
} else if (type != null && type.startsWith("image/")) {
|
||||
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
|
||||
if (null != uri) {
|
||||
val hash = withIOContext { uri.asUniFile().sha1() }
|
||||
navController.navigateTo(
|
||||
navigator.navigate(
|
||||
ListUrlBuilder(
|
||||
mode = ListUrlBuilder.MODE_IMAGE_SEARCH,
|
||||
hash = hash,
|
||||
@@ -286,8 +288,11 @@ class MainActivity : EhActivity() {
|
||||
}
|
||||
}
|
||||
DownloadService.ACTION_START_DOWNLOADSCENE -> {
|
||||
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
|
||||
navController.navigateTo(DownloadsScreenDestination)
|
||||
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)!!
|
||||
if (args.getString(DownloadService.KEY_ACTION) == DownloadService.ACTION_CLEAR_DOWNLOAD_SERVICE) {
|
||||
DownloadService.clear()
|
||||
}
|
||||
navigator.navigate(DownloadsScreenDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,11 +332,11 @@ class MainActivity : EhActivity() {
|
||||
val result1 = GalleryDetailUrlParser.parse(text, false)
|
||||
var launch: (() -> Unit)? = null
|
||||
if (result1 != null) {
|
||||
launch = { navController.navigateTo(result1.gid asDstWith result1.token) }
|
||||
launch = { navigator.navigate(result1.gid asDstWith result1.token) }
|
||||
}
|
||||
val result2 = GalleryPageUrlParser.parse(text, false)
|
||||
if (result2 != null) {
|
||||
launch = { navController.navigateTo(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
|
||||
launch = { navigator.navigate(ProgressScreenDestination(result2.gid, result2.pToken, result2.page)) }
|
||||
}
|
||||
launch?.let {
|
||||
val ret = snackbarState.showSnackbar(snackMessage, snackAction, true)
|
||||
@@ -346,14 +351,15 @@ class MainActivity : EhActivity() {
|
||||
val lockDrawerHandle = remember { mutableStateListOf<Int>() }
|
||||
var snackbarFabPadding by remember { mutableStateOf(0.dp) }
|
||||
val drawerLocked = lockDrawerHandle.isNotEmpty()
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
CompositionLocalProvider(
|
||||
LocalNavDrawerState provides navDrawerState,
|
||||
LocalSideSheetState provides sideSheetState,
|
||||
LocalDrawerLockHandle provides lockDrawerHandle,
|
||||
LocalSnackBarHostState provides snackbarState,
|
||||
LocalSnackBarFabPadding provides animateDpAsState(snackbarFabPadding, label = "SnackbarFabPadding"),
|
||||
LocalWindowSizeClass provides adaptiveInfo.windowSizeClass,
|
||||
) {
|
||||
Scaffold(
|
||||
snackbarHost = {
|
||||
@@ -367,92 +373,85 @@ class MainActivity : EhActivity() {
|
||||
)
|
||||
},
|
||||
) {
|
||||
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
|
||||
val minOffset = -with(density) { 360.dp.toPx() }
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
drawerState = navDrawerState,
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
|
||||
var minOffset by remember {
|
||||
mutableFloatStateOf(-with(density) { DrawerDefaults.MaximumDrawerWidth.toPx() })
|
||||
}
|
||||
ModalNavigationDrawer(
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
drawerState = navDrawerState,
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 56).dp)
|
||||
.onSizeChanged { minOffset = -it.width.toFloat() },
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Start),
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState)
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState)
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.sadpanda_low_poly),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.sadpanda_low_poly),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
navItems.forEach { (direction, stringId, icon) ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(text = stringResource(id = stringId))
|
||||
},
|
||||
selected = currentDestination === direction,
|
||||
onClick = {
|
||||
navigator.navigate(direction)
|
||||
closeDrawer()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
icon = {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
},
|
||||
)
|
||||
navItems.forEach { (direction, stringId, icon) ->
|
||||
NavigationDrawerItem(
|
||||
label = {
|
||||
Text(text = stringResource(id = stringId))
|
||||
},
|
||||
selected = currentDestination === direction,
|
||||
onClick = {
|
||||
navController.navigate(direction.route)
|
||||
closeDrawer()
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
icon = {
|
||||
Icon(imageVector = icon, contentDescription = null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
drawerState = navDrawerState,
|
||||
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
|
||||
) {
|
||||
val sheet = sideSheet.firstOrNull()
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
|
||||
with(density) { lerp(0, 10, step).dp.toPx() }
|
||||
}
|
||||
}.collectAsState(0f)
|
||||
ModalSideDrawer(
|
||||
drawerContent = {
|
||||
if (sheet != null) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
|
||||
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
|
||||
) {
|
||||
sheet(sideSheetState)
|
||||
}
|
||||
}
|
||||
},
|
||||
drawerState = navDrawerState,
|
||||
gesturesEnabled = !drawerLocked || navDrawerState.isOpen,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
if (radius != 0f) {
|
||||
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
},
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && !drawerLocked,
|
||||
) {
|
||||
val sheet = sideSheet.firstOrNull()
|
||||
val radius by remember {
|
||||
snapshotFlow {
|
||||
val step = calculateFraction(minOffset, 0f, navDrawerState.currentOffset)
|
||||
with(density) { lerp(0, 10, step).dp.toPx() }
|
||||
}
|
||||
}.collectAsState(0f)
|
||||
ModalSideDrawer(
|
||||
drawerContent = {
|
||||
if (sheet != null) {
|
||||
ModalDrawerSheet(
|
||||
modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
|
||||
drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
|
||||
windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End),
|
||||
) {
|
||||
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
|
||||
sheet(sideSheetState)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.graphicsLayer {
|
||||
if (radius != 0f) {
|
||||
renderEffect = BlurEffect(radius, radius, TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
},
|
||||
drawerState = sideSheetState,
|
||||
gesturesEnabled = sheet != null && !drawerLocked,
|
||||
) {
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
CompositionLocalProvider(
|
||||
LocalViewConfiguration provides viewConfiguration,
|
||||
LocalWindowSizeClass provides windowSizeClass,
|
||||
) {
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
DestinationsNavHost(
|
||||
navGraph = NavGraphs.root,
|
||||
startRoute = if (Settings.needSignIn) SignInScreenDestination else StartDestination,
|
||||
defaultTransitions = rememberEhNavAnim(),
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ suspend fun postLogin() = coroutineScope {
|
||||
launch {
|
||||
runCatching {
|
||||
EhEngine.getProfile().run {
|
||||
Settings.displayName = displayName
|
||||
Settings.avatar = avatar
|
||||
Settings.displayName.value = displayName
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
|
||||
@@ -14,48 +14,178 @@ import androidx.compose.foundation.layout.systemBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.ui.StartDestination
|
||||
import com.hippo.ehviewer.ui.destinations.WebViewSignInScreenDestination
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.screen.popNavigate
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.ui.tools.autofill
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
@Destination<RootGraph>(start = true)
|
||||
@Composable
|
||||
fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
LockDrawer(true)
|
||||
val windowSizeClass = LocalWindowSizeClass.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var isProgressIndicatorVisible by rememberSaveable { mutableStateOf(false) }
|
||||
var showUsernameError by rememberSaveable { mutableStateOf(false) }
|
||||
var showPasswordError by rememberSaveable { mutableStateOf(false) }
|
||||
var username by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var passwordHidden by rememberSaveable { mutableStateOf(true) }
|
||||
val context = LocalContext.current
|
||||
var signInJob by remember { mutableStateOf<Job?>(null) }
|
||||
val dialogState = LocalDialogState.current
|
||||
|
||||
fun signIn() {
|
||||
if (signInJob?.isActive == true) return
|
||||
if (username.isEmpty()) {
|
||||
showUsernameError = true
|
||||
return
|
||||
} else {
|
||||
showUsernameError = false
|
||||
}
|
||||
if (password.isEmpty()) {
|
||||
showPasswordError = true
|
||||
return
|
||||
} else {
|
||||
showPasswordError = false
|
||||
}
|
||||
focusManager.clearFocus()
|
||||
isProgressIndicatorVisible = true
|
||||
|
||||
EhUtils.signOut()
|
||||
signInJob = coroutineScope.launchIO {
|
||||
runCatching {
|
||||
EhEngine.signIn(username, password)
|
||||
}.onFailure {
|
||||
withUIContext {
|
||||
focusManager.clearFocus()
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
confirmText = R.string.get_it,
|
||||
title = R.string.sign_in_failed,
|
||||
showCancelButton = false,
|
||||
text = {
|
||||
Text(
|
||||
"""
|
||||
${it.displayString()}
|
||||
${stringResource(R.string.sign_in_failed_tip, stringResource(R.string.sign_in_via_webview))}
|
||||
""".trimIndent(),
|
||||
)
|
||||
},
|
||||
)
|
||||
isProgressIndicatorVisible = false
|
||||
}
|
||||
}.onSuccess {
|
||||
withNonCancellableContext { postLogin() }
|
||||
withUIContext { navigator.popNavigate(StartDestination) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsernameAndPasswordTextField() {
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
|
||||
autofillTypes = listOf(AutofillType.Username),
|
||||
onFill = { username = it },
|
||||
),
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
supportingText = { if (showUsernameError) Text(stringResource(R.string.error_username_cannot_empty)) },
|
||||
trailingIcon = { if (showUsernameError) Icon(imageVector = Icons.Filled.Info, contentDescription = null) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
singleLine = true,
|
||||
isError = showUsernameError,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
modifier = Modifier.width(dimensionResource(id = R.dimen.single_max_width)).autofill(
|
||||
autofillTypes = listOf(AutofillType.Password),
|
||||
onFill = { password = it },
|
||||
),
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
visualTransformation = if (passwordHidden) PasswordVisualTransformation() else VisualTransformation.None,
|
||||
supportingText = { if (showPasswordError) Text(stringResource(R.string.error_password_cannot_empty)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
keyboardActions = KeyboardActions(onDone = { signIn() }),
|
||||
trailingIcon = {
|
||||
if (showPasswordError) {
|
||||
Icon(imageVector = Icons.Filled.Info, contentDescription = null)
|
||||
} else {
|
||||
IconButton(onClick = { passwordHidden = !passwordHidden }) {
|
||||
val visibilityIcon = if (passwordHidden) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
|
||||
Icon(imageVector = visibilityIcon, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
isError = showPasswordError,
|
||||
)
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
when (windowSizeClass.widthSizeClass) {
|
||||
WindowWidthSizeClass.Compact, WindowWidthSizeClass.Medium -> {
|
||||
when (windowSizeClass.windowWidthSizeClass) {
|
||||
WindowWidthSizeClass.COMPACT, WindowWidthSizeClass.MEDIUM -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).systemBarsPadding().padding(dimensionResource(id = R.dimen.keyline_margin)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -65,6 +195,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(dimensionResource(id = R.dimen.keyline_margin)),
|
||||
)
|
||||
UsernameAndPasswordTextField()
|
||||
Text(
|
||||
text = stringResource(id = R.string.app_waring),
|
||||
modifier = Modifier.widthIn(max = dimensionResource(id = R.dimen.single_max_width)).padding(top = 24.dp),
|
||||
@@ -73,7 +204,7 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.app_waring_2),
|
||||
modifier = Modifier.widthIn(max = dimensionResource(id = R.dimen.single_max_width)).padding(top = 12.dp),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
Row(modifier = Modifier.padding(top = dimensionResource(R.dimen.keyline_margin))) {
|
||||
@@ -84,32 +215,49 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
Text(text = stringResource(id = R.string.register))
|
||||
}
|
||||
Button(
|
||||
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
|
||||
onClick = ::signIn,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.sign_in))
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
Settings.needSignIn = false
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
navigator.popNavigate(StartDestination)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
) {
|
||||
append(stringResource(id = R.string.guest_mode))
|
||||
}
|
||||
Row(modifier = Modifier.padding(horizontal = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
) {
|
||||
append(stringResource(id = R.string.sign_in_via_webview))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
Settings.needSignIn = false
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
navigator.popNavigate(StartDestination)
|
||||
},
|
||||
)
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
) {
|
||||
append(stringResource(id = R.string.guest_mode))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowWidthSizeClass.Expanded -> {
|
||||
WindowWidthSizeClass.EXPANDED -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).systemBarsPadding().padding(dimensionResource(id = R.dimen.keyline_margin)),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -132,17 +280,18 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.app_waring_2),
|
||||
modifier = Modifier.widthIn(max = 360.dp),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
UsernameAndPasswordTextField()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
Button(
|
||||
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
|
||||
onClick = ::signIn,
|
||||
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.sign_in))
|
||||
@@ -155,27 +304,46 @@ fun SignInScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = {
|
||||
Settings.needSignIn = false
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
navigator.popNavigate(StartDestination)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
) {
|
||||
append(stringResource(id = R.string.guest_mode))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
TextButton(
|
||||
onClick = { navigator.navigate(WebViewSignInScreenDestination) },
|
||||
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
|
||||
) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
) {
|
||||
append(stringResource(id = R.string.sign_in_via_webview))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
Settings.needSignIn = false
|
||||
Settings.gallerySite = EhUrl.SITE_E
|
||||
navigator.popNavigate(StartDestination)
|
||||
},
|
||||
)
|
||||
modifier = Modifier.padding(horizontal = 4.dp).width(128.dp),
|
||||
) {
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
style = SpanStyle(textDecoration = TextDecoration.Underline),
|
||||
) {
|
||||
append(stringResource(id = R.string.guest_mode))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isProgressIndicatorVisible) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package com.hippo.ehviewer.ui.main
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
object AdvanceTable {
|
||||
const val SH = 0x1
|
||||
@@ -12,7 +14,9 @@ object AdvanceTable {
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
data class AdvancedSearchOption(
|
||||
@Transient
|
||||
val advanceSearch: Int = 0,
|
||||
val minRating: Int = 0,
|
||||
val fromPage: Int = 0,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.parser.Archive
|
||||
import com.hippo.ehviewer.client.parser.Funds
|
||||
|
||||
@Composable
|
||||
fun ArchiveList(
|
||||
funds: Funds,
|
||||
items: List<Archive>,
|
||||
onItemClick: (Archive) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val currentFunds = stringResource(R.string.current_funds)
|
||||
val archiveFree = stringResource(R.string.archive_free)
|
||||
val archiveOriginal = stringResource(R.string.archive_original)
|
||||
val archiveResample = stringResource(R.string.archive_resample)
|
||||
val fundsStyle = MaterialTheme.typography.labelLarge
|
||||
val (hAtH, nonHAtH) = remember(items) { items.partition { it.isHAtH } }
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Text(text = currentFunds, textAlign = TextAlign.Center)
|
||||
}
|
||||
item {
|
||||
FundsItem(
|
||||
type = "GP",
|
||||
amount = funds.gp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = fundsStyle,
|
||||
)
|
||||
}
|
||||
item {
|
||||
FundsItem(
|
||||
type = "C",
|
||||
amount = funds.credit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = fundsStyle,
|
||||
)
|
||||
}
|
||||
items(nonHAtH) {
|
||||
ArchiveItem(
|
||||
name = if (it.res == "org") archiveOriginal else archiveResample,
|
||||
cost = if (it.cost == "Free!") archiveFree else it.cost,
|
||||
size = it.size,
|
||||
onClick = { onItemClick(it) },
|
||||
)
|
||||
}
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
Text(text = "H@H")
|
||||
}
|
||||
}
|
||||
items(hAtH) {
|
||||
ArchiveItem(
|
||||
name = if (it.res == "org") archiveOriginal else it.name,
|
||||
cost = if (it.cost == "Free") archiveFree else it.cost,
|
||||
size = it.size,
|
||||
onClick = { onItemClick(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArchiveItem(
|
||||
name: String,
|
||||
cost: String,
|
||||
size: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val labelStyle = MaterialTheme.typography.labelMedium
|
||||
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = cost,
|
||||
style = labelStyle,
|
||||
)
|
||||
Button(onClick = onClick) {
|
||||
Text(text = name)
|
||||
}
|
||||
Text(
|
||||
text = size,
|
||||
style = labelStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@ package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
@@ -18,19 +18,24 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.ConstraintSet
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import arrow.core.Tuple9
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
@@ -40,6 +45,51 @@ import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
|
||||
private val ids = Tuple9(1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
|
||||
private val constraintSet = ConstraintSet {
|
||||
val (
|
||||
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
|
||||
progressBarRef, progressTextRef, speedRef,
|
||||
) = createRefsFor(1, 2, 3, 4, 5, 6, 7, 8, 9)
|
||||
constrain(titleRef) {
|
||||
top.linkTo(parent.top)
|
||||
width = Dimension.matchParent
|
||||
}
|
||||
constrain(uploaderRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(actionsRef.top)
|
||||
}
|
||||
constrain(ratingRef) {
|
||||
start.linkTo(parent.start)
|
||||
top.linkTo(actionsRef.top, 1.dp)
|
||||
}
|
||||
constrain(categoryRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(parent.bottom)
|
||||
}
|
||||
constrain(progressBarRef) {
|
||||
bottom.linkTo(actionsRef.top)
|
||||
width = Dimension.matchParent
|
||||
}
|
||||
constrain(progressTextRef) {
|
||||
bottom.linkTo(progressBarRef.top)
|
||||
start.linkTo(progressBarRef.start)
|
||||
}
|
||||
constrain(speedRef) {
|
||||
bottom.linkTo(progressBarRef.top)
|
||||
end.linkTo(progressBarRef.end)
|
||||
}
|
||||
constrain(actionsRef) {
|
||||
end.linkTo(parent.end, (-4).dp)
|
||||
bottom.linkTo(parent.bottom, (-4).dp)
|
||||
}
|
||||
constrain(stateTextRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(actionsRef.top)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DownloadCard(
|
||||
onClick: () -> Unit,
|
||||
@@ -48,143 +98,122 @@ fun DownloadCard(
|
||||
onStart: () -> Unit,
|
||||
onStop: () -> Unit,
|
||||
info: DownloadInfo,
|
||||
selectMode: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
CrystalCard(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Row {
|
||||
Card(onClick = onThumbClick) {
|
||||
) = CrystalCard(modifier = modifier, onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource) {
|
||||
Row {
|
||||
val thumb = remember {
|
||||
movableContentOf<DownloadInfo> {
|
||||
EhAsyncCropThumb(
|
||||
key = info,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
|
||||
key = it,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
val stateFailed = stringResource(R.string.download_state_failed)
|
||||
val stateFailed2 = stringResource(R.string.download_state_failed_2, info.legacy)
|
||||
val downloadState by DownloadManager.collectDownloadState(info.gid)
|
||||
val stateText = when (downloadState) {
|
||||
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
|
||||
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
|
||||
DownloadInfo.STATE_DOWNLOAD -> null
|
||||
DownloadInfo.STATE_FAILED -> if (info.legacy <= 0) stateFailed else stateFailed2
|
||||
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_finish)
|
||||
else -> null // Chill, will be removed soon
|
||||
}
|
||||
if (selectMode) {
|
||||
Card {
|
||||
thumb(info)
|
||||
}
|
||||
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize()) {
|
||||
val (
|
||||
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
|
||||
progressBarRef, progressTextRef, speedRef,
|
||||
) = createRefs()
|
||||
Text(
|
||||
text = EhUtils.getSuitableTitle(info),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.constrainAs(titleRef) {
|
||||
top.linkTo(parent.top)
|
||||
start.linkTo(parent.start)
|
||||
}.fillMaxWidth(),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (downloadState != DownloadInfo.STATE_DOWNLOAD) {
|
||||
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
|
||||
info.uploader?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.constrainAs(uploaderRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(ratingRef.top)
|
||||
}.alpha(if (info.disowned) 0.5f else 1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
GalleryListCardRating(
|
||||
rating = info.rating,
|
||||
modifier = Modifier.constrainAs(ratingRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(categoryRef.top)
|
||||
},
|
||||
)
|
||||
val categoryColor = EhUtils.getCategoryColor(info.category)
|
||||
val categoryText = EhUtils.getCategory(info.category).uppercase()
|
||||
} else {
|
||||
Card(onClick = onThumbClick) {
|
||||
thumb(info)
|
||||
}
|
||||
}
|
||||
val stateFailed = stringResource(R.string.download_state_failed)
|
||||
val stateFailed2 = stringResource(R.string.download_state_failed_2, info.legacy)
|
||||
val downloadState by DownloadManager.collectDownloadState(info.gid)
|
||||
val stateText = when (downloadState) {
|
||||
DownloadInfo.STATE_NONE -> stringResource(R.string.download_state_none)
|
||||
DownloadInfo.STATE_WAIT -> stringResource(R.string.download_state_wait)
|
||||
DownloadInfo.STATE_DOWNLOAD -> null
|
||||
DownloadInfo.STATE_FAILED -> if (info.legacy <= 0) stateFailed else stateFailed2
|
||||
DownloadInfo.STATE_FINISH -> stringResource(R.string.download_state_finish)
|
||||
else -> null // Chill, will be removed soon
|
||||
}
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
|
||||
constraintSet = constraintSet,
|
||||
) {
|
||||
val (
|
||||
titleRef, uploaderRef, ratingRef, categoryRef, actionsRef, stateTextRef,
|
||||
progressBarRef, progressTextRef, speedRef,
|
||||
) = ids
|
||||
Text(
|
||||
text = EhUtils.getSuitableTitle(info),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.layoutId(titleRef),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
if (downloadState != DownloadInfo.STATE_DOWNLOAD) {
|
||||
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
|
||||
info.uploader?.let {
|
||||
Text(
|
||||
text = categoryText,
|
||||
modifier = Modifier.constrainAs(categoryRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(parent.bottom)
|
||||
}.clip(ShapeDefaults.Small).background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
|
||||
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
|
||||
textAlign = TextAlign.Center,
|
||||
text = it,
|
||||
modifier = Modifier.layoutId(uploaderRef).alpha(if (info.disowned) 0.5f else 1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val (total, finished, speed) = DownloadManager.updatedDownloadInfo(info) {
|
||||
Triple(total, finished, speed)
|
||||
}
|
||||
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
|
||||
if (total <= 0 || finished < 0) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.constrainAs(progressBarRef) {
|
||||
bottom.linkTo(actionsRef.top)
|
||||
}.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
progress = { finished.toFloat() / total.toFloat() },
|
||||
modifier = Modifier.constrainAs(progressBarRef) {
|
||||
bottom.linkTo(actionsRef.top)
|
||||
}.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = "$finished/$total",
|
||||
modifier = Modifier.constrainAs(progressTextRef) {
|
||||
bottom.linkTo(progressBarRef.top)
|
||||
start.linkTo(progressBarRef.start)
|
||||
},
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = FileUtils.humanReadableByteCount(speed.coerceAtLeast(0), false) + "/S",
|
||||
modifier = Modifier.constrainAs(speedRef) {
|
||||
bottom.linkTo(progressBarRef.top)
|
||||
end.linkTo(progressBarRef.end)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.constrainAs(actionsRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(parent.bottom)
|
||||
},
|
||||
) {
|
||||
if (downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD) {
|
||||
IconButton(onClick = onStop) {
|
||||
Icon(imageVector = Icons.Default.Pause, contentDescription = null)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onStart) {
|
||||
Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stateText != null) {
|
||||
GalleryListCardRating(
|
||||
rating = info.rating,
|
||||
modifier = Modifier.layoutId(ratingRef),
|
||||
)
|
||||
val categoryColor = EhUtils.getCategoryColor(info.category)
|
||||
val categoryText = EhUtils.getCategory(info.category).uppercase()
|
||||
Text(
|
||||
text = stateText,
|
||||
modifier = Modifier.constrainAs(stateTextRef) {
|
||||
end.linkTo(parent.end, 16.dp)
|
||||
bottom.linkTo(actionsRef.top)
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
text = categoryText,
|
||||
modifier = Modifier.layoutId(categoryRef).clip(ShapeDefaults.Small)
|
||||
.background(categoryColor).padding(vertical = 2.dp, horizontal = 8.dp),
|
||||
color = if (Settings.harmonizeCategoryColor) Color.Unspecified else EhUtils.categoryTextColor,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val (total, finished, speed) = DownloadManager.updatedDownloadInfo(info) {
|
||||
Triple(total, finished, speed)
|
||||
}
|
||||
ProvideTextStyle(MaterialTheme.typography.labelMedium) {
|
||||
if (total <= 0 || finished < 0) {
|
||||
LinearProgressIndicator(modifier = Modifier.layoutId(progressBarRef))
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
progress = { finished.toFloat() / total.toFloat() },
|
||||
modifier = Modifier.layoutId(progressBarRef),
|
||||
)
|
||||
Text(
|
||||
text = "$finished/$total",
|
||||
modifier = Modifier.layoutId(progressTextRef),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = FileUtils.humanReadableByteCount(speed.coerceAtLeast(0), false) + "/S",
|
||||
modifier = Modifier.layoutId(speedRef),
|
||||
)
|
||||
}
|
||||
}
|
||||
val running = downloadState == DownloadInfo.STATE_WAIT || downloadState == DownloadInfo.STATE_DOWNLOAD
|
||||
val icon = remember {
|
||||
movableContentOf<Boolean> {
|
||||
Icon(imageVector = if (it) Icons.Default.Pause else Icons.Default.PlayArrow, contentDescription = null)
|
||||
}
|
||||
}
|
||||
if (selectMode) {
|
||||
Box(modifier = Modifier.layoutId(actionsRef).minimumInteractiveComponentSize()) {
|
||||
icon(running)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = if (running) onStop else onStart, modifier = Modifier.layoutId(actionsRef)) {
|
||||
icon(running)
|
||||
}
|
||||
}
|
||||
if (stateText != null) {
|
||||
Text(
|
||||
text = stateText,
|
||||
modifier = Modifier.layoutId(stateTextRef),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.Image
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.request.ImageRequest
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
@@ -29,13 +28,15 @@ fun requestOf(model: GalleryInfo): ImageRequest {
|
||||
fun EhAsyncThumb(
|
||||
model: GalleryInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: Painter? = null,
|
||||
onSuccess: ((Image) -> Unit)? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
) = AsyncImage(
|
||||
model = requestOf(model),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
placeholder = placeholder,
|
||||
onSuccess = onSuccess?.let { callback ->
|
||||
{ callback(it.result.image) }
|
||||
},
|
||||
contentScale = contentScale,
|
||||
)
|
||||
|
||||
@@ -49,11 +50,9 @@ fun EhAsyncCropThumb(
|
||||
model = requestOf(key),
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
onState = {
|
||||
if (it is AsyncImagePainter.State.Success) {
|
||||
if (it.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
onSuccess = {
|
||||
if (it.result.image.shouldCrop) {
|
||||
contentScale = ContentScale.Crop
|
||||
}
|
||||
},
|
||||
contentScale = contentScale,
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.hippo.ehviewer.ui.main
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
@@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
@@ -23,7 +23,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -31,27 +30,26 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.hippo.ehviewer.ui.tools.PredictiveBackEasing
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
import com.hippo.ehviewer.ui.tools.snackBarPadding
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.onEachLatest
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
enum class FabLayoutValue {
|
||||
Hidden,
|
||||
Primary,
|
||||
Expand,
|
||||
}
|
||||
@Stable
|
||||
enum class FabLayoutValue { Hidden, Primary, Expand }
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun rememberFabLayoutState(initialValue: FabLayoutValue = FabLayoutValue.Primary): FabLayoutState {
|
||||
return remember { FabLayoutState(initialValue) }
|
||||
fun rememberFabLayoutState(initialValue: FabLayoutValue = FabLayoutValue.Primary) = remember {
|
||||
FabLayoutState(initialValue)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -97,9 +95,11 @@ class FabLayoutState(
|
||||
}
|
||||
|
||||
fun interface FabBuilder {
|
||||
fun onClick(icon: ImageVector, that: suspend () -> Unit)
|
||||
fun onClick(icon: ImageVector, autoClose: Boolean, that: suspend () -> Unit)
|
||||
fun onClick(icon: ImageVector, that: suspend () -> Unit) = onClick(icon, true, that)
|
||||
}
|
||||
|
||||
context(CoroutineScope)
|
||||
@Composable
|
||||
fun FabLayout(
|
||||
hidden: Boolean,
|
||||
@@ -132,7 +132,7 @@ fun FabLayout(
|
||||
record { buildFab(builder) }
|
||||
transform { onEachLatest { state.waitCollapse() } }
|
||||
}
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (updatedExpanded && autoCancel) {
|
||||
Spacer(
|
||||
modifier = Modifier.fillMaxSize().pointerInput(Unit) {
|
||||
@@ -140,76 +140,73 @@ fun FabLayout(
|
||||
},
|
||||
)
|
||||
}
|
||||
val appearState by state.appearProgress.asState()
|
||||
|
||||
val animatedProgress by state.expandProgress.asState()
|
||||
PredictiveBackHandler(updatedExpanded) { flow ->
|
||||
try {
|
||||
state.mutatorMutex.mutate(MutatePriority.UserInput) {
|
||||
flow.collect {
|
||||
val eased = PredictiveBackEasing.transform(it.progress)
|
||||
state.expandProgress.snapTo(1 - eased)
|
||||
}
|
||||
}
|
||||
onExpandChanged(false)
|
||||
} catch (e: CancellationException) {
|
||||
launch { state.expand() }
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(16.dp).snackBarPadding(),
|
||||
modifier = Modifier.fillMaxSize().navigationBarsPadding().snackBarPadding(),
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
if (!state.appearProgress.isRunning && !updatedHidden) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = animatedProgress },
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
) {
|
||||
with(secondaryFab) {
|
||||
forEachIndexed { index, (imageVector, autoClose, onClick) ->
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
launch(Dispatchers.Default) {
|
||||
onClick()
|
||||
if (autoClose) onExpandChanged(false)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(20.dp).offset {
|
||||
val distance = lerp(0, 150 * (size - index) + 50, animatedProgress)
|
||||
IntOffset(0, -distance)
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = imageVector, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val appearState by state.appearProgress.asState()
|
||||
FloatingActionButton(
|
||||
onClick = { onExpandChanged(!updatedExpanded) },
|
||||
modifier = Modifier.padding(16.dp).graphicsLayer {
|
||||
rotationZ = lerp(-90f, 0f, appearState)
|
||||
scaleX = appearState
|
||||
scaleY = appearState
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val animatedProgress by state.expandProgress.asState()
|
||||
PredictiveBackHandler(updatedExpanded) { flow ->
|
||||
try {
|
||||
state.mutatorMutex.mutate(MutatePriority.UserInput) {
|
||||
flow.collect {
|
||||
val eased = EaseOut.transform(it.progress)
|
||||
state.expandProgress.snapTo(1 - eased)
|
||||
}
|
||||
}
|
||||
onExpandChanged(false)
|
||||
} catch (e: CancellationException) {
|
||||
coroutineScope.launch {
|
||||
state.expand()
|
||||
}
|
||||
}
|
||||
}
|
||||
FloatingActionButton(onClick = { onExpandChanged(!updatedExpanded) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = lerp(-135f, 0f, animatedProgress)
|
||||
},
|
||||
)
|
||||
}
|
||||
secondaryFab.run {
|
||||
forEachIndexed { index, (imageVector, onClick) ->
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
coroutineScope.launchIO {
|
||||
runSuspendCatching {
|
||||
onClick()
|
||||
}
|
||||
onExpandChanged(false)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
layout(placeable.width, placeable.height) {
|
||||
val distance = lerp(0, 150 * (size - index) + 50, animatedProgress)
|
||||
placeable.placeRelative(0, -distance, -(size - index).toFloat())
|
||||
}
|
||||
}.graphicsLayer {
|
||||
alpha = animatedProgress
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = imageVector, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
rotationZ = lerp(-135f, 0f, animatedProgress)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFab(builder: FabBuilder.() -> Unit) = buildList {
|
||||
builder { icon, action ->
|
||||
add(icon to action)
|
||||
builder { icon, autoClose, action ->
|
||||
add(Triple(icon, autoClose, action))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun FundsItem(
|
||||
type: String,
|
||||
amount: String,
|
||||
modifier: Modifier = Modifier,
|
||||
textStyle: TextStyle = LocalTextStyle.current,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Text(text = type)
|
||||
}
|
||||
Text(text = amount, style = textStyle)
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,13 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
@@ -25,16 +26,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BrushPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -42,6 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.ConstraintSet
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import arrow.core.Tuple7
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
@@ -60,15 +60,15 @@ private val constraintSet = ConstraintSet {
|
||||
createRefsFor(1, 2, 3, 4, 5, 6, 7)
|
||||
constrain(titleRef) {
|
||||
top.linkTo(parent.top)
|
||||
start.linkTo(parent.start)
|
||||
width = Dimension.matchParent
|
||||
}
|
||||
constrain(uploaderRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(ratingRef.top)
|
||||
bottom.linkTo(iconsRef.top)
|
||||
}
|
||||
constrain(ratingRef) {
|
||||
start.linkTo(parent.start)
|
||||
bottom.linkTo(categoryRef.top)
|
||||
top.linkTo(iconsRef.top, 1.dp)
|
||||
}
|
||||
constrain(categoryRef) {
|
||||
start.linkTo(parent.start)
|
||||
@@ -76,7 +76,7 @@ private val constraintSet = ConstraintSet {
|
||||
}
|
||||
constrain(iconsRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(postedRef.top)
|
||||
bottom.linkTo(categoryRef.top)
|
||||
}
|
||||
constrain(favRef) {
|
||||
end.linkTo(parent.end)
|
||||
@@ -84,7 +84,7 @@ private val constraintSet = ConstraintSet {
|
||||
}
|
||||
constrain(postedRef) {
|
||||
end.linkTo(parent.end)
|
||||
bottom.linkTo(parent.bottom)
|
||||
linkTo(categoryRef.top, parent.bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,15 +107,18 @@ fun GalleryInfoListItem(
|
||||
Card {
|
||||
EhAsyncCropThumb(
|
||||
key = info,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_ASPECT).fillMaxSize(),
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
ConstraintLayout(modifier = Modifier.padding(8.dp, 4.dp).fillMaxSize(), constraintSet = constraintSet) {
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.padding(start = 8.dp, top = 2.dp, end = 4.dp, bottom = 4.dp).fillMaxSize(),
|
||||
constraintSet = constraintSet,
|
||||
) {
|
||||
val (titleRef, uploaderRef, ratingRef, categoryRef, postedRef, favRef, iconsRef) = ids
|
||||
Text(
|
||||
text = EhUtils.getSuitableTitle(info),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.layoutId(titleRef).fillMaxWidth(),
|
||||
modifier = Modifier.layoutId(titleRef),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
@@ -145,6 +148,8 @@ fun GalleryInfoListItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.layoutId(iconsRef),
|
||||
) {
|
||||
// Placeholder to reserve minimum height
|
||||
Text(text = "")
|
||||
val download by DownloadManager.collectContainDownloadInfo(info.gid)
|
||||
if (download) {
|
||||
Icon(
|
||||
@@ -153,7 +158,9 @@ fun GalleryInfoListItem(
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
Text(text = info.simpleLanguage.orEmpty())
|
||||
info.simpleLanguage?.let {
|
||||
Text(text = it)
|
||||
}
|
||||
if (info.pages != 0 && showPages) {
|
||||
Text(text = "${info.pages}P")
|
||||
}
|
||||
@@ -163,11 +170,12 @@ fun GalleryInfoListItem(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.layoutId(favRef),
|
||||
) {
|
||||
// Placeholder to reserve minimum height
|
||||
Text(text = "")
|
||||
if (isInFavScene) {
|
||||
Text(
|
||||
text = info.favoriteNote.orEmpty(),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
info.favoriteNote?.let {
|
||||
Text(text = it, fontStyle = FontStyle.Italic)
|
||||
}
|
||||
} else {
|
||||
val showFav by FavouriteStatusRouter.collectAsState(info) { it != NOT_FAVORITED }
|
||||
if (showFav) {
|
||||
@@ -176,7 +184,9 @@ fun GalleryInfoListItem(
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(text = info.favoriteName.orEmpty())
|
||||
info.favoriteName?.let {
|
||||
Text(text = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,7 +205,8 @@ fun GalleryInfoGridItem(
|
||||
onLongClick: () -> Unit,
|
||||
info: GalleryInfo,
|
||||
modifier: Modifier = Modifier,
|
||||
badgeText: String? = info.simpleLanguage,
|
||||
showLanguage: Boolean = true,
|
||||
showPages: Boolean = true,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) = ElevatedCard(
|
||||
modifier = modifier,
|
||||
@@ -204,19 +215,20 @@ fun GalleryInfoGridItem(
|
||||
interactionSource = interactionSource,
|
||||
) {
|
||||
Box {
|
||||
val placeholder = remember {
|
||||
val aspect = if (info.thumbHeight != 0) {
|
||||
(info.thumbWidth.toFloat() / info.thumbHeight).coerceIn(MIN_ASPECT, MAX_ASPECT)
|
||||
var ratio by remember(info) {
|
||||
val ratio = if (info.thumbHeight != 0) {
|
||||
(info.thumbWidth.toFloat() / info.thumbHeight).coerceIn(MIN_RATIO, MAX_RATIO)
|
||||
} else {
|
||||
DEFAULT_ASPECT
|
||||
DEFAULT_RATIO
|
||||
}
|
||||
BrushPainter(Brush.linearGradient(PlaceholderColors, end = Offset(aspect, 1f)))
|
||||
mutableFloatStateOf(ratio)
|
||||
}
|
||||
EhAsyncThumb(
|
||||
model = info,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = placeholder,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.aspectRatio(ratio),
|
||||
onSuccess = {
|
||||
ratio = (it.width.toFloat() / it.height).coerceIn(MIN_RATIO, MAX_RATIO)
|
||||
},
|
||||
)
|
||||
val categoryColor = EhUtils.getCategoryColor(info.category)
|
||||
Badge(
|
||||
@@ -224,15 +236,20 @@ fun GalleryInfoGridItem(
|
||||
containerColor = categoryColor,
|
||||
contentColor = if (Settings.harmonizeCategoryColor) contentColorFor(categoryColor) else EhUtils.categoryTextColor,
|
||||
) {
|
||||
if (badgeText != null) {
|
||||
Text(text = badgeText)
|
||||
val shouldShowLanguage = showLanguage && info.simpleLanguage != null
|
||||
if (showPages && info.pages > 0) {
|
||||
Text(text = "${info.pages}")
|
||||
if (shouldShowLanguage) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
if (shouldShowLanguage) {
|
||||
Text(text = "${info.simpleLanguage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val PlaceholderColors = listOf(Color.Transparent, Color.Transparent)
|
||||
|
||||
private const val MIN_ASPECT = 0.33F
|
||||
private const val MAX_ASPECT = 1.5F
|
||||
const val DEFAULT_ASPECT = 0.67F
|
||||
private const val MIN_RATIO = 0.5F
|
||||
private const val MAX_RATIO = 1.5F
|
||||
const val DEFAULT_RATIO = 0.67F
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -25,19 +26,24 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.pullToRefresh
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
@@ -54,7 +60,10 @@ import com.hippo.ehviewer.ui.screen.collectDetailSizeAsState
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
operator fun PaddingValues.plus(r: PaddingValues) = object : PaddingValues {
|
||||
@@ -65,6 +74,7 @@ operator fun PaddingValues.plus(r: PaddingValues) = object : PaddingValues {
|
||||
override fun calculateTopPadding() = l.calculateTopPadding() + r.calculateTopPadding()
|
||||
}
|
||||
|
||||
context(CoroutineScope)
|
||||
@Composable
|
||||
fun GalleryList(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -76,20 +86,41 @@ fun GalleryList(
|
||||
detailItemContent: @Composable (LazyGridItemScope.(BaseGalleryInfo) -> Unit),
|
||||
thumbListState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
|
||||
thumbItemContent: @Composable (LazyStaggeredGridItemScope.(BaseGalleryInfo) -> Unit),
|
||||
refreshState: PullToRefreshState,
|
||||
searchBarOffsetY: () -> Int,
|
||||
scrollToTopOnRefresh: Boolean = true,
|
||||
onRefresh: () -> Unit,
|
||||
onLoading: () -> Unit,
|
||||
) {
|
||||
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
|
||||
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
|
||||
val combinedModifier = contentModifier.nestedScroll(refreshState.nestedScrollConnection)
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val refreshState = rememberPullToRefreshState()
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().pullToRefresh(
|
||||
isRefreshing = isRefreshing,
|
||||
state = refreshState,
|
||||
enabled = data.loadState.refresh is LoadState.NotLoading,
|
||||
) {
|
||||
isRefreshing = true
|
||||
launch {
|
||||
if (data.loadState.prepend.endOfPaginationReached) {
|
||||
onRefresh()
|
||||
} else {
|
||||
data.retry()
|
||||
}
|
||||
snapshotFlow { data.loadState }.drop(1).first { state ->
|
||||
state.prepend !is LoadState.Loading && state.refresh !is LoadState.Loading
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (listMode == 0) {
|
||||
val columnWidth by collectDetailSizeAsState()
|
||||
FastScrollLazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(columnWidth),
|
||||
modifier = combinedModifier,
|
||||
modifier = contentModifier,
|
||||
state = detailListState,
|
||||
contentPadding = contentPadding + PaddingValues(marginH, marginV),
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)),
|
||||
@@ -118,7 +149,7 @@ fun GalleryList(
|
||||
val thumbColumns by Settings.thumbColumns.collectAsState()
|
||||
FastScrollLazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(thumbColumns),
|
||||
modifier = combinedModifier,
|
||||
modifier = contentModifier,
|
||||
state = thumbListState,
|
||||
verticalItemSpacing = gridInterval,
|
||||
horizontalArrangement = Arrangement.spacedBy(gridInterval),
|
||||
@@ -144,23 +175,8 @@ fun GalleryList(
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshState.isRefreshing) {
|
||||
LaunchedEffect(Unit) {
|
||||
if (data.loadState.prepend.endOfPaginationReached) {
|
||||
onRefresh()
|
||||
} else {
|
||||
data.retry()
|
||||
}
|
||||
snapshotFlow { data.loadState }.drop(1).collect { state ->
|
||||
if (state.prepend !is LoadState.Loading && state.refresh !is LoadState.Loading) {
|
||||
refreshState.endRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val state = data.loadState.refresh) {
|
||||
is LoadState.Loading -> if (!refreshState.isRefreshing && scrollToTopOnRefresh) {
|
||||
is LoadState.Loading -> if (!isRefreshing && scrollToTopOnRefresh) {
|
||||
LaunchedEffect(Unit) {
|
||||
onLoading()
|
||||
}
|
||||
@@ -195,6 +211,13 @@ fun GalleryList(
|
||||
ErrorTip(modifier = Modifier.widthIn(max = 228.dp), text = stringResource(id = R.string.gallery_list_empty_hit))
|
||||
}
|
||||
}
|
||||
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = refreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = contentPadding.calculateTopPadding())
|
||||
.offset { IntOffset(0, searchBarOffsetY()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ fun EhPreviewItem(
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CrystalCard(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.aspectRatio(DEFAULT_ASPECT),
|
||||
modifier = Modifier.aspectRatio(DEFAULT_RATIO),
|
||||
) {
|
||||
if (galleryPreview != null) {
|
||||
EhAsyncPreview(
|
||||
|
||||
@@ -12,9 +12,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.FilterAlt
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.outlined.FilterAlt
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -39,8 +37,11 @@ import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.S_LANG_TAGS
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.ui.tools.DropdownFilterChip
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.toIntOrDefault
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -68,10 +69,11 @@ fun SearchFilter(
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val dialogState = LocalDialogState.current
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
fun isCategoryChecked(bit: Int) = category and bit != 0
|
||||
val categories = remember(category) { categoryTable.sortedBy { !isCategoryChecked(it.first) } }
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
contentPadding = PaddingValues(start = 8.dp, top = 8.dp, end = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Workaround for the first item's animation
|
||||
@@ -82,44 +84,48 @@ fun SearchFilter(
|
||||
selected = isCategoryChecked(it.first),
|
||||
onClick = { onCategoryChanged(category xor it.first) },
|
||||
label = { Text(text = stringResource(id = it.second)) },
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
)
|
||||
}
|
||||
}
|
||||
var languageFilter by Settings.languageFilter.asMutableState()
|
||||
val languages = remember {
|
||||
GalleryInfo.S_LANG_TAGS.map { tag ->
|
||||
tag.substringAfter(':').let {
|
||||
if (EhTagDatabase.initialized && EhTagDatabase.isTranslatable(context)) {
|
||||
EhTagDatabase.getTranslation("l", it) ?: it
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val any = stringResource(id = R.string.any)
|
||||
val languageStr = stringResource(id = R.string.key_language)
|
||||
FilterChip(
|
||||
selected = languageFilter != -1,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val items = listOf(any, *languages.toTypedArray())
|
||||
languageFilter = dialogState.showSingleChoice(items, languageFilter + 1) - 1
|
||||
val languages = remember {
|
||||
val translatable = EhTagDatabase.initialized && EhTagDatabase.isTranslatable(context)
|
||||
List(S_LANG_TAGS.size + 1) { i ->
|
||||
if (i == 0) {
|
||||
any
|
||||
} else {
|
||||
val tag = S_LANG_TAGS[i - 1].substringAfter(':')
|
||||
if (translatable) {
|
||||
EhTagDatabase.getTranslation("l", tag) ?: tag
|
||||
} else {
|
||||
tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var languageFilter by Settings.languageFilter.asMutableState()
|
||||
DropdownFilterChip(
|
||||
label = languageStr,
|
||||
menuItems = languages,
|
||||
selectedItemIndex = languageFilter + 1,
|
||||
onSelectedItemIndexChange = {
|
||||
languageFilter = it - 1
|
||||
},
|
||||
label = {
|
||||
Text(text = if (languageFilter == -1) languageStr else languages[languageFilter])
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = if (languageFilter == -1) Icons.Outlined.FilterAlt else Icons.Default.FilterAlt,
|
||||
contentDescription = null,
|
||||
)
|
||||
)
|
||||
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
|
||||
val minRatingStr = stringResource(id = R.string.search_sr)
|
||||
DropdownFilterChip(
|
||||
label = minRatingStr,
|
||||
menuItems = minRatingItems.asList(),
|
||||
selectedItemIndex = (advancedOption.minRating - 1).coerceAtLeast(0),
|
||||
onSelectedItemIndexChange = {
|
||||
onAdvancedOptionChanged(advancedOption.copy(minRating = if (it == 0) 0 else it + 1))
|
||||
},
|
||||
)
|
||||
val pageErr1 = stringResource(R.string.search_sp_err1)
|
||||
@@ -201,19 +207,6 @@ fun SearchFilter(
|
||||
},
|
||||
label = { Text(text = pagesText) },
|
||||
)
|
||||
val minRatingItems = stringArrayResource(id = R.array.search_min_rating)
|
||||
val minRatingStr = stringResource(id = R.string.search_sr)
|
||||
val minRatingIndex = (advancedOption.minRating - 1).coerceAtLeast(0)
|
||||
FilterChip(
|
||||
selected = advancedOption.minRating != 0,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val selected = dialogState.showSingleChoice(minRatingItems.toList(), minRatingIndex)
|
||||
onAdvancedOptionChanged(advancedOption.copy(minRating = if (selected == 0) 0 else selected + 1))
|
||||
}
|
||||
},
|
||||
label = { Text(text = minRatingStr + minRatingItems[minRatingIndex]) },
|
||||
)
|
||||
fun checked(bit: Int) = advancedOption.advanceSearch and bit != 0
|
||||
fun AdvancedSearchOption.inv(bit: Int) = onAdvancedOptionChanged(copy(advanceSearch = advanceSearch xor bit))
|
||||
FilterChip(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.hippo.ehviewer.ui.main
|
||||
|
||||
import androidx.compose.foundation.MarqueeSpacing
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.parser.Torrent
|
||||
import com.hippo.ehviewer.client.parser.format
|
||||
|
||||
@Composable
|
||||
fun TorrentList(
|
||||
items: List<Torrent>,
|
||||
onItemClick: (Torrent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = stringResource(R.string.torrents)
|
||||
Column(
|
||||
modifier = modifier.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.headlineSmall)
|
||||
val labelStyle = MaterialTheme.typography.labelLarge
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items) {
|
||||
Column(
|
||||
modifier = Modifier.clickable { onItemClick(it) }.minimumInteractiveComponentSize()
|
||||
.padding(horizontal = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = it.name,
|
||||
modifier = Modifier.basicMarquee(
|
||||
spacing = MarqueeSpacing(16.dp),
|
||||
velocity = 60.dp,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = it.posted,
|
||||
modifier = Modifier.weight(2.5f),
|
||||
color = if (it.outdated) MaterialTheme.colorScheme.error else Color.Unspecified,
|
||||
style = labelStyle,
|
||||
)
|
||||
Text(
|
||||
text = it.uploader,
|
||||
modifier = Modifier.weight(2.5f),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
style = labelStyle,
|
||||
)
|
||||
Text(
|
||||
text = it.size,
|
||||
modifier = Modifier.weight(2f),
|
||||
style = labelStyle,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
Text(
|
||||
text = it.format(),
|
||||
modifier = Modifier.weight(3f),
|
||||
style = labelStyle,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.hippo.ehviewer.ui.reader
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -39,6 +40,7 @@ fun GalleryPager(
|
||||
onSelectPage: (ReaderPage) -> Unit,
|
||||
onMenuRegionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
) {
|
||||
val items = pageLoader.pages
|
||||
when (type) {
|
||||
@@ -46,6 +48,7 @@ fun GalleryPager(
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
reverseLayout = type == RIGHT_TO_LEFT,
|
||||
key = { it },
|
||||
) { index ->
|
||||
@@ -82,6 +85,7 @@ fun GalleryPager(
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
key = { it },
|
||||
) { index ->
|
||||
val page = items[index]
|
||||
@@ -132,6 +136,7 @@ fun GalleryPager(
|
||||
},
|
||||
),
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(if (type != WEBTOON) 15.dp else 0.dp),
|
||||
) {
|
||||
items(items, key = { it.index }) { page ->
|
||||
|
||||
@@ -86,6 +86,10 @@ suspend fun save(page: ReaderPage) {
|
||||
val cannotSave = getString(R.string.error_cant_save_image)
|
||||
if (granted) {
|
||||
val filename = getImageFilename(page.index)
|
||||
if (filename == null) {
|
||||
showSnackbar(cannotSave)
|
||||
return
|
||||
}
|
||||
val extension = FileUtils.getExtensionFromFilename(filename)
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
|
||||
val values = ContentValues()
|
||||
@@ -133,6 +137,10 @@ suspend fun save(page: ReaderPage) {
|
||||
context(SnackbarHostState, Context, PageLoader2)
|
||||
suspend fun saveTo(page: ReaderPage) {
|
||||
val filename = getImageFilename(page.index)
|
||||
if (filename == null) {
|
||||
showSnackbar(getString(R.string.error_cant_save_image))
|
||||
return
|
||||
}
|
||||
val extension = FileUtils.getExtensionFromFilename(filename)
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg"
|
||||
page.runSuspendCatching {
|
||||
|
||||
@@ -7,12 +7,15 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
|
||||
@Composable
|
||||
fun ReaderGeneralSetting() = Column(modifier = Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding()) {
|
||||
@@ -39,11 +42,15 @@ fun ReaderGeneralSetting() = Column(modifier = Modifier.verticalScroll(rememberS
|
||||
title = stringResource(id = R.string.pref_fullscreen),
|
||||
field = fullscreen,
|
||||
)
|
||||
AnimatedVisibility(visible = fullscreen.value) {
|
||||
SwitchChoice(
|
||||
title = stringResource(id = R.string.pref_cutout_short),
|
||||
field = Settings.cutoutShort.asMutableState(),
|
||||
)
|
||||
val view = LocalView.current
|
||||
val hasDisplayCutout = remember(view) { isAtLeastP && view.rootWindowInsets.displayCutout != null }
|
||||
if (hasDisplayCutout) {
|
||||
AnimatedVisibility(visible = fullscreen.value) {
|
||||
SwitchChoice(
|
||||
title = stringResource(id = R.string.pref_cutout_short),
|
||||
field = Settings.cutoutShort.asMutableState(),
|
||||
)
|
||||
}
|
||||
}
|
||||
SwitchChoice(
|
||||
title = stringResource(id = R.string.pref_keep_screen_on),
|
||||
|
||||
@@ -70,7 +70,7 @@ private fun PagerSetting() = Column {
|
||||
title = stringResource(id = R.string.pref_read_with_tapping_inverted),
|
||||
entries = stringArrayResource(id = R.array.invert_tapping_mode),
|
||||
values = arrayOf("0", "1", "2", "3"),
|
||||
field = Settings.readerWebtoonNavInverted.asMutableState(),
|
||||
field = Settings.readerPagerNavInverted.asMutableState(),
|
||||
)
|
||||
SwitchChoice(
|
||||
title = stringResource(id = R.string.pref_navigate_pan),
|
||||
|
||||
@@ -4,10 +4,17 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.displayCutout
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.statusBarsIgnoringVisibility
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
@@ -24,12 +31,15 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTrackerAddPointsFix
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
@@ -57,15 +67,6 @@ import moe.tarsin.kt.unreachable
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsNavigator) = composing(navigator) {
|
||||
// Workaround for incorrect fling velocity on zoom gesture end
|
||||
// https://github.com/saket/telephoto/issues/71
|
||||
DisposableEffect(Unit) {
|
||||
VelocityTrackerAddPointsFix = false
|
||||
onDispose {
|
||||
VelocityTrackerAddPointsFix = true
|
||||
}
|
||||
}
|
||||
|
||||
LockDrawer(true)
|
||||
ConfigureKeepScreenOn()
|
||||
val pageLoader = remember {
|
||||
@@ -86,6 +87,16 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
|
||||
val showSeekbar by Settings.showReaderSeekbar.collectAsState()
|
||||
val readingMode by Settings.readingMode.collectAsState { ReadingModeType.fromPreference(it) }
|
||||
val volumeKeysEnabled by Settings.readWithVolumeKeys.collectAsState()
|
||||
val fullscreen by Settings.fullscreen.collectAsState()
|
||||
val cutoutShort by Settings.cutoutShort.collectAsState()
|
||||
val uiController = rememberSystemUiController()
|
||||
DisposableEffect(uiController) {
|
||||
uiController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
onDispose {
|
||||
uiController.isSystemBarsVisible = true
|
||||
uiController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
|
||||
}
|
||||
}
|
||||
Deferred({ pageLoader.awaitReady() }) {
|
||||
val lazyListState = rememberLazyListState()
|
||||
val pagerState = rememberPagerState { pageLoader.size }
|
||||
@@ -94,6 +105,13 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
|
||||
Box {
|
||||
var appbarVisible by remember { mutableStateOf(false) }
|
||||
val bgColor by collectBackgroundColorAsState()
|
||||
if (fullscreen) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { appbarVisible }.collect {
|
||||
uiController.isSystemBarsVisible = it
|
||||
}
|
||||
}
|
||||
}
|
||||
VolumeKeysHandler(
|
||||
enabled = { volumeKeysEnabled && !appbarVisible },
|
||||
movePrevious = { syncState.sliderScrollTo(syncState.sliderValue - 1) },
|
||||
@@ -112,8 +130,9 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
|
||||
val state = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dispose() },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
|
||||
sheetState = state,
|
||||
windowInsets = WindowInsets.statusBarsIgnoringVisibility,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
) {
|
||||
ReaderPageSheetMeta(
|
||||
retry = { pageLoader.retryPage(page.index) },
|
||||
@@ -131,6 +150,15 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
|
||||
},
|
||||
onMenuRegionClick = { appbarVisible = !appbarVisible },
|
||||
modifier = Modifier.background(bgColor),
|
||||
contentPadding = if (fullscreen) {
|
||||
if (cutoutShort) {
|
||||
PaddingValues(0.dp)
|
||||
} else {
|
||||
WindowInsets.displayCutout.asPaddingValues()
|
||||
}
|
||||
} else {
|
||||
WindowInsets.systemBars.asPaddingValues()
|
||||
},
|
||||
)
|
||||
val brightness by Settings.customBrightness.collectAsState()
|
||||
val brightnessValue by Settings.customBrightnessValue.collectAsState()
|
||||
@@ -153,7 +181,7 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
|
||||
colorBlendMode = colorOverlayMode,
|
||||
)
|
||||
val showPageNumber by Settings.showPageNumber.collectAsState()
|
||||
if (showPageNumber) {
|
||||
if (showPageNumber && !appbarVisible) {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodySmall) {
|
||||
PageIndicatorText(
|
||||
currentPage = syncState.sliderValue,
|
||||
@@ -180,10 +208,11 @@ fun ReaderScreen(info: BaseGalleryInfo, page: Int = -1, navigator: DestinationsN
|
||||
)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { dispose() },
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
|
||||
// Yeah, I know color state should not be read here, but we have to do it...
|
||||
scrimColor = scrim,
|
||||
dragHandle = null,
|
||||
windowInsets = WindowInsets.statusBarsIgnoringVisibility,
|
||||
contentWindowInsets = { WindowInsets(0) },
|
||||
) {
|
||||
SettingsPager(modifier = Modifier.fillMaxSize()) { page ->
|
||||
isColorFilter = page == 2
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
@@ -16,7 +15,7 @@ import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.gallery.PageLoader2
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
|
||||
@Stable
|
||||
class SliderPagerDoubleSync(
|
||||
@@ -24,8 +23,10 @@ class SliderPagerDoubleSync(
|
||||
private val pagerState: PagerState,
|
||||
private val pageLoader: PageLoader2,
|
||||
) {
|
||||
private var sliderFollowPager by mutableStateOf(true)
|
||||
var sliderValue by mutableIntStateOf(1)
|
||||
private var requestScroll = true
|
||||
private var sliderFollowPager = true
|
||||
var sliderValue by mutableIntStateOf(pageLoader.startPage + 1)
|
||||
private set
|
||||
|
||||
fun sliderScrollTo(index: Int) {
|
||||
sliderFollowPager = false
|
||||
@@ -37,52 +38,55 @@ class SliderPagerDoubleSync(
|
||||
val fling by lazyListState.interactionSource.collectIsDraggedAsState()
|
||||
val pagerFling by pagerState.interactionSource.collectIsDraggedAsState()
|
||||
if (fling || pagerFling) sliderFollowPager = true
|
||||
val startPage = remember { pageLoader.startPage }
|
||||
val currentIndexFlow = remember(webtoon) {
|
||||
requestScroll = true
|
||||
if (webtoon) {
|
||||
snapshotFlow { lazyListState.layoutInfo }.mapNotNull { info ->
|
||||
info.visibleItemsInfo.lastOrNull {
|
||||
it.offset <= maxOf(info.viewportStartOffset, info.viewportEndOffset - it.size)
|
||||
}?.index
|
||||
}
|
||||
snapshotFlow {
|
||||
with(lazyListState.layoutInfo) {
|
||||
visibleItemsInfo.lastOrNull {
|
||||
it.offset <= maxOf(viewportStartOffset, viewportEndOffset - it.size)
|
||||
}?.index
|
||||
}
|
||||
}.filterNotNull()
|
||||
} else {
|
||||
snapshotFlow { pagerState.currentPage }
|
||||
}
|
||||
}
|
||||
if (sliderFollowPager) {
|
||||
LaunchedEffect(currentIndexFlow) {
|
||||
currentIndexFlow.collect { index ->
|
||||
LaunchedEffect(currentIndexFlow) {
|
||||
currentIndexFlow.collect { index ->
|
||||
if (sliderFollowPager && !requestScroll) {
|
||||
sliderValue = index + 1
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(webtoon) {
|
||||
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
|
||||
if (webtoon) {
|
||||
val noAnim = (index - lazyListState.firstVisibleItemIndex).absoluteValue > SMOOTH_SCROLL_THRESHOLD || !Settings.pageTransitions.value
|
||||
if (noAnim) {
|
||||
lazyListState.scrollToItem(index)
|
||||
} else {
|
||||
lazyListState.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(webtoon) {
|
||||
snapshotFlow { sliderValue - 1 }.collectLatest { index ->
|
||||
if (sliderFollowPager && !requestScroll) return@collectLatest
|
||||
if (webtoon) {
|
||||
if (requestScroll) {
|
||||
lazyListState.requestScrollToItem(index)
|
||||
requestScroll = false
|
||||
} else {
|
||||
val noAnim = (index - pagerState.currentPage).absoluteValue > SMOOTH_SCROLL_THRESHOLD || !Settings.pageTransitions.value
|
||||
if (noAnim) {
|
||||
pagerState.scrollToPage(index)
|
||||
if (smoothScroll((index - lazyListState.firstVisibleItemIndex))) {
|
||||
lazyListState.animateScrollToItem(index)
|
||||
} else {
|
||||
lazyListState.scrollToItem(index)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (requestScroll) {
|
||||
pagerState.requestScrollToPage(index)
|
||||
requestScroll = false
|
||||
} else {
|
||||
if (smoothScroll((index - pagerState.currentPage))) {
|
||||
pagerState.animateScrollToPage(index)
|
||||
} else {
|
||||
pagerState.scrollToPage(index)
|
||||
}
|
||||
}
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
if (webtoon) {
|
||||
lazyListState.scrollToItem(startPage)
|
||||
} else {
|
||||
pagerState.scrollToPage(startPage)
|
||||
pageLoader.startPage = index
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,4 +104,7 @@ fun rememberSliderPagerDoubleSyncState(
|
||||
}
|
||||
}
|
||||
|
||||
private fun smoothScroll(distance: Int) =
|
||||
distance.absoluteValue < SMOOTH_SCROLL_THRESHOLD && Settings.pageTransitions.value
|
||||
|
||||
private const val SMOOTH_SCROLL_THRESHOLD = 50
|
||||
@@ -29,6 +29,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SpinnerChoice(title: String, entries: Array<String>, values: Array<String>, field: MutableState<Int>) {
|
||||
@@ -97,9 +98,7 @@ fun SliderChoice(
|
||||
) {
|
||||
startSlot()
|
||||
val configuration = LocalConfiguration.current
|
||||
// https://m3.material.io/components/sliders/specs
|
||||
// Tick width is 2 dp
|
||||
val maxTickCount = configuration.screenWidthDp / 6
|
||||
val maxTickCount = configuration.screenWidthDp / (SliderDefaults.TickSize.value * 2.5f).roundToInt()
|
||||
val steps = range.last - range.first - 1
|
||||
val showTicks = steps < maxTickCount
|
||||
Slider(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
// https://github.com/Kotlin/KEEP/blob/context-parameters/proposals/context-parameters.md#standard-library-support
|
||||
context(T)
|
||||
inline fun <reified T> implicit(): T = this@T
|
||||
@@ -2,18 +2,20 @@ package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.util.containsIgnoreCase
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class DownloadsFilterState(
|
||||
val mode: DownloadsFilterMode,
|
||||
val label: String?,
|
||||
val state: Int = -1,
|
||||
val keyword: String = "",
|
||||
) : Parcelable
|
||||
|
||||
fun DownloadsFilterState.take(info: DownloadInfo) =
|
||||
(label == "" || info.label == label) && (state == -1 || info.state == state) &&
|
||||
mode.take(info, label) && (state == -1 || info.state == state) &&
|
||||
with(info) {
|
||||
title.containsIgnoreCase(keyword) || titleJpn.containsIgnoreCase(keyword) ||
|
||||
simpleTags?.any { it.containsIgnoreCase(keyword) } ?: false
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.compose.animation.Crossfade
|
||||
@@ -12,8 +14,6 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material.icons.automirrored.filled.ViewList
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.DoneAll
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.NewLabel
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Reorder
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -47,13 +49,15 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBoxDefaults
|
||||
import androidx.compose.material3.SwipeToDismissBoxState
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.fork.SwipeToDismissBox
|
||||
import androidx.compose.material3.fork.SwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -70,10 +74,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -84,35 +85,39 @@ import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.DownloadManager.labelList
|
||||
import com.hippo.ehviewer.download.DownloadManager.downloadInfoList
|
||||
import com.hippo.ehviewer.download.DownloadService
|
||||
import com.hippo.ehviewer.download.DownloadsFilterMode
|
||||
import com.hippo.ehviewer.download.SortMode
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.big.Download
|
||||
import com.hippo.ehviewer.ui.LocalSideSheetState
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.confirmRemoveDownloadRange
|
||||
import com.hippo.ehviewer.ui.main.DownloadCard
|
||||
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
|
||||
import com.hippo.ehviewer.ui.main.FabLayout
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.showMoveDownloadLabelList
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.DragHandle
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
|
||||
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
|
||||
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
import com.hippo.ehviewer.ui.tools.draggingHapticFeedback
|
||||
import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.findActivity
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.mapToLongArray
|
||||
import com.hippo.ehviewer.util.takeAndClear
|
||||
import com.jamal.composeprefs3.ui.ifTrueThen
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
@@ -120,21 +125,22 @@ import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.onEachLatest
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyColumnState
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
var gridView by Settings.gridView.asMutableState()
|
||||
var sortMode by Settings.downloadSortMode.asMutableState()
|
||||
var filterState by rememberSaveable { mutableStateOf(DownloadsFilterState(Settings.recentDownloadLabel.value)) }
|
||||
val filterMode by Settings.downloadFilterMode.collectAsState { DownloadsFilterMode.from(it) }
|
||||
var filterState by rememberSaveable { mutableStateOf(DownloadsFilterState(filterMode, Settings.recentDownloadLabel.value)) }
|
||||
var invalidateKey by rememberSaveable { mutableStateOf(false) }
|
||||
var searchBarOffsetY by remember(filterState.label) { mutableIntStateOf(0) }
|
||||
var searchBarOffsetY by remember { mutableIntStateOf(0) }
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
var fabExpanded by remember { mutableStateOf(false) }
|
||||
var fabHidden by remember { mutableStateOf(false) }
|
||||
@@ -142,22 +148,33 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty())
|
||||
LockDrawer(selectMode)
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = remember { findActivity<MainActivity>() }
|
||||
val density = LocalDensity.current
|
||||
val view = LocalView.current
|
||||
val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(implicit<Context>()) && EhTagDatabase.initialized
|
||||
val ehTags = EhTagDatabase.takeIf { canTranslate }
|
||||
fun String.translateArtist() = ehTags?.getTranslation(TagNamespace.Artist.toPrefix(), this) ?: this
|
||||
val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold
|
||||
val allName = stringResource(R.string.download_all)
|
||||
val defaultName = stringResource(R.string.default_download_label_name)
|
||||
val unknownName = stringResource(R.string.unknown_artists)
|
||||
val emptyLabelName = when (filterMode) {
|
||||
DownloadsFilterMode.ARTIST -> unknownName
|
||||
DownloadsFilterMode.CUSTOM -> defaultName
|
||||
}
|
||||
val title = stringResource(
|
||||
R.string.scene_download_title,
|
||||
with(filterState) { if (label == "") allName else label ?: defaultName },
|
||||
with(filterState) {
|
||||
when (label) {
|
||||
"" -> allName
|
||||
null -> emptyLabelName
|
||||
else -> if (mode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
|
||||
}
|
||||
},
|
||||
)
|
||||
val hint = stringResource(R.string.search_bar_hint, title)
|
||||
val sortModes = stringArrayResource(id = R.array.download_sort_modes)
|
||||
val downloadStates = stringArrayResource(id = R.array.download_state)
|
||||
val list = remember(filterState, invalidateKey) {
|
||||
DownloadManager.downloadInfoList.filterTo(mutableStateListOf()) { info ->
|
||||
downloadInfoList.filterTo(mutableStateListOf()) { info ->
|
||||
filterState.take(info)
|
||||
}
|
||||
}
|
||||
@@ -168,21 +185,42 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val labelEmpty = stringResource(R.string.label_text_is_empty)
|
||||
val defaultInvalid = stringResource(R.string.label_text_is_invalid)
|
||||
val labelExists = stringResource(R.string.label_text_exist)
|
||||
val downloadsCount by rememberInVM { EhDB.downloadsCount }.collectAsState(emptyMap())
|
||||
val totalCount = remember(downloadsCount) { downloadsCount.values.sum() }
|
||||
val downloadsCountGroupByArtist by rememberInVM { EhDB.downloadsCountByArtist }.collectAsState(emptyMap())
|
||||
val downloadsCountGroupByLabel by rememberInVM { EhDB.downloadsCountByLabel }.collectAsState(emptyMap())
|
||||
val downloadsCount = when (filterMode) {
|
||||
DownloadsFilterMode.CUSTOM -> downloadsCountGroupByLabel
|
||||
DownloadsFilterMode.ARTIST -> downloadsCountGroupByArtist
|
||||
}
|
||||
val artistList = remember(downloadsCountGroupByArtist) {
|
||||
downloadsCountGroupByArtist.keys.mapNotNull { artist -> artist?.let { it to it } }
|
||||
}
|
||||
val labelList by remember {
|
||||
derivedStateOf {
|
||||
DownloadManager.labelList.map { it.id!! to it.label }
|
||||
}
|
||||
}
|
||||
val groupList = when (filterMode) {
|
||||
DownloadsFilterMode.CUSTOM -> labelList
|
||||
DownloadsFilterMode.ARTIST -> artistList
|
||||
}
|
||||
val totalCount = remember(downloadsCountGroupByLabel) { downloadsCountGroupByLabel.values.sum() }
|
||||
|
||||
fun switchLabel(label: String?) {
|
||||
Settings.recentDownloadLabel.value = label
|
||||
filterState = filterState.copy(label = label)
|
||||
}
|
||||
|
||||
with(activity) {
|
||||
ProvideSideSheetContent { drawerState ->
|
||||
fun closeSheet() = launch { drawerState.close() }
|
||||
TopAppBar(
|
||||
title = { Text(text = labelsStr) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
actions = {
|
||||
LaunchedEffect(filterState) {
|
||||
searchBarOffsetY = 0
|
||||
}
|
||||
|
||||
ProvideSideSheetContent { drawerState ->
|
||||
fun closeSheet() = launch { drawerState.close() }
|
||||
TopAppBar(
|
||||
title = { Text(text = labelsStr) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
actions = {
|
||||
if (DownloadsFilterMode.CUSTOM == filterMode) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
launch {
|
||||
@@ -204,7 +242,12 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
launch {
|
||||
showSelectActions(R.string.default_download_label) {
|
||||
val selected = if (!Settings.hasDefaultDownloadLabel) {
|
||||
0
|
||||
} else {
|
||||
DownloadManager.labelList.indexOfFirst { it.label == Settings.defaultDownloadLabel } + 2
|
||||
}
|
||||
awaitSelectAction(R.string.default_download_label, selected) {
|
||||
onSelect(letMeSelect) {
|
||||
Settings.hasDefaultDownloadLabel = false
|
||||
}
|
||||
@@ -212,147 +255,180 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
Settings.hasDefaultDownloadLabel = true
|
||||
Settings.defaultDownloadLabel = null
|
||||
}
|
||||
labelList.forEach { (label) ->
|
||||
DownloadManager.labelList.forEach { (label) ->
|
||||
onSelect(label) {
|
||||
Settings.hasDefaultDownloadLabel = true
|
||||
Settings.defaultDownloadLabel = label
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Settings, contentDescription = null)
|
||||
Icon(imageVector = Icons.Default.Download, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val labelsListState = rememberLazyListState()
|
||||
val reorderableLabelState = rememberReorderableLazyColumnState(labelsListState) { from, to ->
|
||||
val fromPosition = from.index - 2
|
||||
val toPosition = to.index - 2
|
||||
labelList.apply { add(toPosition, removeAt(fromPosition)) }
|
||||
view.performHapticFeedback(draggingHapticFeedback)
|
||||
}
|
||||
var fromIndex by remember { mutableIntStateOf(-1) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = labelsListState,
|
||||
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
) {
|
||||
stickyHeader {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
switchLabel("")
|
||||
closeSheet()
|
||||
},
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 1.dp,
|
||||
headlineContent = {
|
||||
Text("$allName [$totalCount]")
|
||||
},
|
||||
)
|
||||
}
|
||||
stickyHeader {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
switchLabel(null)
|
||||
closeSheet()
|
||||
},
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 1.dp,
|
||||
headlineContent = {
|
||||
Text("$defaultName [${downloadsCount.getOrDefault(null, 0)}]")
|
||||
},
|
||||
)
|
||||
}
|
||||
itemsIndexed(labelList, key = { _, item -> item.id!! }) { index, (item, _, id) ->
|
||||
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
|
||||
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) }
|
||||
LaunchedEffect(dismissState) {
|
||||
snapshotFlow { dismissState.currentValue }.collect {
|
||||
if (it == SwipeToDismissBoxValue.EndToStart) {
|
||||
runCatching {
|
||||
awaitPermissionOrCancel(confirmText = R.string.delete) {
|
||||
Text(text = stringResource(R.string.delete_label, item))
|
||||
}
|
||||
}.onSuccess {
|
||||
DownloadManager.deleteLabel(item)
|
||||
when (filterState.label) {
|
||||
item -> switchLabel("")
|
||||
null -> invalidateKey = !invalidateKey
|
||||
}
|
||||
}.onFailure {
|
||||
dismissState.reset()
|
||||
val custom = stringResource(R.string.select_grouping_mode_custom)
|
||||
val artist = stringResource(R.string.select_grouping_mode_artist)
|
||||
IconButton(
|
||||
onClick = {
|
||||
launch {
|
||||
awaitSelectAction(R.string.select_grouping_mode) {
|
||||
val select = { mode: DownloadsFilterMode ->
|
||||
filterState = filterState.copy(mode = mode, label = "")
|
||||
Settings.downloadFilterMode.value = mode.flag
|
||||
Settings.recentDownloadLabel.value = ""
|
||||
}
|
||||
onSelect(custom) { select(DownloadsFilterMode.CUSTOM) }
|
||||
onSelect(artist) { select(DownloadsFilterMode.ARTIST) }
|
||||
}()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Settings, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val labelsListState = rememberLazyListState()
|
||||
val editEnable = DownloadsFilterMode.CUSTOM == filterMode
|
||||
val hapticFeedback = rememberHapticFeedback()
|
||||
val reorderableLabelState = rememberReorderableLazyListState(labelsListState) { from, to ->
|
||||
val fromPosition = from.index - 2
|
||||
val toPosition = to.index - 2
|
||||
DownloadManager.labelList.apply { add(toPosition, removeAt(fromPosition)) }
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
|
||||
}
|
||||
var fromIndex by remember { mutableIntStateOf(-1) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = labelsListState,
|
||||
contentPadding = WindowInsets.systemBars.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
) {
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
switchLabel("")
|
||||
closeSheet()
|
||||
},
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 1.dp,
|
||||
headlineContent = {
|
||||
Text("$allName [$totalCount]")
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
switchLabel(null)
|
||||
closeSheet()
|
||||
},
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = 1.dp,
|
||||
headlineContent = {
|
||||
Text("$emptyLabelName [${downloadsCount.getOrDefault(null, 0)}]")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(groupList, key = { _, (id) -> id }) { index, (id, label) ->
|
||||
val item by rememberUpdatedState(label)
|
||||
// Not using rememberSwipeToDismissBoxState to prevent LazyColumn from reusing it
|
||||
val dismissState = remember { SwipeToDismissBoxState(SwipeToDismissBoxValue.Settled, density, positionalThreshold = positionalThreshold) }
|
||||
LaunchedEffect(dismissState) {
|
||||
snapshotFlow { dismissState.currentValue }.collect {
|
||||
if (it == SwipeToDismissBoxValue.EndToStart) {
|
||||
runCatching {
|
||||
awaitPermissionOrCancel(confirmText = R.string.delete) {
|
||||
Text(text = stringResource(R.string.delete_label, item))
|
||||
}
|
||||
}.onSuccess {
|
||||
DownloadManager.deleteLabel(item)
|
||||
when (filterState.label) {
|
||||
item -> switchLabel("")
|
||||
null -> invalidateKey = !invalidateKey
|
||||
}
|
||||
}.onFailure {
|
||||
dismissState.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
ReorderableItem(reorderableLabelState, key = id!!) { isDragging ->
|
||||
SwipeToDismissBox2(
|
||||
state = dismissState,
|
||||
backgroundContent = {},
|
||||
) {
|
||||
val elevation by animateDpAsState(
|
||||
if (isDragging) {
|
||||
8.dp // md.sys.elevation.level4
|
||||
} else {
|
||||
1.dp // md.sys.elevation.level1
|
||||
},
|
||||
label = "elevation",
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
switchLabel(item)
|
||||
closeSheet()
|
||||
},
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = elevation,
|
||||
headlineContent = {
|
||||
Text("$item [${downloadsCount.getOrDefault(item, 0)}]")
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = {
|
||||
launch {
|
||||
val new = awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
|
||||
when {
|
||||
text.isBlank() -> labelEmpty
|
||||
text == defaultName -> defaultInvalid
|
||||
DownloadManager.containLabel(text) -> labelExists
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
DownloadManager.renameLabel(item, new)
|
||||
if (filterState.label == item) {
|
||||
switchLabel(new)
|
||||
}
|
||||
ReorderableItem(reorderableLabelState, enabled = editEnable, key = id) { isDragging ->
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {},
|
||||
enableDismissFromStartToEnd = false,
|
||||
gesturesEnabled = editEnable,
|
||||
) {
|
||||
val elevation by animateDpAsState(
|
||||
if (isDragging) {
|
||||
8.dp // md.sys.elevation.level4
|
||||
} else {
|
||||
1.dp // md.sys.elevation.level1
|
||||
},
|
||||
label = "elevation",
|
||||
)
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
switchLabel(item)
|
||||
closeSheet()
|
||||
},
|
||||
tonalElevation = 1.dp,
|
||||
shadowElevation = elevation,
|
||||
headlineContent = {
|
||||
val name = if (filterMode == DownloadsFilterMode.ARTIST) label.translateArtist() else label
|
||||
Text("$name [${downloadsCount.getOrDefault(item, 0)}]")
|
||||
},
|
||||
trailingContent = editEnable.ifTrueThen {
|
||||
Row {
|
||||
IconButton(
|
||||
onClick = {
|
||||
launch {
|
||||
val new = awaitInputText(initial = item, title = renameLabel, hint = labelsStr) { text ->
|
||||
when {
|
||||
text.isBlank() -> labelEmpty
|
||||
text == defaultName -> defaultInvalid
|
||||
DownloadManager.containLabel(text) -> labelExists
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Edit, contentDescription = null)
|
||||
}
|
||||
DragHandle(
|
||||
DownloadManager.renameLabel(item, new)
|
||||
if (filterState.label == item) {
|
||||
switchLabel(new)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Edit, contentDescription = null)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.draggableHandle(
|
||||
onDragStarted = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.START)
|
||||
fromIndex = index
|
||||
},
|
||||
onDragStopped = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.END)
|
||||
if (fromIndex != -1) {
|
||||
if (fromIndex != index) {
|
||||
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
|
||||
val toUpdate = labelList.slice(range)
|
||||
val toUpdate = DownloadManager.labelList.slice(range)
|
||||
toUpdate.zip(range).forEach { it.first.position = it.second }
|
||||
launchIO { EhDB.updateDownloadLabel(toUpdate) }
|
||||
}
|
||||
fromIndex = -1
|
||||
}
|
||||
},
|
||||
)
|
||||
),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Reorder, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,9 +467,9 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
text = { Text(text = stringResource(id = R.string.download_start_all)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
val intent = Intent(activity, DownloadService::class.java)
|
||||
val intent = Intent(implicit<Activity>(), DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START_ALL
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
ContextCompat.startForegroundService(implicit<Activity>(), intent)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
@@ -425,27 +501,19 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
onClick = {
|
||||
expanded = false
|
||||
val gidList = list.filter { it.state != DownloadInfo.STATE_FINISH }.asReversed().mapToLongArray(DownloadInfo::gid)
|
||||
val intent = Intent(activity, DownloadService::class.java)
|
||||
val intent = Intent(implicit<Activity>(), DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START_RANGE
|
||||
intent.putExtra(DownloadService.KEY_GID_LIST, gidList)
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
ContextCompat.startForegroundService(implicit<Activity>(), intent)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
val height by collectListThumbSizeAsState()
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
|
||||
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
|
||||
val realPadding = PaddingValues(
|
||||
top = contentPadding.calculateTopPadding() + marginV,
|
||||
bottom = contentPadding.calculateBottomPadding() + marginV,
|
||||
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
|
||||
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
|
||||
)
|
||||
val realPadding = contentPadding + PaddingValues(dimensionResource(id = R.dimen.gallery_list_margin_h), dimensionResource(id = R.dimen.gallery_list_margin_v))
|
||||
val searchBarConnection = remember {
|
||||
val slop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
val slop = ViewConfiguration.get(implicit<Context>()).scaledTouchSlop
|
||||
val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() }
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
@@ -465,6 +533,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
launchIO { EhDB.putHistoryInfo(info.galleryInfo) }
|
||||
navToReader(info.galleryInfo)
|
||||
}
|
||||
|
||||
Crossfade(targetState = gridView, label = "Downloads") { showGridView ->
|
||||
if (showGridView) {
|
||||
val gridInterval = dimensionResource(R.dimen.gallery_grid_interval)
|
||||
@@ -476,13 +545,13 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
horizontalArrangement = Arrangement.spacedBy(gridInterval),
|
||||
contentPadding = realPadding,
|
||||
) {
|
||||
items(list) { info ->
|
||||
items(list, key = { it.gid }) { info ->
|
||||
GalleryInfoGridItem(
|
||||
onClick = ::onItemClick.partially1(info),
|
||||
onLongClick = { navigator.navigate(info.galleryInfo.asDst()) },
|
||||
onLongClick = { navigate(info.galleryInfo.asDst()) },
|
||||
info = info,
|
||||
modifier = Modifier.animateItemPlacement(),
|
||||
badgeText = info.pages.takeIf { it > 0 }?.toString(),
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
showLanguage = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -494,7 +563,10 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
) {
|
||||
items(list, key = { it.gid }) { info ->
|
||||
val checked = info.gid in checkedInfoMap
|
||||
CheckableItem(checked = checked, modifier = Modifier.animateItem()) { interactionSource ->
|
||||
CheckableItem(
|
||||
checked = checked,
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { interactionSource ->
|
||||
DownloadCard(
|
||||
onClick = {
|
||||
if (selectMode) {
|
||||
@@ -508,19 +580,20 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
},
|
||||
onThumbClick = {
|
||||
navigator.navigate(info.galleryInfo.asDst())
|
||||
navigate(info.galleryInfo.asDst())
|
||||
},
|
||||
onLongClick = {
|
||||
checkedInfoMap[info.gid] = info
|
||||
},
|
||||
onStart = {
|
||||
val intent = Intent(activity, DownloadService::class.java)
|
||||
val intent = Intent(implicit<Activity>(), DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START
|
||||
intent.putExtra(DownloadService.KEY_GALLERY_INFO, info.galleryInfo)
|
||||
ContextCompat.startForegroundService(activity, intent)
|
||||
ContextCompat.startForegroundService(implicit<Activity>(), intent)
|
||||
},
|
||||
onStop = { launchIO { DownloadManager.stopDownload(info.gid) } },
|
||||
info = info,
|
||||
selectMode = selectMode,
|
||||
modifier = Modifier.height(height),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
@@ -577,7 +650,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
onClick(Icons.AutoMirrored.Default.Sort) {
|
||||
val oldMode = SortMode.from(sortMode)
|
||||
val (selected, checked) = showSelectItemWithCheckBox(
|
||||
val (selected, checked) = awaitSelectItemWithCheckBox(
|
||||
sortModes.toList(),
|
||||
R.string.sort_by,
|
||||
R.string.group_by_download_label,
|
||||
@@ -590,7 +663,7 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
invalidateKey = !invalidateKey
|
||||
}
|
||||
onClick(Icons.Default.FilterList) {
|
||||
val state = showSingleChoice(
|
||||
val state = awaitSingleChoice(
|
||||
downloadStates.toList(),
|
||||
filterState.state + 1,
|
||||
R.string.download_filter,
|
||||
@@ -598,31 +671,28 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
filterState = filterState.copy(state = state)
|
||||
}
|
||||
} else {
|
||||
onClick(Icons.Default.DoneAll) {
|
||||
onClick(Icons.Default.DoneAll, autoClose = false) {
|
||||
val info = list.associateBy { it.gid }
|
||||
checkedInfoMap.putAll(info)
|
||||
throw CancellationException()
|
||||
}
|
||||
onClick(Icons.Default.PlayArrow) {
|
||||
val gidList = checkedInfoMap.run { toMap().values.also { clear() } }
|
||||
.mapToLongArray(DownloadInfo::gid)
|
||||
val intent = Intent(activity, DownloadService::class.java)
|
||||
val gidList = checkedInfoMap.takeAndClear().mapToLongArray(DownloadInfo::gid)
|
||||
val intent = Intent(implicit<Activity>(), DownloadService::class.java)
|
||||
intent.action = DownloadService.ACTION_START_RANGE
|
||||
intent.putExtra(DownloadService.KEY_GID_LIST, gidList)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
ContextCompat.startForegroundService(implicit<Context>(), intent)
|
||||
}
|
||||
onClick(Icons.Default.Pause) {
|
||||
val gidList = checkedInfoMap.run { toMap().values.also { clear() } }
|
||||
.mapToLongArray(DownloadInfo::gid)
|
||||
val gidList = checkedInfoMap.takeAndClear().mapToLongArray(DownloadInfo::gid)
|
||||
DownloadManager.stopRangeDownload(gidList)
|
||||
}
|
||||
onClick(Icons.Default.Delete) {
|
||||
val infoList = checkedInfoMap.run { toMap().values.also { clear() } }
|
||||
val infoList = checkedInfoMap.takeAndClear()
|
||||
confirmRemoveDownloadRange(infoList)
|
||||
list.removeAll(infoList)
|
||||
}
|
||||
onClick(Icons.AutoMirrored.Default.DriveFileMove) {
|
||||
val infoList = checkedInfoMap.run { toMap().values.also { clear() } }
|
||||
val infoList = checkedInfoMap.takeAndClear()
|
||||
val toLabel = showMoveDownloadLabelList(infoList)
|
||||
with(filterState) {
|
||||
if (label != "" && label != toLabel) {
|
||||
@@ -633,9 +703,3 @@ fun DownloadsScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object DownloadsFragment {
|
||||
const val KEY_GID = "gid"
|
||||
const val KEY_ACTION = "action"
|
||||
const val ACTION_CLEAR_DOWNLOAD_SERVICE = "clear_download_service"
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.collection.MutableLongSet
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
@@ -26,7 +28,6 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -43,17 +44,16 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.filter
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -66,30 +66,32 @@ import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.filled.GoTo
|
||||
import com.hippo.ehviewer.ui.LocalSideSheetState
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.ui.awaitSelectDate
|
||||
import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.main.FAB_ANIMATE_TIME
|
||||
import com.hippo.ehviewer.ui.main.FabLayout
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryList
|
||||
import com.hippo.ehviewer.ui.showDatePicker
|
||||
import com.hippo.ehviewer.ui.startDownload
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
import com.hippo.ehviewer.ui.tools.foldToLoadResult
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.mapToLongArray
|
||||
import com.hippo.ehviewer.util.takeAndClear
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.onEachLatest
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import moe.tarsin.coroutines.runSwallowingWithUI
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
@@ -97,6 +99,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
// Immutables
|
||||
val localFavName = stringResource(R.string.local_favorites)
|
||||
val cloudFavName = stringResource(R.string.cloud_favorites)
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
// Meta State
|
||||
var urlBuilder by rememberSaveable { mutableStateOf(FavListUrlBuilder(favCat = Settings.recentFavCat)) }
|
||||
@@ -108,7 +111,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val favCatName = remember(urlBuilder) {
|
||||
when (val favCat = urlBuilder.favCat) {
|
||||
in 0..9 -> Settings.favCat[favCat]
|
||||
FavListUrlBuilder.FAV_CAT_LOCAL -> localFavName
|
||||
FavListUrlBuilder.FAV_CAT_LOCAL -> localFavName.also { searchBarOffsetY = 0 }
|
||||
else -> cloudFavName
|
||||
}
|
||||
}
|
||||
@@ -117,7 +120,6 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
} else {
|
||||
stringResource(R.string.favorites_title_2, favCatName, keyword)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val localFavCountFlow = rememberInVM { EhDB.localFavCount }
|
||||
val searchBarHint = stringResource(R.string.search_bar_hint, favCatName)
|
||||
@@ -162,7 +164,14 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
}.flow.map { pagingData ->
|
||||
// https://github.com/FooIbar/EhViewer/issues/1190
|
||||
// Workaround for duplicate items when sorting by favorited time
|
||||
val gidSet = MutableLongSet(50)
|
||||
pagingData.filter {
|
||||
gidSet.add(it.gid)
|
||||
}
|
||||
}.cachedIn(viewModelScope)
|
||||
}.collectAsLazyPagingItems()
|
||||
|
||||
fun refresh(newUrlBuilder: FavListUrlBuilder = urlBuilder.copy(jumpTo = null, prev = null, next = null)) {
|
||||
@@ -211,10 +220,6 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
}
|
||||
}
|
||||
|
||||
val refreshState = rememberPullToRefreshState {
|
||||
data.loadState.refresh is LoadState.NotLoading
|
||||
}
|
||||
|
||||
var fabExpanded by remember { mutableStateOf(false) }
|
||||
var fabHidden by remember { mutableStateOf(false) }
|
||||
val checkedInfoMap = remember { mutableStateMapOf<Long, BaseGalleryInfo>() }
|
||||
@@ -230,7 +235,6 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
fabHidden = true
|
||||
},
|
||||
onSearchHidden = { fabHidden = false },
|
||||
refreshState = refreshState,
|
||||
tagNamespace = !isLocalFav,
|
||||
searchBarOffsetY = { searchBarOffsetY },
|
||||
trailingIcon = {
|
||||
@@ -244,7 +248,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val height by collectListThumbSizeAsState()
|
||||
val showPages by Settings.showGalleryPages.collectAsState()
|
||||
val searchBarConnection = remember {
|
||||
val slop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
val slop = ViewConfiguration.get(implicit<Context>()).scaledTouchSlop
|
||||
val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() }
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
@@ -266,7 +270,10 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
listMode = listMode,
|
||||
detailItemContent = { info ->
|
||||
val checked = info.gid in checkedInfoMap
|
||||
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
|
||||
CheckableItem(
|
||||
checked = checked,
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { interactionSource ->
|
||||
GalleryInfoListItem(
|
||||
onClick = {
|
||||
if (selectMode) {
|
||||
@@ -276,7 +283,7 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
checkedInfoMap[info.gid] = info
|
||||
}
|
||||
} else {
|
||||
navigator.navigate(info.asDst())
|
||||
navigate(info.asDst())
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
@@ -292,7 +299,10 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
},
|
||||
thumbItemContent = { info ->
|
||||
val checked = info.gid in checkedInfoMap
|
||||
CheckableItem(checked = checked, modifier = Modifier.animateItemPlacement()) { interactionSource ->
|
||||
CheckableItem(
|
||||
checked = checked,
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
) { interactionSource ->
|
||||
GalleryInfoGridItem(
|
||||
onClick = {
|
||||
if (selectMode) {
|
||||
@@ -302,18 +312,19 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
checkedInfoMap[info.gid] = info
|
||||
}
|
||||
} else {
|
||||
navigator.navigate(info.asDst())
|
||||
navigate(info.asDst())
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
checkedInfoMap[info.gid] = info
|
||||
},
|
||||
info = info,
|
||||
showPages = showPages,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
}
|
||||
},
|
||||
refreshState = refreshState,
|
||||
searchBarOffsetY = { searchBarOffsetY },
|
||||
scrollToTopOnRefresh = urlBuilder.favCat != FavListUrlBuilder.FAV_CAT_LOCAL,
|
||||
onRefresh = { refresh() },
|
||||
onLoading = { searchBarOffsetY = 0 },
|
||||
@@ -343,16 +354,12 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
if (isLocalFav) {
|
||||
onClick(Icons.Default.Shuffle) {
|
||||
val random = EhDB.randomLocalFav()
|
||||
withUIContext {
|
||||
navigator.navigate(random.asDst())
|
||||
}
|
||||
withUIContext { navigate(random.asDst()) }
|
||||
}
|
||||
}
|
||||
onClick(EhIcons.Default.GoTo) {
|
||||
launch {
|
||||
val date = showDatePicker()
|
||||
refresh(urlBuilder.copy(jumpTo = date))
|
||||
}
|
||||
val date = awaitSelectDate()
|
||||
refresh(urlBuilder.copy(jumpTo = date))
|
||||
}
|
||||
onClick(Icons.Default.Refresh) {
|
||||
refresh()
|
||||
@@ -361,27 +368,31 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
refresh(urlBuilder.copy(jumpTo = null, prev = "1-0", next = null))
|
||||
}
|
||||
} else {
|
||||
onClick(Icons.Default.DoneAll) {
|
||||
onClick(Icons.Default.DoneAll, autoClose = false) {
|
||||
val info = data.itemSnapshotList.items.associateBy { it.gid }
|
||||
checkedInfoMap.putAll(info)
|
||||
throw CancellationException()
|
||||
}
|
||||
onClick(Icons.Default.Download) {
|
||||
val info = checkedInfoMap.run { toMap().values.also { clear() } }
|
||||
startDownload(context, false, *info.toTypedArray())
|
||||
val info = checkedInfoMap.takeAndClear()
|
||||
runSwallowingWithUI {
|
||||
startDownload(implicit<Context>(), false, *info.toTypedArray())
|
||||
}
|
||||
}
|
||||
onClick(Icons.Default.Delete) {
|
||||
val info = checkedInfoMap.run { toMap().values.also { clear() } }
|
||||
val info = checkedInfoMap.takeAndClear()
|
||||
awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) {
|
||||
Text(text = stringResource(R.string.delete_favorites_dialog_message, info.size))
|
||||
}
|
||||
val srcCat = urlBuilder.favCat
|
||||
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav
|
||||
EhDB.removeLocalFavorites(info)
|
||||
} else {
|
||||
val delList = info.mapToLongArray(BaseGalleryInfo::gid)
|
||||
EhEngine.modifyFavorites(delList, srcCat, -1)
|
||||
runSwallowingWithUI {
|
||||
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav
|
||||
EhDB.removeLocalFavorites(info)
|
||||
} else {
|
||||
val delList = info.mapToLongArray(BaseGalleryInfo::gid)
|
||||
EhEngine.modifyFavorites(delList, srcCat, -1)
|
||||
}
|
||||
}
|
||||
// We refresh anyway as cloud data maybe partially modified
|
||||
data.refresh()
|
||||
}
|
||||
onClick(Icons.AutoMirrored.Default.DriveFileMove) {
|
||||
@@ -392,12 +403,12 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
addAll(Settings.favCat)
|
||||
}
|
||||
}
|
||||
val index = showSelectItem(items, R.string.move_favorites_dialog_title)
|
||||
val index = awaitSelectItem(items, R.string.move_favorites_dialog_title)
|
||||
val srcCat = urlBuilder.favCat
|
||||
val dstCat = if (index == 0) FavListUrlBuilder.FAV_CAT_LOCAL else index - 1
|
||||
val info = checkedInfoMap.run { toMap().values.also { clear() } }
|
||||
val info = checkedInfoMap.takeAndClear()
|
||||
if (srcCat != dstCat) {
|
||||
runSuspendCatching {
|
||||
runSwallowingWithUI {
|
||||
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
|
||||
// Move from local to cloud
|
||||
val galleryList = info.map { it.gid to it.token }
|
||||
@@ -411,11 +422,9 @@ fun FavouritesScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val gidArray = info.mapToLongArray(BaseGalleryInfo::gid)
|
||||
EhEngine.modifyFavorites(gidArray, srcCat, dstCat)
|
||||
}
|
||||
}.onSuccess {
|
||||
data.refresh()
|
||||
}.onFailure {
|
||||
showSnackbar(it.displayString())
|
||||
}
|
||||
// We refresh anyway as cloud data maybe partially modified
|
||||
data.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -41,8 +40,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -64,8 +62,9 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalTextToolbar
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.parseAsHtml
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
@@ -74,12 +73,14 @@ import androidx.core.text.inSpans
|
||||
import androidx.core.text.parseAsHtml
|
||||
import com.hippo.ehviewer.EhApplication
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhFilter.remember
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.data.GalleryComment
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
@@ -87,11 +88,13 @@ import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.jumpToReaderByPage
|
||||
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
|
||||
import com.hippo.ehviewer.ui.main.GalleryCommentCard
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
|
||||
import com.hippo.ehviewer.ui.tools.normalizeSpan
|
||||
import com.hippo.ehviewer.ui.tools.rememberBBCodeTextToolbar
|
||||
import com.hippo.ehviewer.ui.tools.snackBarPadding
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.ui.tools.toBBCode
|
||||
import com.hippo.ehviewer.ui.tools.updateSpan
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
@@ -102,7 +105,6 @@ import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlin.math.roundToInt
|
||||
@@ -152,6 +154,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
var commenting by rememberSaveable { mutableStateOf(false) }
|
||||
val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
val galleryDetail = remember { EhApplication.galleryDetailCache[gid]!! }
|
||||
val userCommentBackField = remember { mutableStateOf(TextFieldValue()) }
|
||||
@@ -274,20 +277,28 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
) { paddingValues ->
|
||||
val keylineMargin = dimensionResource(id = R.dimen.keyline_margin)
|
||||
var editTextMeasured by remember { mutableStateOf(MinimumContentPaddingEditText) }
|
||||
val refreshState = rememberPullToRefreshState()
|
||||
if (refreshState.isRefreshing) {
|
||||
LaunchedEffect(Unit) {
|
||||
runSuspendCatching {
|
||||
withIOContext { refreshComment(true) }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
launchIO {
|
||||
runSuspendCatching {
|
||||
refreshComment(true)
|
||||
}
|
||||
isRefreshing = false
|
||||
}
|
||||
refreshState.endRefresh()
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize().imePadding().nestedScroll(refreshState.nestedScrollConnection)) {
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().imePadding(),
|
||||
) {
|
||||
val additionalPadding = if (commenting) {
|
||||
editTextMeasured
|
||||
} else {
|
||||
0.dp
|
||||
if (!comments.hasMore) {
|
||||
MinimumContentPaddingEditText
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
}
|
||||
val voteUpSucceed = stringResource(R.string.vote_up_successfully)
|
||||
val cancelVoteUpSucceed = stringResource(R.string.cancel_vote_up_successfully)
|
||||
@@ -297,10 +308,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = keylineMargin),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding() + additionalPadding,
|
||||
),
|
||||
contentPadding = paddingValues + PaddingValues(bottom = additionalPadding),
|
||||
) {
|
||||
items(
|
||||
items = comments.comments,
|
||||
@@ -324,7 +332,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun doCommentAction(comment: GalleryComment) = showSelectActions {
|
||||
suspend fun doCommentAction(comment: GalleryComment) = awaitSelectAction {
|
||||
onSelect(copyComment) {
|
||||
addTextToClipboard(comment.comment.parseAsHtml())
|
||||
}
|
||||
@@ -333,7 +341,7 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
}
|
||||
if (comment.editable) {
|
||||
onSelect(editComment) {
|
||||
userComment = TextFieldValue(comment.comment.parseAsHtml())
|
||||
userComment = TextFieldValue(AnnotatedString.fromHtml(comment.comment))
|
||||
commentId = comment.id
|
||||
commenting = true
|
||||
}
|
||||
@@ -353,10 +361,10 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
showCommentVoteStatus(comment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
GalleryCommentCard(
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
comment = item,
|
||||
onUserClick = {
|
||||
navigate(
|
||||
@@ -398,18 +406,8 @@ fun GalleryCommentsScreen(gid: Long, navigator: DestinationsNavigator) = composi
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Workaround for comment list lagging when reaching the bottom
|
||||
// https://github.com/FooIbar/EhViewer/issues/994
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(MinimumContentPaddingEditText))
|
||||
}
|
||||
}
|
||||
}
|
||||
PullToRefreshContainer(
|
||||
state = refreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = paddingValues.calculateTopPadding()),
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).layout { measurable, constraints ->
|
||||
val origin = measurable.measure(constraints)
|
||||
|
||||
@@ -56,8 +56,6 @@ import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -78,8 +76,9 @@ import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.text.parseAsHtml
|
||||
import arrow.core.left
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
import arrow.core.partially1
|
||||
import coil3.imageLoader
|
||||
import com.hippo.ehviewer.EhApplication.Companion.galleryDetailCache
|
||||
@@ -105,10 +104,6 @@ import com.hippo.ehviewer.client.data.findBaseInfo
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.NoHAtHClientException
|
||||
import com.hippo.ehviewer.client.getImageKey
|
||||
import com.hippo.ehviewer.client.parser.ArchiveParser
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
import com.hippo.ehviewer.client.parser.TorrentResult
|
||||
import com.hippo.ehviewer.client.parser.format
|
||||
import com.hippo.ehviewer.coil.justDownload
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
@@ -127,11 +122,13 @@ import com.hippo.ehviewer.ui.destinations.GalleryPreviewScreenDestination
|
||||
import com.hippo.ehviewer.ui.getFavoriteIcon
|
||||
import com.hippo.ehviewer.ui.jumpToReaderByPage
|
||||
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
|
||||
import com.hippo.ehviewer.ui.main.ArchiveList
|
||||
import com.hippo.ehviewer.ui.main.EhPreviewItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryCommentCard
|
||||
import com.hippo.ehviewer.ui.main.GalleryDetailErrorTip
|
||||
import com.hippo.ehviewer.ui.main.GalleryDetailHeaderCard
|
||||
import com.hippo.ehviewer.ui.main.GalleryTags
|
||||
import com.hippo.ehviewer.ui.main.TorrentList
|
||||
import com.hippo.ehviewer.ui.modifyFavorites
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
@@ -141,6 +138,7 @@ import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton
|
||||
import com.hippo.ehviewer.ui.tools.FilledTertiaryIconToggleButton
|
||||
import com.hippo.ehviewer.ui.tools.GalleryDetailRating
|
||||
import com.hippo.ehviewer.ui.tools.GalleryRatingBar
|
||||
import com.hippo.ehviewer.ui.tools.LocalWindowSizeClass
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.rememberLambda
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
@@ -164,6 +162,9 @@ import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
@@ -293,13 +294,13 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
}
|
||||
|
||||
var mArchiveFormParamOr by remember { mutableStateOf<String?>(null) }
|
||||
var mArchiveList by remember { mutableStateOf<List<ArchiveParser.Archive>?>(null) }
|
||||
var mCurrentFunds by remember { mutableStateOf<HomeParser.Funds?>(null) }
|
||||
val archiveResult = remember(galleryInfo) {
|
||||
async(Dispatchers.IO, CoroutineStart.LAZY) {
|
||||
val detail = galleryInfo as GalleryDetail
|
||||
EhEngine.getArchiveList(detail.archiveUrl!!, gid, token)
|
||||
}
|
||||
}
|
||||
|
||||
val archiveFree = stringResource(R.string.archive_free)
|
||||
val archiveOriginal = stringResource(R.string.archive_original)
|
||||
val archiveResample = stringResource(R.string.archive_resample)
|
||||
val failureNoHath = stringResource(R.string.download_archive_failure_no_hath)
|
||||
val noArchive = stringResource(R.string.no_archives)
|
||||
val downloadStarted = stringResource(R.string.download_archive_started)
|
||||
@@ -311,42 +312,19 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
if (galleryDetail.apiUid < 0) {
|
||||
showSnackbar(signInFirst)
|
||||
} else {
|
||||
Unit.runSuspendCatching {
|
||||
if (mArchiveList == null) {
|
||||
val result = bgWork {
|
||||
withIOContext {
|
||||
EhEngine.getArchiveList(galleryDetail.archiveUrl!!, gid, token)
|
||||
}
|
||||
}
|
||||
mArchiveFormParamOr = result.paramOr
|
||||
mArchiveList = result.archiveList
|
||||
mCurrentFunds = result.funds
|
||||
}
|
||||
if (mArchiveList!!.isEmpty()) {
|
||||
runSuspendCatching {
|
||||
val (paramOr, archiveList, funds) = bgWork { archiveResult.await() }
|
||||
if (archiveList.isEmpty()) {
|
||||
showSnackbar(noArchive)
|
||||
} else {
|
||||
val items = mArchiveList!!.map {
|
||||
it.run {
|
||||
if (isHAtH) {
|
||||
val costStr = if (cost == "Free") archiveFree else cost
|
||||
"[H@H] $name [$size] [$costStr]"
|
||||
} else {
|
||||
val nameStr = if (res == "org") archiveOriginal else archiveResample
|
||||
val costStr = if (cost == "Free!") archiveFree else cost
|
||||
"$nameStr [$size] [$costStr]"
|
||||
}
|
||||
}
|
||||
val selected = showNoButton {
|
||||
ArchiveList(
|
||||
funds = funds,
|
||||
items = archiveList,
|
||||
onItemClick = { dismissWith(it) },
|
||||
)
|
||||
}
|
||||
var fundsGP = mCurrentFunds!!.fundsGP.toString()
|
||||
// Ex GP numbers are rounded down to the nearest thousand
|
||||
if (EhUtils.isExHentai) {
|
||||
fundsGP += "+"
|
||||
}
|
||||
val title = getString(R.string.current_funds, fundsGP, mCurrentFunds!!.fundsC)
|
||||
val selected = showSelectItem(items, title.left())
|
||||
val res = mArchiveList!![selected].res
|
||||
val isHAtH = mArchiveList!![selected].isHAtH
|
||||
EhEngine.downloadArchive(gid, token, mArchiveFormParamOr!!, res, isHAtH)?.let {
|
||||
EhEngine.downloadArchive(gid, token, paramOr, selected.res, selected.isHAtH)?.let {
|
||||
val uri = Uri.parse(it)
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
@@ -477,7 +455,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
val items = galleryDetail.newerVersions.map {
|
||||
getString(R.string.newer_version_title, it.title, it.posted)
|
||||
}
|
||||
val selected = showSelectItem(items)
|
||||
val selected = awaitSelectItem(items)
|
||||
val info = galleryDetail.newerVersions[selected]
|
||||
withUIContext {
|
||||
// Can't use GalleryInfoArgs as thumbKey is null
|
||||
@@ -583,29 +561,30 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
)
|
||||
},
|
||||
)
|
||||
val torrentText = stringResource(R.string.torrents)
|
||||
val torrentText = stringResource(R.string.torrent_count, galleryDetail.torrentCount)
|
||||
val permissionDenied = stringResource(R.string.permission_denied)
|
||||
val downloadTorrentFailed = stringResource(R.string.download_torrent_failure)
|
||||
val downloadTorrentStarted = stringResource(R.string.download_torrent_started)
|
||||
val noTorrents = stringResource(R.string.no_torrents)
|
||||
val noCurrentTorrents = stringResource(R.string.no_current_torrents)
|
||||
var mTorrentList by remember { mutableStateOf<TorrentResult?>(null) }
|
||||
suspend fun showTorrentDialog() {
|
||||
if (mTorrentList == null) {
|
||||
mTorrentList = bgWork {
|
||||
withIOContext {
|
||||
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, gid, token)
|
||||
}
|
||||
}
|
||||
val torrentResult = remember(galleryDetail) {
|
||||
async(Dispatchers.IO, CoroutineStart.LAZY) {
|
||||
EhEngine.getTorrentList(galleryDetail.torrentUrl!!, gid, token)
|
||||
}
|
||||
if (mTorrentList!!.isEmpty()) {
|
||||
showSnackbar(noCurrentTorrents)
|
||||
}
|
||||
suspend fun showTorrentDialog() {
|
||||
val torrentList = bgWork { torrentResult.await() }
|
||||
if (torrentList.isEmpty()) {
|
||||
showSnackbar(noTorrents)
|
||||
} else {
|
||||
val items = mTorrentList!!.map { it.format() }
|
||||
val selected = showSelectItem(items, R.string.torrents, false)
|
||||
val url = mTorrentList!![selected].url
|
||||
val name = "${mTorrentList!![selected].name}.torrent"
|
||||
val r = DownloadManager.Request(Uri.parse(url))
|
||||
val selected = showNoButton(false) {
|
||||
TorrentList(
|
||||
items = torrentList,
|
||||
onItemClick = { dismissWith(it) },
|
||||
)
|
||||
}
|
||||
val url = selected.url
|
||||
val name = "${selected.name}.torrent"
|
||||
val r = DownloadManager.Request(url.toUri())
|
||||
r.setDestinationInExternalPublicDir(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
AppConfig.APP_DIRNAME + "/" + FileUtils.sanitizeFilename(name),
|
||||
@@ -733,7 +712,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
tag
|
||||
}
|
||||
launchIO {
|
||||
showSelectActions {
|
||||
awaitSelectAction {
|
||||
with(activity) {
|
||||
onSelect(copy) {
|
||||
addTextToClipboard(tag)
|
||||
@@ -756,7 +735,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
onSelect(downTag) { galleryDetail.voteTag(tag, -1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -775,7 +754,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val galleryDetail = galleryInfo.asGalleryDetail()
|
||||
val windowSizeClass = calculateWindowSizeClass(activity)
|
||||
val windowSizeClass = LocalWindowSizeClass.current
|
||||
val thumbColumns by Settings.thumbColumns.collectAsState()
|
||||
val readText = stringResource(R.string.read)
|
||||
val startPage by rememberInVM {
|
||||
@@ -822,7 +801,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
if (showBottomSheet && galleryDetail != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
contentWindowInsets = { WindowInsets(0, 0, 0, 0) },
|
||||
) {
|
||||
GalleryInfoBottomSheet(galleryDetail, navigator)
|
||||
}
|
||||
@@ -856,8 +835,8 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
}
|
||||
|
||||
when (windowSizeClass.widthSizeClass) {
|
||||
WindowWidthSizeClass.Medium, WindowWidthSizeClass.Compact -> LazyVerticalGrid(
|
||||
when (windowSizeClass.windowWidthSizeClass) {
|
||||
WindowWidthSizeClass.MEDIUM, WindowWidthSizeClass.COMPACT -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(thumbColumns),
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier.padding(horizontal = keylineMargin),
|
||||
@@ -908,7 +887,7 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: DestinationsNa
|
||||
}
|
||||
}
|
||||
|
||||
WindowWidthSizeClass.Expanded -> LazyVerticalGrid(
|
||||
WindowWidthSizeClass.EXPANDED -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(thumbColumns),
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier.padding(horizontal = keylineMargin),
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
@@ -63,7 +64,7 @@ fun collectListThumbSizeAsState(): State<Dp> {
|
||||
val density = LocalDensity.current
|
||||
return Settings.listThumbSize.collectAsState {
|
||||
with(density) {
|
||||
it.toDp() * 9
|
||||
(it.toDp() * 9).coerceAtLeast(120.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -17,25 +18,22 @@ import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Help
|
||||
import androidx.compose.material.icons.automirrored.filled.LastPage
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Reorder
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shuffle
|
||||
import androidx.compose.material.icons.outlined.Bookmarks
|
||||
@@ -47,11 +45,11 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBoxDefaults
|
||||
import androidx.compose.material3.SwipeToDismissBoxState
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.fork.SwipeToDismissBox
|
||||
import androidx.compose.material3.fork.SwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
@@ -74,7 +72,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -84,7 +81,6 @@ import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
@@ -114,6 +110,7 @@ import com.hippo.ehviewer.dao.QuickSearch
|
||||
import com.hippo.ehviewer.icons.EhIcons
|
||||
import com.hippo.ehviewer.icons.filled.GoTo
|
||||
import com.hippo.ehviewer.ui.LocalSideSheetState
|
||||
import com.hippo.ehviewer.ui.awaitSelectDate
|
||||
import com.hippo.ehviewer.ui.composing
|
||||
import com.hippo.ehviewer.ui.destinations.ProgressScreenDestination
|
||||
import com.hippo.ehviewer.ui.doGalleryInfoAction
|
||||
@@ -125,15 +122,14 @@ import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryList
|
||||
import com.hippo.ehviewer.ui.main.ImageSearch
|
||||
import com.hippo.ehviewer.ui.main.SearchFilter
|
||||
import com.hippo.ehviewer.ui.showDatePicker
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.DragHandle
|
||||
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
|
||||
import com.hippo.ehviewer.ui.tools.HapticFeedbackType
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
|
||||
import com.hippo.ehviewer.ui.tools.delegateSnapshotUpdate
|
||||
import com.hippo.ehviewer.ui.tools.draggingHapticFeedback
|
||||
import com.hippo.ehviewer.ui.tools.foldToLoadResult
|
||||
import com.hippo.ehviewer.ui.tools.rememberHapticFeedback
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.rememberMutableStateInDataStore
|
||||
import com.hippo.ehviewer.ui.tools.snackBarPadding
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.pickVisualMedia
|
||||
@@ -153,11 +149,14 @@ import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.onEachLatest
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyColumnState
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
fun HomePageScreen(navigator: DestinationsNavigator) = GalleryListScreen(ListUrlBuilder(), navigator)
|
||||
fun HomePageScreen(navigator: DestinationsNavigator) {
|
||||
val displayName by Settings.displayName.collectAsState()
|
||||
GalleryListScreen(ListUrlBuilder(category = displayName?.let { EhUtils.NONE } ?: EhUtils.NON_H), navigator)
|
||||
}
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
@@ -179,13 +178,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
var searchBarOffsetY by remember { mutableIntStateOf(0) }
|
||||
var showSearchLayout by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var category by rememberSaveable { mutableIntStateOf(Settings.searchCategory) }
|
||||
var searchMethod by rememberSaveable { mutableIntStateOf(1) }
|
||||
var advancedSearchOption by rememberSaveable { mutableStateOf(AdvancedSearchOption()) }
|
||||
var category by rememberMutableStateInDataStore("SearchCategory") { EhUtils.ALL_CATEGORY }
|
||||
var advancedSearchOption by rememberMutableStateInDataStore("AdvancedSearchOption") { AdvancedSearchOption() }
|
||||
var imageUri by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
|
||||
LaunchedEffect(urlBuilder) {
|
||||
if (urlBuilder.mode == MODE_SUBSCRIPTION) searchMethod = 2
|
||||
if (urlBuilder.category != EhUtils.NONE) category = urlBuilder.category
|
||||
if (urlBuilder.mode != MODE_TOPLIST) {
|
||||
var keyword = urlBuilder.keyword.orEmpty()
|
||||
@@ -368,14 +365,14 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
},
|
||||
windowInsets = WindowInsets(0),
|
||||
)
|
||||
val view = LocalView.current
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val quickSearchListState = rememberLazyListState()
|
||||
val reorderableLazyListState = rememberReorderableLazyColumnState(quickSearchListState) { from, to ->
|
||||
val hapticFeedback = rememberHapticFeedback()
|
||||
val reorderableLazyListState = rememberReorderableLazyListState(quickSearchListState) { from, to ->
|
||||
val fromIndex = from.index - 1
|
||||
val toIndex = to.index - 1
|
||||
quickSearchList.apply { add(toIndex, removeAt(fromIndex)) }
|
||||
view.performHapticFeedback(draggingHapticFeedback)
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.MOVE)
|
||||
}
|
||||
var fromIndex by remember { mutableIntStateOf(-1) }
|
||||
LazyColumn(
|
||||
@@ -413,9 +410,10 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
}
|
||||
}
|
||||
}
|
||||
SwipeToDismissBox2(
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {},
|
||||
enableDismissFromStartToEnd = false,
|
||||
) {
|
||||
val elevation by animateDpAsState(
|
||||
if (isDragging) {
|
||||
@@ -443,22 +441,29 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
Text(text = item.name)
|
||||
},
|
||||
trailingContent = {
|
||||
DragHandle(
|
||||
onDragStarted = {
|
||||
fromIndex = index
|
||||
},
|
||||
onDragStopped = {
|
||||
if (fromIndex != -1) {
|
||||
if (fromIndex != index) {
|
||||
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
|
||||
val toUpdate = quickSearchList.slice(range)
|
||||
toUpdate.zip(range).forEach { it.first.position = it.second }
|
||||
launchIO { EhDB.updateQuickSearch(toUpdate) }
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.draggableHandle(
|
||||
onDragStarted = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.START)
|
||||
fromIndex = index
|
||||
},
|
||||
onDragStopped = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.END)
|
||||
if (fromIndex != -1) {
|
||||
if (fromIndex != index) {
|
||||
val range = if (fromIndex < index) fromIndex..index else index..fromIndex
|
||||
val toUpdate = quickSearchList.slice(range)
|
||||
toUpdate.zip(range).forEach { it.first.position = it.second }
|
||||
launchIO { EhDB.updateQuickSearch(toUpdate) }
|
||||
}
|
||||
fromIndex = -1
|
||||
}
|
||||
fromIndex = -1
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Reorder, contentDescription = null)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -477,10 +482,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
}
|
||||
}
|
||||
|
||||
val refreshState = rememberPullToRefreshState {
|
||||
data.loadState.refresh is LoadState.NotLoading
|
||||
}
|
||||
|
||||
var fabExpanded by remember { mutableStateOf(false) }
|
||||
var fabHidden by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -546,7 +547,6 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
onApplySearch = ::onApplySearch,
|
||||
onSearchExpanded = { fabHidden = true },
|
||||
onSearchHidden = { fabHidden = false },
|
||||
refreshState = refreshState,
|
||||
suggestionProvider = {
|
||||
GalleryDetailUrlParser.parse(it, false)?.run {
|
||||
GalleryDetailUrlSuggestion(gid, token)
|
||||
@@ -574,12 +574,11 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
filter = {
|
||||
SearchFilter(
|
||||
category = category,
|
||||
onCategoryChanged = {
|
||||
Settings.searchCategory = it
|
||||
category = it
|
||||
},
|
||||
onCategoryChanged = { category = it },
|
||||
advancedOption = advancedSearchOption,
|
||||
onAdvancedOptionChanged = { advancedSearchOption = it },
|
||||
onAdvancedOptionChanged = {
|
||||
advancedSearchOption = it
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -606,50 +605,44 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
|
||||
val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v)
|
||||
Column(
|
||||
modifier = Modifier.imePadding().verticalScroll(rememberScrollState())
|
||||
.padding(
|
||||
top = contentPadding.calculateTopPadding() + marginV,
|
||||
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
|
||||
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
|
||||
bottom = 8.dp,
|
||||
)
|
||||
.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom))
|
||||
.graphicsLayer {
|
||||
scaleX = 1 - animatedSearchLayout
|
||||
scaleY = 1 - animatedSearchLayout
|
||||
alpha = 1 - animatedSearchLayout
|
||||
},
|
||||
ElevatedCard(
|
||||
modifier = Modifier.graphicsLayer {
|
||||
scaleX = 1 - animatedSearchLayout
|
||||
scaleY = 1 - animatedSearchLayout
|
||||
alpha = 1 - animatedSearchLayout
|
||||
}.padding(
|
||||
top = contentPadding.calculateTopPadding() + marginV,
|
||||
start = contentPadding.calculateStartPadding(layoutDirection) + marginH,
|
||||
end = contentPadding.calculateEndPadding(layoutDirection) + marginH,
|
||||
bottom = 8.dp,
|
||||
).padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v)),
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
|
||||
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
|
||||
).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_image),
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
ImageSearch(
|
||||
image = imageUri,
|
||||
onSelectImage = {
|
||||
launch { imageUri = pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly) }
|
||||
},
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(id = R.dimen.search_category_padding_h),
|
||||
vertical = dimensionResource(id = R.dimen.search_category_padding_v),
|
||||
).fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.search_image),
|
||||
modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
ImageSearch(
|
||||
image = imageUri,
|
||||
onSelectImage = {
|
||||
launch { imageUri = pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val height by collectListThumbSizeAsState()
|
||||
val showPages by Settings.showGalleryPages.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val searchBarConnection = remember {
|
||||
val slop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
val slop = ViewConfiguration.get(implicit<Context>()).scaledTouchSlop
|
||||
val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() }
|
||||
object : NestedScrollConnection {
|
||||
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||
@@ -690,9 +683,10 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
onClick = { navigate(info.asDst()) },
|
||||
onLongClick = { launch { doGalleryInfoAction(info) } },
|
||||
info = info,
|
||||
showPages = showPages,
|
||||
)
|
||||
},
|
||||
refreshState = refreshState,
|
||||
searchBarOffsetY = { searchBarOffsetY },
|
||||
onRefresh = {
|
||||
urlBuilder.setRange(0)
|
||||
data.refresh()
|
||||
@@ -737,32 +731,26 @@ fun GalleryListScreen(lub: ListUrlBuilder, navigator: DestinationsNavigator) = c
|
||||
val page = urlBuilder.mJumpTo?.toIntOrNull() ?: 0
|
||||
val hint = getString(R.string.go_to_hint, page + 1, TOPLIST_PAGES)
|
||||
val text = awaitInputText(title = gotoTitle, hint = hint, isNumber = true) { oriText ->
|
||||
val text = oriText.trim()
|
||||
val goTo = runCatching {
|
||||
text.toInt() - 1
|
||||
}.onFailure {
|
||||
return@awaitInputText invalidNum
|
||||
}.getOrThrow()
|
||||
if (goTo !in 0..<TOPLIST_PAGES) outOfRange else null
|
||||
when (oriText.trim().toIntOrNull()?.let { it - 1 }) {
|
||||
null -> invalidNum
|
||||
!in 0..<TOPLIST_PAGES -> outOfRange
|
||||
else -> null
|
||||
}
|
||||
}.trim().toInt() - 1
|
||||
urlBuilder.setJumpTo(text)
|
||||
data.refresh()
|
||||
} else {
|
||||
launch {
|
||||
val date = showDatePicker()
|
||||
urlBuilder.mJumpTo = date
|
||||
data.refresh()
|
||||
}
|
||||
val date = awaitSelectDate()
|
||||
urlBuilder.mJumpTo = date
|
||||
}
|
||||
data.refresh()
|
||||
}
|
||||
onClick(Icons.AutoMirrored.Default.LastPage) {
|
||||
if (isTopList) {
|
||||
urlBuilder.setJumpTo(TOPLIST_PAGES - 1)
|
||||
data.refresh()
|
||||
} else {
|
||||
urlBuilder.setIndex("1", false)
|
||||
data.refresh()
|
||||
}
|
||||
data.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.material3.fork.SwipeToDismissBox
|
||||
import androidx.compose.material3.fork.rememberSwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -52,8 +53,8 @@ import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.main.plus
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.SwipeToDismissBox2
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
@@ -67,6 +68,7 @@ import kotlinx.coroutines.launch
|
||||
fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
val title = stringResource(id = R.string.history)
|
||||
val hint = stringResource(R.string.search_bar_hint, title)
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
var searchBarOffsetY by remember { mutableIntStateOf(0) }
|
||||
var keyword by rememberSaveable { mutableStateOf("") }
|
||||
@@ -139,10 +141,11 @@ fun HistoryScreen(navigator: DestinationsNavigator) = composing(navigator) {
|
||||
true
|
||||
},
|
||||
)
|
||||
SwipeToDismissBox2(
|
||||
SwipeToDismissBox(
|
||||
state = dismissState,
|
||||
backgroundContent = {},
|
||||
modifier = Modifier.animateItem(),
|
||||
modifier = Modifier.thenIf(animateItems) { animateItem() },
|
||||
enableDismissFromStartToEnd = false,
|
||||
) {
|
||||
GalleryInfoListItem(
|
||||
onClick = { navigate(info.asDst()) },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.navigation.NavController
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
|
||||
@@ -34,19 +33,9 @@ fun DestinationsNavigator.navWithUrl(url: String): Boolean {
|
||||
|
||||
@MainThread
|
||||
fun DestinationsNavigator.popNavigate(direction: Direction) = navigate(direction) {
|
||||
popUpTo(NavGraphs.root.route)
|
||||
popUpTo(NavGraphs.root)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun NavController.navWithUrl(url: String): Boolean {
|
||||
val dest = urlToDestination(url) ?: return false
|
||||
navigateTo(dest)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun NavController.navigateTo(direction: Direction) = navigate(direction.route)
|
||||
|
||||
fun BaseGalleryInfo.asDst() = GalleryDetailScreenDestination(GalleryInfoArgs(this))
|
||||
|
||||
infix fun Long.asDstWith(token: String) = GalleryDetailScreenDestination(TokenArgs(this, token))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.hippo.ehviewer.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -8,17 +10,14 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -42,8 +41,6 @@ import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.SearchBarHorizontalPadding
|
||||
import androidx.compose.material3.SearchBarInputField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -66,12 +63,14 @@ import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.data.TagNamespace
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.Search
|
||||
import com.hippo.ehviewer.dao.SearchDao
|
||||
import com.hippo.ehviewer.ui.LocalNavDrawerState
|
||||
import com.hippo.ehviewer.ui.LockDrawer
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.rememberCompositionActiveState
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.jamal.composeprefs3.ui.ifNotNullThen
|
||||
import com.jamal.composeprefs3.ui.ifTrueThen
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
@@ -96,7 +95,7 @@ abstract class Suggestion {
|
||||
}
|
||||
|
||||
suspend fun SearchDao.suggestions(prefix: String, limit: Int) =
|
||||
(if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit)).map { it.query }
|
||||
(if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit))
|
||||
|
||||
@Composable
|
||||
fun SearchBarScreen(
|
||||
@@ -106,7 +105,6 @@ fun SearchBarScreen(
|
||||
onApplySearch: (String) -> Unit,
|
||||
onSearchExpanded: () -> Unit,
|
||||
onSearchHidden: () -> Unit,
|
||||
refreshState: PullToRefreshState? = null,
|
||||
suggestionProvider: SuggestionProvider? = null,
|
||||
tagNamespace: Boolean = false,
|
||||
searchBarOffsetY: () -> Int,
|
||||
@@ -121,6 +119,7 @@ fun SearchBarScreen(
|
||||
val scope = rememberCoroutineScope { Dispatchers.IO }
|
||||
val context = LocalContext.current
|
||||
val dialogState = LocalDialogState.current
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
class TagSuggestion(
|
||||
override val hint: String?,
|
||||
@@ -217,31 +216,18 @@ fun SearchBarScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val scrollAwayModifier = if (!expanded) {
|
||||
Modifier.offset { IntOffset(0, searchBarOffsetY()) }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
// Placeholder, fill immutable SearchBar padding
|
||||
Spacer(
|
||||
modifier = Modifier.statusBarsPadding().displayCutoutPadding()
|
||||
.height(SearchBarDefaults.InputFieldHeight + 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.statusBarsPadding().height(SearchBarDefaults.InputFieldHeight + 16.dp))
|
||||
},
|
||||
floatingActionButton = floatingActionButton,
|
||||
content = content,
|
||||
)
|
||||
if (refreshState != null) {
|
||||
PullToRefreshContainer(
|
||||
state = refreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter).safeDrawingPadding()
|
||||
.padding(top = 48.dp) then scrollAwayModifier,
|
||||
)
|
||||
}
|
||||
// https://issuetracker.google.com/337191298
|
||||
// Workaround for can't exit SearchBar due to refocus in non-touch mode
|
||||
Box(Modifier.size(1.dp).focusable())
|
||||
val onExpandedChange = { v: Boolean ->
|
||||
if (v) {
|
||||
onSearchViewExpanded()
|
||||
@@ -252,7 +238,7 @@ fun SearchBarScreen(
|
||||
}
|
||||
val activeState = rememberCompositionActiveState()
|
||||
SearchBar(
|
||||
modifier = Modifier.align(Alignment.TopCenter) then scrollAwayModifier
|
||||
modifier = Modifier.align(Alignment.TopCenter).thenIf(!expanded) { offset { IntOffset(0, searchBarOffsetY()) } }
|
||||
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)),
|
||||
inputField = {
|
||||
SearchBarInputField(
|
||||
@@ -305,8 +291,7 @@ fun SearchBarScreen(
|
||||
filter?.invoke()
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime)
|
||||
.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom).asPaddingValues(),
|
||||
) {
|
||||
// Workaround for prepending before the first item
|
||||
item {}
|
||||
@@ -329,7 +314,7 @@ fun SearchBarScreen(
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { it.onClick() }.animateItem(),
|
||||
modifier = Modifier.clickable { it.onClick() }.thenIf(animateItems) { animateItem() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.parseAsHtml
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.ui.destinations.LicenseScreenDestination
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
@@ -38,6 +41,7 @@ import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.updater.AppUpdater
|
||||
import com.hippo.ehviewer.updater.Release
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.ehviewer.util.installPackage
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
@@ -47,6 +51,7 @@ import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
private const val REPO_URL = "https://github.com/${BuildConfig.REPO_NAME}"
|
||||
@@ -54,11 +59,11 @@ private const val RELEASE_URL = "$REPO_URL/releases"
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
private fun versionCode() = "${BuildConfig.VERSION_NAME} (${BuildConfig.COMMIT_SHA})\n" + stringResource(R.string.settings_about_build_time, BuildConfig.BUILD_TIME)
|
||||
private fun versionCode() = "${BuildConfig.VERSION_NAME} (${BuildConfig.COMMIT_SHA})\n" + stringResource(R.string.settings_about_build_time, AppConfig.buildTime)
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
private fun author() = stringResource(R.string.settings_about_author_summary).replace('$', '@').parseAsHtml()
|
||||
private fun author() = AnnotatedString.fromHtml(stringResource(R.string.settings_about_author_summary).replace('$', '@'))
|
||||
|
||||
@Destination<RootGraph>
|
||||
@Composable
|
||||
@@ -107,6 +112,10 @@ fun AboutScreen(navigator: DestinationsNavigator) {
|
||||
title = stringResource(id = R.string.settings_about_version),
|
||||
summary = versionCode(),
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.backup_before_update),
|
||||
value = Settings::backupBeforeUpdate,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.use_ci_update_channel),
|
||||
value = Settings::useCIUpdateChannel,
|
||||
@@ -144,6 +153,12 @@ suspend fun DialogState.showNewVersion(context: Context, release: Release) {
|
||||
Text(text = release.changelog)
|
||||
}
|
||||
}
|
||||
if (Settings.backupBeforeUpdate) {
|
||||
val time = ReadableTime.getFilenamableTime(Clock.System.now().toEpochMilliseconds())
|
||||
downloadLocation.createFile("$time.db")?.let {
|
||||
EhDB.exportDB(context, it)
|
||||
}
|
||||
}
|
||||
// TODO: Download in the background and show progress in notification
|
||||
val file = File(AppConfig.tempDir, "update.apk").apply { delete() }
|
||||
AppUpdater.downloadUpdate(release.downloadLink, file)
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -33,6 +34,7 @@ import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
import com.hippo.ehviewer.client.CHROME_USER_AGENT
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
@@ -42,6 +44,12 @@ import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.getAppLanguage
|
||||
import com.hippo.ehviewer.util.getLanguages
|
||||
import com.hippo.ehviewer.util.setAppLanguage
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.jamal.composeprefs3.ui.prefs.DropDownPref
|
||||
import com.jamal.composeprefs3.ui.prefs.SwitchPref
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
@@ -78,10 +86,12 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).verticalScroll(rememberScrollState()).padding(paddingValues)) {
|
||||
// SwitchPreference(
|
||||
// title = "New Compose Reader [WIP!!!]",
|
||||
// value = Settings::newReader,
|
||||
// )
|
||||
if (AppConfig.isSnapshot) {
|
||||
SwitchPreference(
|
||||
title = "New Compose Reader [WIP!!!]",
|
||||
value = Settings::newReader,
|
||||
)
|
||||
}
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.settings_advanced_save_parse_error_body),
|
||||
summary = stringResource(id = R.string.settings_advanced_save_parse_error_body_summary),
|
||||
@@ -132,22 +142,28 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
entryValueRes = R.array.read_cache_size_entry_values,
|
||||
value = Settings::readCacheSize.observed,
|
||||
)
|
||||
SimpleMenuPreference(
|
||||
var currentLanguage by remember { mutableStateOf(getAppLanguage()) }
|
||||
val languages = remember { context.getLanguages() }
|
||||
DropDownPref(
|
||||
title = stringResource(id = R.string.settings_advanced_app_language_title),
|
||||
entry = R.array.app_language_entries,
|
||||
entryValueRes = R.array.app_language_entry_values,
|
||||
value = Settings::language,
|
||||
defaultValue = currentLanguage,
|
||||
onValueChange = {
|
||||
setAppLanguage(it)
|
||||
currentLanguage = it
|
||||
},
|
||||
useSelectedAsSummary = true,
|
||||
entries = languages,
|
||||
)
|
||||
SwitchPreference(
|
||||
title = stringResource(id = R.string.preload_thumb_aggressively),
|
||||
value = Settings::preloadThumbAggressively,
|
||||
)
|
||||
IntSliderPreference(
|
||||
maxValue = 5,
|
||||
minValue = 1,
|
||||
title = stringResource(id = R.string.settings_advanced_touch_slop),
|
||||
summary = stringResource(id = R.string.settings_advanced_touch_slop_summary),
|
||||
value = Settings::touchSlopFactor,
|
||||
var animateItems by Settings.animateItems.asMutableState()
|
||||
SwitchPref(
|
||||
checked = animateItems,
|
||||
onMutate = { animateItems = !animateItems },
|
||||
title = stringResource(id = R.string.animate_items),
|
||||
summary = stringResource(id = R.string.animate_items_summary),
|
||||
)
|
||||
var userAgent by Settings::userAgent.observed
|
||||
val userAgentTitle = stringResource(id = R.string.user_agent)
|
||||
@@ -159,7 +175,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
userAgent = dialogState.awaitInputText(
|
||||
initial = userAgent,
|
||||
title = userAgentTitle,
|
||||
).trim().takeUnless { it.isBlank() } ?: CHROME_USER_AGENT
|
||||
).trim().ifBlank { null } ?: CHROME_USER_AGENT
|
||||
}
|
||||
}
|
||||
val exportFailed = stringResource(id = R.string.settings_advanced_export_data_failed)
|
||||
@@ -172,7 +188,7 @@ fun AdvancedScreen(navigator: DestinationsNavigator) {
|
||||
uri?.let {
|
||||
context.runCatching {
|
||||
grantUriPermission(BuildConfig.APPLICATION_ID, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
EhDB.exportDB(context, uri)
|
||||
EhDB.exportDB(context, uri.asUniFile())
|
||||
launchSnackBar(getString(R.string.settings_advanced_export_data_to, uri.toString()))
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import arrow.fx.coroutines.parMap
|
||||
import arrow.fx.coroutines.parMapNotNull
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
@@ -36,7 +37,10 @@ import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
|
||||
import com.hippo.ehviewer.client.parser.ParserUtils
|
||||
import com.hippo.ehviewer.dao.DownloadInfo
|
||||
import com.hippo.ehviewer.download.DownloadManager
|
||||
import com.hippo.ehviewer.download.downloadDir
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.spider.COMIC_INFO_FILE
|
||||
import com.hippo.ehviewer.spider.SpiderDen
|
||||
@@ -46,6 +50,7 @@ import com.hippo.ehviewer.spider.readCompatFromUniFile
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.ui.tools.observed
|
||||
import com.hippo.ehviewer.ui.tools.rememberedAccessor
|
||||
import com.hippo.ehviewer.util.displayString
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.asUniFile
|
||||
import com.hippo.unifile.displayPath
|
||||
@@ -94,6 +99,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
|
||||
keepNoMediaFileStatus(downloadLocationState)
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
launchSnackBar(cannotGetDownloadLocation)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +162,34 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
|
||||
summary = stringResource(id = R.string.settings_download_archive_metadata_summary),
|
||||
value = Settings::archiveMetadata,
|
||||
)
|
||||
WorkPreference(
|
||||
title = stringResource(id = R.string.settings_download_reload_metadata),
|
||||
summary = stringResource(id = R.string.settings_download_reload_metadata_summary),
|
||||
) {
|
||||
fun DownloadInfo.isStable(): Boolean {
|
||||
val downloadTime = downloadDir?.findFile(COMIC_INFO_FILE)?.lastModified() ?: return false
|
||||
val postedTime = posted?.let { ParserUtils.parseDate(it) } ?: return false
|
||||
// stable 30 days after posted
|
||||
val stableTime = postedTime + 30L * 24L * 60L * 60L * 1000L
|
||||
return downloadTime > stableTime
|
||||
}
|
||||
|
||||
runSuspendCatching {
|
||||
DownloadManager.downloadInfoList.parMapNotNull {
|
||||
if (it.state == DownloadInfo.STATE_FINISH && !it.isStable()) it else null
|
||||
}.apply {
|
||||
fillGalleryListByApi(this, EhUrl.referer)
|
||||
val toUpdate = parMap { di ->
|
||||
di.galleryInfo.also { SpiderDen(it, di.dirname!!).writeComicInfo(false) }
|
||||
}
|
||||
EhDB.updateGalleryInfo(toUpdate)
|
||||
launchSnackBar(context.getString(R.string.settings_download_reload_metadata_successfully, toUpdate.size))
|
||||
}
|
||||
}.onFailure {
|
||||
launchSnackBar(context.getString(R.string.settings_download_reload_metadata_failed, it.displayString()))
|
||||
}
|
||||
}
|
||||
val restoreFailed = stringResource(id = R.string.settings_download_restore_failed)
|
||||
WorkPreference(
|
||||
title = stringResource(id = R.string.settings_download_restore_download_items),
|
||||
summary = stringResource(id = R.string.settings_download_restore_download_items_summary),
|
||||
@@ -163,7 +197,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
|
||||
var restoreDirCount = 0
|
||||
suspend fun getRestoreItem(file: UniFile): RestoreItem? {
|
||||
if (!file.isDirectory) return null
|
||||
return runCatching {
|
||||
return runSuspendCatching {
|
||||
val (gid, token) = file.findFile(SPIDER_INFO_FILENAME)?.let {
|
||||
readCompatFromUniFile(it)?.run {
|
||||
GalleryDetailUrlParser.Result(gid, token)
|
||||
@@ -189,12 +223,8 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
|
||||
}.getOrNull()
|
||||
}
|
||||
runCatching {
|
||||
val result = downloadLocation.listFiles().mapNotNull { getRestoreItem(it) }.apply {
|
||||
runSuspendCatching {
|
||||
fillGalleryListByApi(this, EhUrl.referer)
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
}
|
||||
val result = downloadLocation.listFiles().parMapNotNull { getRestoreItem(it) }.also {
|
||||
fillGalleryListByApi(it, EhUrl.referer)
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
launchSnackBar(RESTORE_COUNT_MSG(restoreDirCount))
|
||||
@@ -210,6 +240,7 @@ fun DownloadScreen(navigator: DestinationsNavigator) {
|
||||
}
|
||||
}.onFailure {
|
||||
logcat(it)
|
||||
launchSnackBar(restoreFailed)
|
||||
}
|
||||
}
|
||||
WorkPreference(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.hippo.ehviewer.ui.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -14,6 +17,7 @@ import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -37,7 +41,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.parseAsHtml
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.fromHtml
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.asMutableState
|
||||
@@ -46,10 +52,12 @@ import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.ui.destinations.FilterScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.MyTagsScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.SignInScreenDestination
|
||||
import com.hippo.ehviewer.ui.destinations.UConfigScreenDestination
|
||||
import com.hippo.ehviewer.ui.main.FundsItem
|
||||
import com.hippo.ehviewer.ui.screen.popNavigate
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.observed
|
||||
@@ -100,9 +108,10 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
val displayName by Settings.displayName.collectAsState()
|
||||
Preference(
|
||||
title = stringResource(id = R.string.account_name),
|
||||
summary = Settings.displayName ?: guestMode,
|
||||
summary = displayName ?: guestMode,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
val cookies = EhCookieStore.getIdentityCookies()
|
||||
@@ -111,12 +120,13 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
dismissText = R.string.settings_eh_clear_igneous,
|
||||
showCancelButton = cookies.last().second != null,
|
||||
onCancelButtonClick = { EhCookieStore.clearIgneous() },
|
||||
secure = signin,
|
||||
) {
|
||||
if (signin) {
|
||||
Column {
|
||||
val warning = stringResource(id = R.string.settings_eh_identity_cookies_signed)
|
||||
val str = cookies.joinToString("\n") { (k, v) -> "$k: $v" }
|
||||
Text(text = warning.parseAsHtml())
|
||||
Text(text = AnnotatedString.fromHtml(warning))
|
||||
Spacer(modifier = Modifier.size(dimensionResource(id = R.dimen.keyline_margin)))
|
||||
OutlinedTextField(
|
||||
value = str,
|
||||
@@ -149,11 +159,9 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
var result by rememberSaveable { mutableStateOf<HomeParser.Result?>(null) }
|
||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val summary by rememberUpdatedState(
|
||||
stringResource(
|
||||
id = R.string.image_limits_summary,
|
||||
result?.run { limits.current } ?: 0,
|
||||
result?.run { limits.maximum } ?: 0,
|
||||
),
|
||||
result?.run {
|
||||
stringResource(id = R.string.image_limits_summary, limits.current, limits.maximum)
|
||||
} ?: placeholder,
|
||||
)
|
||||
suspend fun getImageLimits() {
|
||||
result = EhEngine.getImageLimits()
|
||||
@@ -181,10 +189,28 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
error?.let {
|
||||
Text(text = it)
|
||||
} ?: result?.let { (limits, funds) ->
|
||||
Text(
|
||||
text = stringResource(id = R.string.current_limits, summary, limits.resetCost) +
|
||||
"\n" + stringResource(id = R.string.current_funds, "${funds.fundsGP}+", funds.fundsC),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { limits.current.toFloat() / limits.maximum },
|
||||
modifier = Modifier.height(12.dp).fillMaxWidth(),
|
||||
)
|
||||
Text(text = summary)
|
||||
Text(text = stringResource(id = R.string.reset_cost, limits.resetCost))
|
||||
Text(text = stringResource(id = R.string.current_funds))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
FundsItem(
|
||||
type = "GP",
|
||||
amount = funds.gp,
|
||||
)
|
||||
FundsItem(
|
||||
type = "C",
|
||||
amount = funds.credit,
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -241,7 +267,11 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
addAll(Settings.favCat)
|
||||
}
|
||||
}
|
||||
defaultFavSlot = dialogState.showSelectItem(items, R.string.default_favorites_collection) - 2
|
||||
defaultFavSlot = dialogState.awaitSelectItem(
|
||||
items = items,
|
||||
title = R.string.default_favorites_collection,
|
||||
selected = defaultFavSlot + 2,
|
||||
) - 2
|
||||
}
|
||||
}
|
||||
SimpleMenuPreferenceInt(
|
||||
@@ -366,7 +396,7 @@ fun EhScreen(navigator: DestinationsNavigator) {
|
||||
Preference(title = pickerTitle) {
|
||||
coroutineScope.launch {
|
||||
val time = LocalTime.fromSecondOfDay(Settings.requestNewsTime)
|
||||
val (hour, minute) = dialogState.showTimePicker(pickerTitle, time.hour, time.minute)
|
||||
val (hour, minute) = dialogState.awaitSelectTime(pickerTitle, time.hour, time.minute)
|
||||
Settings.requestNewsTime = LocalTime(hour, minute).toSecondOfDay()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -49,14 +50,17 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhFilter
|
||||
import com.hippo.ehviewer.client.EhFilter.forget
|
||||
import com.hippo.ehviewer.client.EhFilter.remember
|
||||
import com.hippo.ehviewer.client.EhFilter.trigger
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.LocalDialogState
|
||||
import com.hippo.ehviewer.ui.tools.thenIf
|
||||
import com.ramcosta.composedestinations.annotation.Destination
|
||||
import com.ramcosta.composedestinations.annotation.RootGraph
|
||||
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
|
||||
@@ -74,6 +78,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
val dialogState = LocalDialogState.current
|
||||
val textIsEmpty = stringResource(R.string.text_is_empty)
|
||||
val labelExist = stringResource(R.string.label_text_exist)
|
||||
val animateItems by Settings.animateItems.collectAsState()
|
||||
|
||||
fun addFilter() {
|
||||
scope.launch {
|
||||
@@ -122,7 +127,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.menuAnchor(),
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
readOnly = true,
|
||||
value = type,
|
||||
onValueChange = {},
|
||||
@@ -224,10 +229,10 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
FilterMode.COMMENT -> R.string.filter_comment
|
||||
}
|
||||
if (filters.isNotEmpty()) {
|
||||
stickyHeader(key = filterMode) {
|
||||
item(key = filterMode) {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp).animateItem(),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp).thenIf(animateItems) { animateItem() },
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
@@ -235,7 +240,7 @@ fun FilterScreen(navigator: DestinationsNavigator) {
|
||||
items(filters, key = { requireNotNull(it.id) }) { filter ->
|
||||
val filterCheckBoxRecomposeScope = currentRecomposeScope
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().animateItem().clickable { filter.trigger { filterCheckBoxRecomposeScope.invalidate() } },
|
||||
modifier = Modifier.fillMaxWidth().thenIf(animateItems) { animateItem() }.clickable { filter.trigger { filterCheckBoxRecomposeScope.invalidate() } },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.autofill.AutofillNode
|
||||
import androidx.compose.ui.autofill.AutofillType
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalAutofill
|
||||
import androidx.compose.ui.platform.LocalAutofillTree
|
||||
|
||||
@Composable
|
||||
fun Modifier.autofill(
|
||||
autofillTypes: List<AutofillType>,
|
||||
onFill: ((String) -> Unit),
|
||||
): Modifier {
|
||||
val autofill = LocalAutofill.current
|
||||
val autofillNode = AutofillNode(onFill = onFill, autofillTypes = autofillTypes)
|
||||
LocalAutofillTree.current += autofillNode
|
||||
|
||||
return onGloballyPositioned {
|
||||
autofillNode.boundingBox = it.boundsInWindow()
|
||||
}.onFocusChanged { focusState ->
|
||||
autofill?.run {
|
||||
if (focusState.isFocused) {
|
||||
requestAutofillForNode(autofillNode)
|
||||
} else {
|
||||
cancelAutofillForNode(autofillNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,11 @@ import androidx.compose.foundation.gestures.draggable2D
|
||||
import androidx.compose.foundation.gestures.rememberDraggable2DState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
@@ -38,7 +35,7 @@ fun CircularLayout(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var delta by remember { mutableFloatStateOf(Settings.favDialogTheta) }
|
||||
var delta by rememberMutableStateInDataStore("FavDialogTheta") { 0F }
|
||||
val state = rememberDraggable2DState {
|
||||
val prevOfs = currentOfs
|
||||
val prevTheta = currentTheta
|
||||
@@ -72,12 +69,10 @@ fun CircularLayout(
|
||||
animateDecay(delta, omega, rotateDecay) { d, _ ->
|
||||
delta = d
|
||||
}
|
||||
Settings.favDialogTheta = delta
|
||||
}
|
||||
}
|
||||
currentOfs = null
|
||||
currentTheta = null
|
||||
Settings.favDialogTheta = delta
|
||||
},
|
||||
),
|
||||
content = content,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
|
||||
val LocalWindowSizeClass = compositionLocalOf<WindowSizeClass> { error("CompositionLocal LocalWindowSizeClass not present!") }
|
||||
@@ -35,7 +35,7 @@ import androidx.compose.material3.DisplayMode
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
@@ -50,7 +50,6 @@ import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -69,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import arrow.core.Either
|
||||
import arrow.core.right
|
||||
import com.jamal.composeprefs3.ui.ifNotNullThen
|
||||
@@ -100,15 +100,10 @@ class DialogState {
|
||||
content = null
|
||||
}
|
||||
|
||||
suspend inline fun <R> dialog(crossinline block: @Composable (CancellableContinuation<R>) -> Unit) = suspendCancellableCoroutine { cont ->
|
||||
cont.invokeOnCancellation { dismiss() }
|
||||
val realContinuation = object : CancellableContinuation<R> by cont {
|
||||
override fun resumeWith(result: Result<R>) {
|
||||
dismiss()
|
||||
cont.resumeWith(result)
|
||||
}
|
||||
}
|
||||
content = { block(realContinuation) }
|
||||
suspend inline fun <R> dialog(crossinline block: @Composable (CancellableContinuation<R>) -> Unit) = try {
|
||||
suspendCancellableCoroutine { cont -> content = { block(cont) } }
|
||||
} finally {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
suspend fun <R> awaitResult(
|
||||
@@ -280,6 +275,7 @@ class DialogState {
|
||||
confirmButtonEnabled: Boolean = true,
|
||||
showCancelButton: Boolean = true,
|
||||
onCancelButtonClick: () -> Unit = {},
|
||||
secure: Boolean = false,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
return dialog { cont ->
|
||||
@@ -300,11 +296,16 @@ class DialogState {
|
||||
},
|
||||
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
|
||||
text = text,
|
||||
properties = if (secure) {
|
||||
DialogProperties(securePolicy = SecureFlagPolicy.SecureOn)
|
||||
} else {
|
||||
DialogProperties()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showDatePicker(
|
||||
suspend fun awaitSelectDate(
|
||||
@StringRes title: Int,
|
||||
initialSelectedDateMillis: Long? = null,
|
||||
initialDisplayedMonthMillis: Long? = initialSelectedDateMillis,
|
||||
@@ -371,7 +372,7 @@ class DialogState {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showTimePicker(
|
||||
suspend fun awaitSelectTime(
|
||||
title: String,
|
||||
initialHour: Int,
|
||||
initialMinute: Int,
|
||||
@@ -413,7 +414,7 @@ class DialogState {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showSingleChoice(
|
||||
suspend fun awaitSingleChoice(
|
||||
items: List<String>,
|
||||
selected: Int,
|
||||
@StringRes title: Int? = null,
|
||||
@@ -439,15 +440,17 @@ class DialogState {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showSelectItem(
|
||||
suspend fun awaitSelectItem(
|
||||
items: List<String>,
|
||||
@StringRes title: Int? = null,
|
||||
selected: Int = -1,
|
||||
respectDefaultWidth: Boolean = true,
|
||||
) = showSelectItem(items, title?.right(), respectDefaultWidth)
|
||||
) = awaitSelectItem(items, title?.right(), selected, respectDefaultWidth)
|
||||
|
||||
suspend fun showSelectItem(
|
||||
suspend fun awaitSelectItem(
|
||||
items: List<String>,
|
||||
title: Either<String, Int>?,
|
||||
selected: Int = -1,
|
||||
respectDefaultWidth: Boolean = true,
|
||||
): Int = showNoButton(respectDefaultWidth) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
@@ -460,29 +463,27 @@ class DialogState {
|
||||
}
|
||||
LazyColumn {
|
||||
itemsIndexed(items) { index, text ->
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.tertiary) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.clickable { dismissWith(index) }.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
CheckableItem(
|
||||
text = text,
|
||||
checked = index == selected,
|
||||
modifier = Modifier.fillMaxWidth().clickable { dismissWith(index) }.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun showSelectActions(
|
||||
suspend inline fun awaitSelectAction(
|
||||
@StringRes title: Int? = null,
|
||||
selected: Int = -1,
|
||||
builder: ActionScope.() -> Unit,
|
||||
) {
|
||||
): suspend () -> Unit {
|
||||
val (items, actions) = buildList { builder(ActionScope { action, that -> add(action to that) }) }.unzip()
|
||||
val selected = showSelectItem(items, title)
|
||||
actions[selected].invoke()
|
||||
val index = awaitSelectItem(items, title, selected)
|
||||
return actions[index]
|
||||
}
|
||||
|
||||
suspend fun showSelectItemWithCheckBox(
|
||||
suspend fun awaitSelectItemWithCheckBox(
|
||||
items: List<String>,
|
||||
@StringRes title: Int,
|
||||
@StringRes checkBoxText: Int,
|
||||
@@ -490,8 +491,6 @@ class DialogState {
|
||||
initialChecked: Boolean = false,
|
||||
): Pair<Int, Boolean> = showNoButton {
|
||||
var checked by remember { mutableStateOf(initialChecked) }
|
||||
val textStyle = MaterialTheme.typography.titleMedium
|
||||
val selectedColor = MaterialTheme.colorScheme.tertiary
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
@@ -500,19 +499,10 @@ class DialogState {
|
||||
)
|
||||
LazyColumn {
|
||||
itemsIndexed(items) { index, text ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = text,
|
||||
style = if (index == selected) textStyle.copy(color = selectedColor) else textStyle,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { dismissWith(index to checked) }.fillMaxWidth(),
|
||||
trailingContent = {
|
||||
if (index == selected) {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = selectedColor)
|
||||
}
|
||||
},
|
||||
CheckableItem(
|
||||
text = text,
|
||||
checked = index == selected,
|
||||
modifier = Modifier.fillMaxWidth().clickable { dismissWith(index to checked) }.padding(horizontal = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -525,7 +515,7 @@ class DialogState {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showSelectItemWithIcon(
|
||||
suspend fun awaitSelectItemWithIcon(
|
||||
items: List<Pair<ImageVector, Int>>,
|
||||
title: String,
|
||||
): Int = showNoButton {
|
||||
@@ -538,16 +528,17 @@ class DialogState {
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = text), style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
modifier = Modifier.clickable { dismissWith(index) },
|
||||
modifier = Modifier.clickable { dismissWith(index) }.padding(horizontal = 8.dp),
|
||||
leadingContent = {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = AlertDialogDefaults.iconContentColor)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showSelectItemWithIconAndTextField(
|
||||
suspend fun awaitSelectItemWithIconAndTextField(
|
||||
items: List<Pair<ImageVector, String>>,
|
||||
@StringRes title: Int,
|
||||
@StringRes hint: Int,
|
||||
@@ -606,6 +597,25 @@ class DialogState {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CheckableItem(text: String, checked: Boolean, modifier: Modifier = Modifier) {
|
||||
val textStyle = MaterialTheme.typography.titleMedium
|
||||
val checkedColor = MaterialTheme.colorScheme.primary
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = text,
|
||||
style = if (checked) textStyle.copy(color = checkedColor) else textStyle,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
trailingContent = checked.ifTrueThen {
|
||||
Icon(imageVector = Icons.Default.Check, contentDescription = null, tint = checkedColor)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Unspecified),
|
||||
)
|
||||
}
|
||||
|
||||
private val IconWithTextCorner = RoundedCornerShape(8.dp)
|
||||
private val DatePickerTitlePadding = PaddingValues(start = 24.dp, end = 12.dp, top = 16.dp)
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Reorder
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import com.hippo.ehviewer.util.isAtLeastOMR1
|
||||
import com.hippo.ehviewer.util.isAtLeastR
|
||||
import com.hippo.ehviewer.util.isAtLeastU
|
||||
import sh.calvin.reorderable.ReorderableItemScope
|
||||
|
||||
@Composable
|
||||
fun ReorderableItemScope.DragHandle(
|
||||
modifier: Modifier = Modifier,
|
||||
onDragStarted: () -> Unit = {},
|
||||
onDragStopped: () -> Unit = {},
|
||||
) {
|
||||
val view = LocalView.current
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = modifier.draggableHandle(
|
||||
onDragStarted = {
|
||||
val feedbackConstant = if (isAtLeastU) {
|
||||
HapticFeedbackConstants.DRAG_START
|
||||
} else if (isAtLeastR) {
|
||||
HapticFeedbackConstants.GESTURE_START
|
||||
} else if (isAtLeastOMR1) {
|
||||
HapticFeedbackConstants.KEYBOARD_PRESS
|
||||
} else {
|
||||
HapticFeedbackConstants.VIRTUAL_KEY
|
||||
}
|
||||
view.performHapticFeedback(feedbackConstant)
|
||||
onDragStarted()
|
||||
},
|
||||
onDragStopped = {
|
||||
val feedbackConstant = if (isAtLeastR) {
|
||||
HapticFeedbackConstants.GESTURE_END
|
||||
} else if (isAtLeastOMR1) {
|
||||
HapticFeedbackConstants.KEYBOARD_RELEASE
|
||||
} else {
|
||||
HapticFeedbackConstants.VIRTUAL_KEY
|
||||
}
|
||||
view.performHapticFeedback(feedbackConstant)
|
||||
onDragStopped()
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Reorder,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val draggingHapticFeedback = if (isAtLeastU) {
|
||||
HapticFeedbackConstants.SEGMENT_FREQUENT_TICK
|
||||
} else if (isAtLeastOMR1) {
|
||||
HapticFeedbackConstants.TEXT_HANDLE_MOVE
|
||||
} else {
|
||||
HapticFeedbackConstants.CLOCK_TICK
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun DropdownFilterChip(
|
||||
label: String,
|
||||
menuItems: List<String>,
|
||||
selectedItemIndex: Int,
|
||||
onSelectedItemIndexChange: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = modifier) {
|
||||
FilterChip(
|
||||
selected = selectedItemIndex != 0,
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(text = if (selectedItemIndex == 0) label else menuItems[selectedItemIndex])
|
||||
},
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }, matchTextFieldWidth = false) {
|
||||
menuItems.forEachIndexed { index, item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelectedItemIndexChange(index)
|
||||
},
|
||||
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import com.hippo.ehviewer.util.isAtLeastOMR1
|
||||
import com.hippo.ehviewer.util.isAtLeastR
|
||||
import com.hippo.ehviewer.util.isAtLeastU
|
||||
|
||||
enum class HapticFeedbackType {
|
||||
START,
|
||||
MOVE,
|
||||
END,
|
||||
}
|
||||
|
||||
interface HapticFeedback {
|
||||
fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberHapticFeedback(): HapticFeedback {
|
||||
val view = LocalView.current
|
||||
return remember(view) { AndroidHapticFeedback(view) }
|
||||
}
|
||||
|
||||
class AndroidHapticFeedback(private val view: View) : HapticFeedback {
|
||||
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {
|
||||
val feedbackConstant = when (hapticFeedbackType) {
|
||||
HapticFeedbackType.START -> {
|
||||
if (isAtLeastU) {
|
||||
HapticFeedbackConstants.DRAG_START
|
||||
} else if (isAtLeastR) {
|
||||
HapticFeedbackConstants.GESTURE_START
|
||||
} else if (isAtLeastOMR1) {
|
||||
HapticFeedbackConstants.KEYBOARD_PRESS
|
||||
} else {
|
||||
HapticFeedbackConstants.VIRTUAL_KEY
|
||||
}
|
||||
}
|
||||
HapticFeedbackType.MOVE -> {
|
||||
if (isAtLeastU) {
|
||||
HapticFeedbackConstants.SEGMENT_FREQUENT_TICK
|
||||
} else if (isAtLeastOMR1) {
|
||||
HapticFeedbackConstants.TEXT_HANDLE_MOVE
|
||||
} else {
|
||||
HapticFeedbackConstants.CLOCK_TICK
|
||||
}
|
||||
}
|
||||
HapticFeedbackType.END -> {
|
||||
if (isAtLeastR) {
|
||||
HapticFeedbackConstants.GESTURE_END
|
||||
} else if (isAtLeastOMR1) {
|
||||
HapticFeedbackConstants.KEYBOARD_RELEASE
|
||||
} else {
|
||||
HapticFeedbackConstants.VIRTUAL_KEY
|
||||
}
|
||||
}
|
||||
}
|
||||
view.performHapticFeedback(feedbackConstant)
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.platform.ViewConfiguration
|
||||
|
||||
// TODO: https://issuetracker.google.com/issues/269627294
|
||||
@Composable
|
||||
fun LocalTouchSlopProvider(factor: Float, content: @Composable () -> Unit) {
|
||||
val current = LocalViewConfiguration.current
|
||||
CompositionLocalProvider(
|
||||
value = LocalViewConfiguration provides object : ViewConfiguration by current {
|
||||
override val touchSlop: Float
|
||||
get() = current.touchSlop * factor
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
inline fun Modifier.thenIf(condition: Boolean, crossinline block: Modifier.() -> Modifier) =
|
||||
if (condition) block() else this
|
||||
@@ -4,7 +4,7 @@ import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.spring
|
||||
@@ -23,6 +23,8 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val PredictiveBackEasing = CubicBezierEasing(0.1f, 0.1f, 0f, 1f)
|
||||
|
||||
/* enable 0 1
|
||||
* animationProgress 1F 0F
|
||||
*
|
||||
@@ -41,7 +43,7 @@ import kotlinx.coroutines.launch
|
||||
fun animateFloatMergePredictiveBackAsState(
|
||||
enable: Boolean,
|
||||
animationSpec: AnimationSpec<Float> = spring(),
|
||||
predictiveBackInterpolator: Easing = EaseOut,
|
||||
predictiveBackInterpolator: Easing = PredictiveBackEasing,
|
||||
finishedListener: ((Float) -> Unit)? = null,
|
||||
onBack: () -> Unit,
|
||||
): State<Float> {
|
||||
@@ -84,7 +86,7 @@ fun animateFloatMergePredictiveBackAsState(
|
||||
fun animateFloatMergeOneWayPredictiveBackAsState(
|
||||
enable: Boolean,
|
||||
animationSpec: AnimationSpec<Float> = spring(),
|
||||
predictiveBackInterpolator: Easing = EaseOut,
|
||||
predictiveBackInterpolator: Easing = PredictiveBackEasing,
|
||||
onBack: suspend () -> Unit,
|
||||
): State<Float> {
|
||||
val animatable = remember { Animatable(0f, Float.VectorConverter) }
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarOutline
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -25,34 +26,20 @@ import kotlin.math.roundToInt
|
||||
|
||||
private val colorYellow800 = Color(0xfff9a825)
|
||||
|
||||
@Stable
|
||||
private fun getImageVector(index: Int, rating: Int) = when {
|
||||
index * 2 + 1 < rating -> Icons.Default.Star
|
||||
index * 2 + 1 == rating -> Icons.AutoMirrored.Default.StarHalf
|
||||
else -> Icons.Default.StarOutline
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MetaRatingWidgetReuse(rating: Float, ratingSize: Dp, ratingInterval: Dp, modifier: Modifier = Modifier) {
|
||||
val r = (rating * 2).roundToInt().coerceIn(0, 10)
|
||||
val fullStar = r / 2
|
||||
val halfStar = r % 2
|
||||
val outlineStar = 5 - fullStar - halfStar
|
||||
Row(modifier = modifier) {
|
||||
repeat(fullStar) {
|
||||
repeat(5) {
|
||||
IconCached(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ratingInterval))
|
||||
}
|
||||
repeat(halfStar) {
|
||||
IconCached(
|
||||
imageVector = Icons.AutoMirrored.Default.StarHalf,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ratingInterval))
|
||||
}
|
||||
repeat(outlineStar) {
|
||||
IconCached(
|
||||
imageVector = Icons.Default.StarOutline,
|
||||
imageVector = getImageVector(it, r),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
@@ -65,9 +52,6 @@ fun MetaRatingWidgetReuse(rating: Float, ratingSize: Dp, ratingInterval: Dp, mod
|
||||
@Composable
|
||||
fun MetaRatingWidget(rating: Float, ratingSize: Dp, ratingInterval: Dp, modifier: Modifier = Modifier, onRatingChange: ((Float) -> Unit)? = null) {
|
||||
val r = (rating * 2).roundToInt().coerceIn(0, 10)
|
||||
val fullStar = r / 2
|
||||
val halfStar = r % 2
|
||||
val outlineStar = 5 - fullStar - halfStar
|
||||
val density = LocalDensity.current
|
||||
val ratingSizePx = remember { with(density) { ratingSize.toPx() } }
|
||||
val ratingIntervalPx = remember { with(density) { ratingInterval.toPx() } }
|
||||
@@ -93,27 +77,9 @@ fun MetaRatingWidget(rating: Float, ratingSize: Dp, ratingInterval: Dp, modifier
|
||||
}
|
||||
} ?: modifier,
|
||||
) {
|
||||
repeat(fullStar) {
|
||||
repeat(5) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ratingInterval))
|
||||
}
|
||||
repeat(halfStar) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.StarHalf,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ratingInterval))
|
||||
}
|
||||
repeat(outlineStar) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.StarOutline,
|
||||
imageVector = getImageVector(it, r),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(ratingSize),
|
||||
tint = colorYellow800,
|
||||
|
||||
@@ -3,43 +3,83 @@ package com.hippo.ehviewer.ui.tools
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.currentCompositeKeyHash
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.datastore.dataStoreFile
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.byteArrayPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.cbor.Cbor
|
||||
import kotlinx.serialization.decodeFromByteArray
|
||||
import kotlinx.serialization.encodeToByteArray
|
||||
import splitties.init.appCtx
|
||||
|
||||
val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
val dataStore = PreferenceDataStoreFactory.create { appCtx.dataStoreFile("Remembered.preferences_pb") }
|
||||
val dataStateFlow = dataStore.data.stateIn(dataStoreScope, SharingStarted.Eagerly, emptyPreferences())
|
||||
|
||||
class StateMapViewModel : ViewModel() {
|
||||
val states: MutableMap<Int, ArrayDeque<Any>> = mutableMapOf()
|
||||
val statesMap = mutableMapOf<Int, ArrayDeque<Any>>()
|
||||
}
|
||||
|
||||
@Composable
|
||||
inline fun <T : Any> rememberInVM(
|
||||
inline fun <reified T : Any> rememberInVM(
|
||||
vararg inputs: Any?,
|
||||
crossinline init: @DisallowComposableCalls ViewModel.() -> T,
|
||||
): T {
|
||||
val vm: StateMapViewModel = viewModel()
|
||||
) = with(viewModel<StateMapViewModel>()) {
|
||||
val key = currentCompositeKeyHash
|
||||
val value = remember(*inputs) {
|
||||
val states = vm.states[key] ?: ArrayDeque<Any>().also { vm.states[key] = it }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
states.removeLastOrNull() as T? ?: init(vm)
|
||||
remember(*inputs) {
|
||||
val states = statesMap[key] ?: ArrayDeque<Any>().also { statesMap[key] = it }
|
||||
states.removeLastOrNull() as T? ?: init()
|
||||
}.also { value ->
|
||||
val valueState by rememberUpdatedState(value)
|
||||
DisposableEffect(key) {
|
||||
onDispose {
|
||||
statesMap[key]?.addFirst(valueState)
|
||||
}
|
||||
}
|
||||
}
|
||||
val valueState = rememberUpdatedState(value)
|
||||
DisposableEffect(key) {
|
||||
onDispose {
|
||||
vm.states[key]?.addFirst(valueState.value)
|
||||
}
|
||||
|
||||
// Find out how make this work with any generic `Serializable` type
|
||||
@Composable
|
||||
inline fun <reified T> rememberMutableStateInDataStore(
|
||||
key: String,
|
||||
crossinline defaultValue: @DisallowComposableCalls () -> T,
|
||||
) = with(dataStateFlow) {
|
||||
remember {
|
||||
val keyObj = byteArrayPreferencesKey(key)
|
||||
val r = value[keyObj]?.let { bytes -> Cbor.decodeFromByteArray(bytes) } ?: defaultValue()
|
||||
mutableStateOf(r)
|
||||
}.also { mutableState ->
|
||||
LifecycleResumeEffect(key) {
|
||||
onPauseOrDispose {
|
||||
dataStoreScope.launch {
|
||||
val keyObj = byteArrayPreferencesKey(key)
|
||||
dataStore.edit { p ->
|
||||
p[keyObj] = Cbor.encodeToByteArray(mutableState.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -52,6 +92,4 @@ fun launchInVM(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> rememberUpdatedStateInVM(newValue: T): State<T> = rememberInVM {
|
||||
mutableStateOf(newValue)
|
||||
}.apply { value = newValue }
|
||||
fun <T> rememberUpdatedStateInVM(newValue: T) = rememberInVM { mutableStateOf(newValue) }.apply { value = newValue }
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.SwipeToDismissBox
|
||||
import androidx.compose.material3.SwipeToDismissBoxState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import com.hippo.ehviewer.Settings
|
||||
|
||||
@Composable
|
||||
fun SwipeToDismissBox2(
|
||||
state: SwipeToDismissBoxState,
|
||||
backgroundContent: @Composable RowScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val viewConfiguration = LocalViewConfiguration.current
|
||||
LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) {
|
||||
SwipeToDismissBox(
|
||||
state = state,
|
||||
backgroundContent = backgroundContent,
|
||||
modifier = modifier,
|
||||
enableDismissFromStartToEnd = false,
|
||||
) {
|
||||
CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ package com.hippo.ehviewer.util
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import com.hippo.ehviewer.BuildConfig
|
||||
import com.hippo.ehviewer.client.parser.ParserUtils
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.hippo.unifile.asUniFile
|
||||
import java.io.File
|
||||
@@ -33,11 +34,15 @@ object AppConfig {
|
||||
private const val CRASH = "crash"
|
||||
|
||||
private val abi = Build.SUPPORTED_ABIS[0].takeIf {
|
||||
it in setOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||
it in setOf("arm64-v8a", "x86_64", "armeabi-v7a")
|
||||
} ?: "universal"
|
||||
|
||||
fun matchVariant(name: String) = name.contains(BuildConfig.FLAVOR) && name.contains(abi)
|
||||
|
||||
val buildTime = ParserUtils.formatDate(BuildConfig.BUILD_TIME * 1000)
|
||||
|
||||
val isSnapshot = "SNAPSHOT" in BuildConfig.VERSION_NAME
|
||||
|
||||
private val externalAppDir: File?
|
||||
get() {
|
||||
if (Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()) {
|
||||
|
||||
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user