Compare commits
442
Commits
1.8.9.7-gms
...
1.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19d14e1d0c | ||
|
|
b2368b312f | ||
|
|
3cade906d6 | ||
|
|
e7cb33ef6f | ||
|
|
8c331c9577 | ||
|
|
8560a050ba | ||
|
|
583637b692 | ||
|
|
791359e949 | ||
|
|
e238b6aec0 | ||
|
|
17a143c4de | ||
|
|
dfb0aa823e | ||
|
|
b5cad36967 | ||
|
|
86378630bb | ||
|
|
730035aff7 | ||
|
|
64ac770c56 | ||
|
|
557a5599e2 | ||
|
|
ffd24015ac | ||
|
|
5eb1f12113 | ||
|
|
8425fed5a7 | ||
|
|
1e34aa4d9a | ||
|
|
f383594050 | ||
|
|
086db7572d | ||
|
|
082e05584a | ||
|
|
fb20f899e2 | ||
|
|
c0c07ff2cf | ||
|
|
2f67b741ff | ||
|
|
5a96048051 | ||
|
|
5d8b38ecf9 | ||
|
|
2941ac8285 | ||
|
|
07e316d9a9 | ||
|
|
5bd9fa3a62 | ||
|
|
0b5da2b2dd | ||
|
|
714a8ccf65 | ||
|
|
5c731cc6b2 | ||
|
|
eda8115fe5 | ||
|
|
cf0a408b90 | ||
|
|
f9786377a5 | ||
|
|
93f234b16d | ||
|
|
afb986937b | ||
|
|
61fecaffe1 | ||
|
|
6f87afc1bf | ||
|
|
2e5950a1a8 | ||
|
|
4bf0c4ecec | ||
|
|
3a8dbcdb32 | ||
|
|
1f3546f3cc | ||
|
|
f864d65d98 | ||
|
|
3479498c6c | ||
|
|
ac319330be | ||
|
|
9b3595e45f | ||
|
|
d855858543 | ||
|
|
ada52f1687 | ||
|
|
ef68374f52 | ||
|
|
0bf8049021 | ||
|
|
3cff3f837a | ||
|
|
ca94a63762 | ||
|
|
81b8bc9029 | ||
|
|
9d38520b85 | ||
|
|
800e09adad | ||
|
|
30d6c11220 | ||
|
|
e465356df8 | ||
|
|
ff8e1b7d28 | ||
|
|
175e1d7df9 | ||
|
|
271c1b1390 | ||
|
|
a128356fc4 | ||
|
|
2ed957b5f2 | ||
|
|
85bb9b9f97 | ||
|
|
050b5c0451 | ||
|
|
a14df3fc0c | ||
|
|
939136476e | ||
|
|
7e2af653ee | ||
|
|
d357a55307 | ||
|
|
23509ac2c9 | ||
|
|
118a4f75c0 | ||
|
|
2b8a7fafe6 | ||
|
|
10eda7e5f3 | ||
|
|
c23cadec8a | ||
|
|
0114081a2b | ||
|
|
2611601fb9 | ||
|
|
4194087f0b | ||
|
|
673adc41ba | ||
|
|
b195ae25a8 | ||
|
|
556c49fbfd | ||
|
|
358a5249a4 | ||
|
|
d5b837e6af | ||
|
|
61100d0c83 | ||
|
|
8473ca9fff | ||
|
|
37a2227c5c | ||
|
|
d6a4ab7471 | ||
|
|
6291288c42 | ||
|
|
6c926419e9 | ||
|
|
f292f31ac1 | ||
|
|
86a49f764e | ||
|
|
5dad21f4ea | ||
|
|
a4e9291bf9 | ||
|
|
2a667e62f4 | ||
|
|
6bd8063976 | ||
|
|
0aba5907b0 | ||
|
|
32182a7791 | ||
|
|
198960f93c | ||
|
|
618f14a732 | ||
|
|
fec2c39364 | ||
|
|
dd06d82cd9 | ||
|
|
4c98ee5350 | ||
|
|
2800a7bc15 | ||
|
|
04818e5afb | ||
|
|
122089eb1e | ||
|
|
b991e5d70a | ||
|
|
0d00ae207a | ||
|
|
9e451c18d9 | ||
|
|
71ebd99115 | ||
|
|
116dbead4b | ||
|
|
ef8adb00f4 | ||
|
|
7d74781f96 | ||
|
|
b7627dc576 | ||
|
|
cf42acd65c | ||
|
|
4163c895df | ||
|
|
63ddd5ea89 | ||
|
|
216dfded4b | ||
|
|
08b5eac6bd | ||
|
|
6e541be6ca | ||
|
|
17a9c02f96 | ||
|
|
ed640ab00e | ||
|
|
d69df5f7ab | ||
|
|
6f6461e855 | ||
|
|
682d959688 | ||
|
|
179686de16 | ||
|
|
32e35622bd | ||
|
|
c9c06202bb | ||
|
|
42d17621f9 | ||
|
|
629044c241 | ||
|
|
9be19489a4 | ||
|
|
cde4f9e405 | ||
|
|
16fddcb377 | ||
|
|
35cff4647a | ||
|
|
80ad749fc2 | ||
|
|
1a1d444b20 | ||
|
|
bb2d500067 | ||
|
|
c3c7e88c15 | ||
|
|
8dc3b1f376 | ||
|
|
b49a80bb35 | ||
|
|
21bfd4dd2d | ||
|
|
3551ed7c7a | ||
|
|
5f8ab98e9e | ||
|
|
8c6b2f5366 | ||
|
|
bac60fbec8 | ||
|
|
4eed03ebed | ||
|
|
e33295d0a6 | ||
|
|
07a495c499 | ||
|
|
e69eba6023 | ||
|
|
8be6ad84fc | ||
|
|
59e46aeeea | ||
|
|
4404531171 | ||
|
|
99db84fdd0 | ||
|
|
aa50b69754 | ||
|
|
fa2933df7c | ||
|
|
c51a26b9b7 | ||
|
|
9859a1eaac | ||
|
|
cd3858ace8 | ||
|
|
0c54e8b0b3 | ||
|
|
e32a7b61b6 | ||
|
|
ea2977268b | ||
|
|
6b529bcb18 | ||
|
|
941e7d18f3 | ||
|
|
d0fe9fac8c | ||
|
|
8f37c5ba25 | ||
|
|
b63b36e6d2 | ||
|
|
081357cbb4 | ||
|
|
0f7aa5f9ea | ||
|
|
a565b428e8 | ||
|
|
71f4df6f05 | ||
|
|
5da2f91e6a | ||
|
|
a6e9867596 | ||
|
|
9e1cff014d | ||
|
|
9c06f10d43 | ||
|
|
3f11df1dc6 | ||
|
|
6dd4505cec | ||
|
|
8faabd7759 | ||
|
|
288e56d7e0 | ||
|
|
7bcddb5f51 | ||
|
|
7b0bd309e4 | ||
|
|
237938b1f0 | ||
|
|
07009f8bd6 | ||
|
|
f7ef768c8f | ||
|
|
d1c3c9a37a | ||
|
|
78f15860e0 | ||
|
|
6b11773da9 | ||
|
|
ed26cb8f35 | ||
|
|
337f27dc38 | ||
|
|
97fab45877 | ||
|
|
8e8749ed6e | ||
|
|
a0e999576e | ||
|
|
b29802c260 | ||
|
|
665e5a3d39 | ||
|
|
c2a2dafb47 | ||
|
|
1d12a16e3d | ||
|
|
f3fe5f0a75 | ||
|
|
3bad1d06f4 | ||
|
|
70f2bfb760 | ||
|
|
9e86d42972 | ||
|
|
c8ade4d750 | ||
|
|
11b7bbd7ef | ||
|
|
513bce51ea | ||
|
|
f2c8c5d66d | ||
|
|
4e08f4f962 | ||
|
|
a9bd55dff5 | ||
|
|
5cec825dbe | ||
|
|
14c5b28cdc | ||
|
|
7693b11e0a | ||
|
|
f9cb49e4d3 | ||
|
|
892a614246 | ||
|
|
4a482d996e | ||
|
|
af9baa8ce4 | ||
|
|
39a11525a9 | ||
|
|
2337dda4eb | ||
|
|
c0a610a02b | ||
|
|
85f7b54719 | ||
|
|
e0aea2fedf | ||
|
|
ee83dbb877 | ||
|
|
1de4bc11eb | ||
|
|
04a7656afa | ||
|
|
77ec7e76e9 | ||
|
|
9ca7be081a | ||
|
|
fb36346f0b | ||
|
|
95679f3fb9 | ||
|
|
addd4124c1 | ||
|
|
74b79d9faa | ||
|
|
9daa05ef02 | ||
|
|
5ee884e0a2 | ||
|
|
dba7f4e6c0 | ||
|
|
8221514805 | ||
|
|
2ae3effc0e | ||
|
|
5b450ff18f | ||
|
|
92be6e9340 | ||
|
|
8884aeec11 | ||
|
|
8e15eda4e3 | ||
|
|
0dbf220655 | ||
|
|
fe69d59ef1 | ||
|
|
1e1f8350b5 | ||
|
|
afa2d03b56 | ||
|
|
1293d7fe28 | ||
|
|
62e879f2de | ||
|
|
a771994f6f | ||
|
|
8c2ba051a0 | ||
|
|
ae2f289122 | ||
|
|
703d63d106 | ||
|
|
2d7b024293 | ||
|
|
26906f6709 | ||
|
|
e9ec97403f | ||
|
|
34fb72707b | ||
|
|
fb7eb64c3a | ||
|
|
ef65720999 | ||
|
|
3bc71d6798 | ||
|
|
c4be269fb7 | ||
|
|
1238edfe2b | ||
|
|
747fd23b96 | ||
|
|
022f7a3ef1 | ||
|
|
e0cad8600c | ||
|
|
9510ebbc31 | ||
|
|
b63f2fd393 | ||
|
|
95a0e9f03b | ||
|
|
8265dd7f02 | ||
|
|
67feb19cab | ||
|
|
614e847de0 | ||
|
|
c2ff4b6a0b | ||
|
|
b273f1039c | ||
|
|
bea0c6a1f0 | ||
|
|
dcfaa361ec | ||
|
|
9904e166d2 | ||
|
|
5f02769012 | ||
|
|
19e5636dd2 | ||
|
|
9a220f89d9 | ||
|
|
1c76ec813c | ||
|
|
d36c189430 | ||
|
|
61e2ace5c8 | ||
|
|
d6a3f21754 | ||
|
|
4e54d7f66f | ||
|
|
f2dff038cd | ||
|
|
a7b30640e0 | ||
|
|
45048b1f46 | ||
|
|
498129314a | ||
|
|
3ee4b4a61e | ||
|
|
ee59c0f22e | ||
|
|
c56134cd92 | ||
|
|
b3d1b66a06 | ||
|
|
c9bb81bf63 | ||
|
|
d176810dae | ||
|
|
17bdca71b1 | ||
|
|
22fd3730df | ||
|
|
23d1271949 | ||
|
|
bf6aa3e8ae | ||
|
|
75fcfde892 | ||
|
|
89be27995e | ||
|
|
6738dc6cdc | ||
|
|
9ff0decb98 | ||
|
|
717d3717ba | ||
|
|
fdbca869a8 | ||
|
|
aafa750ca3 | ||
|
|
afe2bac672 | ||
|
|
b6f5d72db5 | ||
|
|
f0021a7601 | ||
|
|
ea5bfbb346 | ||
|
|
38fbcf4457 | ||
|
|
7c43e8f349 | ||
|
|
93df4e3dd9 | ||
|
|
f7d45a52a7 | ||
|
|
328f96d115 | ||
|
|
22338a72d8 | ||
|
|
256dedcb7c | ||
|
|
95d2438b84 | ||
|
|
f8d27e165e | ||
|
|
e2f024ae29 | ||
|
|
9c28fbeaf8 | ||
|
|
6458eb090a | ||
|
|
f1ca2b6958 | ||
|
|
60f031e7e3 | ||
|
|
e6a44ee0e1 | ||
|
|
7a3a641a07 | ||
|
|
1021efb239 | ||
|
|
edbe6509e1 | ||
|
|
44a6253bfc | ||
|
|
3d6e5df71e | ||
|
|
2733d2e58c | ||
|
|
0c78673870 | ||
|
|
d618948429 | ||
|
|
7584704d22 | ||
|
|
4a4658cc54 | ||
|
|
e9875f1a24 | ||
|
|
cc11257d80 | ||
|
|
4fd64601ef | ||
|
|
7eea490adf | ||
|
|
b2b12f7b93 | ||
|
|
66d026cffa | ||
|
|
5a15a20dd6 | ||
|
|
70450a9725 | ||
|
|
470a3c6a09 | ||
|
|
15f8b2f15d | ||
|
|
717cbe7630 | ||
|
|
d34075d29d | ||
|
|
80d4d66fb5 | ||
|
|
6b20b46761 | ||
|
|
cd764a4a3c | ||
|
|
5422b3d8bd | ||
|
|
0273390d79 | ||
|
|
8f7242b939 | ||
|
|
246dee63b4 | ||
|
|
697918a73a | ||
|
|
67e480220e | ||
|
|
5784eb008e | ||
|
|
82d66c4d57 | ||
|
|
ab8671cc3b | ||
|
|
2e52dda208 | ||
|
|
1ab08d0ae1 | ||
|
|
702be0ed43 | ||
|
|
4067543701 | ||
|
|
074689c60e | ||
|
|
2beb715df3 | ||
|
|
7ea2597899 | ||
|
|
34a8f92865 | ||
|
|
6e2a2de13f | ||
|
|
e073232352 | ||
|
|
6f49b4d23e | ||
|
|
97cad20ab1 | ||
|
|
9300a3d9ce | ||
|
|
28b7f80a8f | ||
|
|
a06d23753a | ||
|
|
98d66b3e6c | ||
|
|
6e59583dea | ||
|
|
2f0571e384 | ||
|
|
64a6074e02 | ||
|
|
0b7cb4cf41 | ||
|
|
e620832e33 | ||
|
|
f82e4af0e3 | ||
|
|
02baa793f8 | ||
|
|
9311a9e7f9 | ||
|
|
00a60ad6ab | ||
|
|
e740c25211 | ||
|
|
79c6b63c56 | ||
|
|
bc914566dd | ||
|
|
2c6ccfb23d | ||
|
|
646767e712 | ||
|
|
754b14d349 | ||
|
|
38c18149aa | ||
|
|
35b448a274 | ||
|
|
c1e63b5a5d | ||
|
|
37f984829a | ||
|
|
65942021d0 | ||
|
|
ecef20d52b | ||
|
|
1a092bb200 | ||
|
|
a880f801fb | ||
|
|
f00360e81c | ||
|
|
1737163364 | ||
|
|
f9a3c3d266 | ||
|
|
7db04e4359 | ||
|
|
934629d829 | ||
|
|
cd65bb057d | ||
|
|
f2d94f1ed6 | ||
|
|
e29281ce39 | ||
|
|
1e719a8738 | ||
|
|
92926487b6 | ||
|
|
69f744131a | ||
|
|
83cd9bdd00 | ||
|
|
1130f4f114 | ||
|
|
d99d9008dd | ||
|
|
78f61b2396 | ||
|
|
32829bbf15 | ||
|
|
a2d3046070 | ||
|
|
95641e398a | ||
|
|
97c39d07e8 | ||
|
|
14fa2fab71 | ||
|
|
ef8c31ae52 | ||
|
|
353080867a | ||
|
|
4d09b895d4 | ||
|
|
6c00d66334 | ||
|
|
77de187207 | ||
|
|
79eec13856 | ||
|
|
562c6fa9a4 | ||
|
|
9fa3ddd254 | ||
|
|
d11a0b9b8a | ||
|
|
41cf3fbe89 | ||
|
|
ac12ab93e5 | ||
|
|
8b4e978cf4 | ||
|
|
c15f22400e | ||
|
|
1773116313 | ||
|
|
62424fbad1 | ||
|
|
d07bd1aa29 | ||
|
|
8433df09e3 | ||
|
|
0634898cb6 | ||
|
|
2ebafc3eeb | ||
|
|
8288559e44 | ||
|
|
2caf3f0174 | ||
|
|
e88e48906d | ||
|
|
8fb5153983 | ||
|
|
03725b0ab4 | ||
|
|
b0896ea465 | ||
|
|
654e9a79c5 | ||
|
|
7f541e2623 | ||
|
|
198e905050 | ||
|
|
3744018af8 | ||
|
|
a21ba04e95 | ||
|
|
bf4d39421b | ||
|
|
2e4935715f | ||
|
|
cf8ce9642a |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
[*.{kt,kts}]
|
||||
ij_kotlin_imports_layout = *
|
||||
ktlint_code_style = intellij_idea
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
@@ -64,7 +64,7 @@ body:
|
||||
description: 您可以通过设置-高级-导出日志来获得日志文件 / You can get logs file in Settings - Advanced - Dump logcat
|
||||
placeholder: 06-15 17:44:53.704 23382 23382 E ...
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: more
|
||||
@@ -79,6 +79,15 @@ body:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: site
|
||||
attributes:
|
||||
label: 浏览站点 / Browsing site
|
||||
description: E-Hentai / ExHentai
|
||||
placeholder: E-Hentai
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
|
||||
+143
-21
@@ -14,12 +14,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
@@ -54,39 +54,161 @@ jobs:
|
||||
path: |
|
||||
app/.cxx
|
||||
app/src/main/cpp/nettle/nettle
|
||||
key: ${{ runner.os }}-cmake-${{ github.run_id }}
|
||||
key: ${{ runner.os }}-cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
|
||||
restore-keys: ${{ runner.os }}-cmake-
|
||||
|
||||
- name: Gradle Build
|
||||
run: ./gradlew assembleRelease
|
||||
- name: Build OSS
|
||||
run: ./gradlew assembleDefaultOssRelease
|
||||
|
||||
- name: Update ARM64
|
||||
- name: Upload OSS ARM64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/release/app-arm64-v8a-release.apk
|
||||
name: default-oss-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultOss/release/app-default-oss-arm64-v8a-release.apk
|
||||
|
||||
- name: Update x86_64
|
||||
- name: Upload OSS x86_64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/release/app-x86_64-release.apk
|
||||
name: default-oss-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultOss/release/app-default-oss-x86_64-release.apk
|
||||
|
||||
- name: Update ARM32
|
||||
- name: Upload OSS ARM32
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/release/app-armeabi-v7a-release.apk
|
||||
name: default-oss-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultOss/release/app-default-oss-armeabi-v7a-release.apk
|
||||
|
||||
- name: Update x86
|
||||
- name: Upload OSS x86
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/release/app-x86-release.apk
|
||||
name: default-oss-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultOss/release/app-default-oss-x86-release.apk
|
||||
|
||||
- name: Update mapping
|
||||
- name: Upload OSS mapping
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/release/mapping.txt
|
||||
retention-days: 5
|
||||
name: default-oss-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/defaultOssRelease/mapping.txt
|
||||
|
||||
- name: Upload OSS native debug symbols
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-oss-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/defaultOssRelease/native-debug-symbols.zip
|
||||
|
||||
- name: Build GMS
|
||||
run: ./gradlew assembleDefaultGmsRelease
|
||||
|
||||
- name: Upload GMS ARM64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultGms/release/app-default-gms-arm64-v8a-release.apk
|
||||
|
||||
- name: Upload GMS x86_64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultGms/release/app-default-gms-x86_64-release.apk
|
||||
|
||||
- name: Upload GMS ARM32
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultGms/release/app-default-gms-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload GMS x86
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/defaultGms/release/app-default-gms-x86-release.apk
|
||||
|
||||
- name: Upload GMS mapping
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/defaultGmsRelease/mapping.txt
|
||||
|
||||
- name: Upload GMS native debug symbols
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: default-gms-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/defaultGmsRelease/native-debug-symbols.zip
|
||||
|
||||
- name: Build Marshmallow OSS
|
||||
run: ./gradlew assembleMarshmallowOssRelease
|
||||
|
||||
- name: Upload Marshmallow OSS ARM64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-oss-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-arm64-v8a-release.apk
|
||||
|
||||
- name: Upload Marshmallow OSS x86_64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-oss-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86_64-release.apk
|
||||
|
||||
- name: Upload Marshmallow OSS ARM32
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-oss-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload Marshmallow OSS x86
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-oss-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86-release.apk
|
||||
|
||||
- name: Upload Marshmallow OSS mapping
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-oss-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/marshmallowOssRelease/mapping.txt
|
||||
|
||||
- name: Upload Marshmallow OSS native debug symbols
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-oss-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/marshmallowOssRelease/native-debug-symbols.zip
|
||||
|
||||
- name: Build Marshmallow GMS
|
||||
run: ./gradlew assembleMarshmallowGmsRelease
|
||||
|
||||
- name: Upload Marshmallow GMS ARM64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-gms-arm64-v8a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-arm64-v8a-release.apk
|
||||
|
||||
- name: Upload Marshmallow GMS x86_64
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-gms-x86_64-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86_64-release.apk
|
||||
|
||||
- name: Upload Marshmallow GMS ARM32
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-gms-armeabi-v7a-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-armeabi-v7a-release.apk
|
||||
|
||||
- name: Upload Marshmallow GMS x86
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-gms-x86-${{ github.sha }}
|
||||
path: app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86-release.apk
|
||||
|
||||
- name: Upload Marshmallow GMS mapping
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-gms-mapping-${{ github.sha }}
|
||||
path: app/build/outputs/mapping/marshmallowGmsRelease/mapping.txt
|
||||
|
||||
- name: Upload Marshmallow GMS native debug symbols
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: marshmallow-gms-native-debug-symbols-${{ github.sha }}
|
||||
path: app/build/outputs/native-debug-symbols/marshmallowGmsRelease/native-debug-symbols.zip
|
||||
@@ -0,0 +1,22 @@
|
||||
name: 'Lock Threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: lock-threads
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
with:
|
||||
issue-inactive-days: '1'
|
||||
include-any-issue-labels: 'duplicate, wontfix'
|
||||
process-only: 'issues'
|
||||
@@ -11,12 +11,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
path: |
|
||||
app/.cxx
|
||||
app/src/main/cpp/nettle/nettle
|
||||
key: ${{ runner.os }}-cmake-${{ github.run_id }}
|
||||
key: ${{ runner.os }}-cmake-${{ hashFiles('gradle/libs.versions.toml', 'app/build.gradle.kts', 'app/src/main/cpp/CMakeLists.txt') }}
|
||||
restore-keys: ${{ runner.os }}-cmake-
|
||||
|
||||
- name: Gradle Build
|
||||
@@ -45,20 +45,71 @@ jobs:
|
||||
|
||||
- name: Rename Apks
|
||||
run: |
|
||||
cp app/build/outputs/apk/release/app-universal-release.apk app/build/outputs/apk/release/EhViewer-universal.apk
|
||||
cp app/build/outputs/apk/release/app-arm64-v8a-release.apk app/build/outputs/apk/release/EhViewer-arm64-v8a.apk
|
||||
cp app/build/outputs/apk/release/app-x86_64-release.apk app/build/outputs/apk/release/EhViewer-x86_64.apk
|
||||
cp app/build/outputs/apk/release/app-x86-release.apk app/build/outputs/apk/release/EhViewer-x86.apk
|
||||
cp app/build/outputs/apk/release/app-armeabi-v7a-release.apk app/build/outputs/apk/release/EhViewer-armeabi-v7a.apk
|
||||
mv app/build/outputs/apk/defaultOss/release/app-default-oss-universal-release.apk EhViewer-${{ github.ref_name }}-default-oss-universal.apk
|
||||
mv app/build/outputs/apk/defaultOss/release/app-default-oss-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-oss-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/defaultOss/release/app-default-oss-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-oss-x86_64.apk
|
||||
mv app/build/outputs/apk/defaultOss/release/app-default-oss-x86-release.apk EhViewer-${{ github.ref_name }}-default-oss-x86.apk
|
||||
mv app/build/outputs/apk/defaultOss/release/app-default-oss-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-oss-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/defaultOssRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-oss-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/defaultOssRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-oss-native-debug-symbols.zip
|
||||
|
||||
mv app/build/outputs/apk/defaultGms/release/app-default-gms-universal-release.apk EhViewer-${{ github.ref_name }}-default-gms-universal.apk
|
||||
mv app/build/outputs/apk/defaultGms/release/app-default-gms-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-default-gms-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/defaultGms/release/app-default-gms-x86_64-release.apk EhViewer-${{ github.ref_name }}-default-gms-x86_64.apk
|
||||
mv app/build/outputs/apk/defaultGms/release/app-default-gms-x86-release.apk EhViewer-${{ github.ref_name }}-default-gms-x86.apk
|
||||
mv app/build/outputs/apk/defaultGms/release/app-default-gms-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-default-gms-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/defaultGmsRelease/mapping.txt EhViewer-${{ github.ref_name }}-default-gms-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/defaultGmsRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-default-gms-native-debug-symbols.zip
|
||||
|
||||
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-universal.apk
|
||||
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-x86_64.apk
|
||||
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-x86.apk
|
||||
mv app/build/outputs/apk/marshmallowOss/release/app-marshmallow-oss-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-oss-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/marshmallowOssRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-oss-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/marshmallowOssRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-oss-native-debug-symbols.zip
|
||||
|
||||
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-universal-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-universal.apk
|
||||
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-arm64-v8a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-arm64-v8a.apk
|
||||
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86_64-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-x86_64.apk
|
||||
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-x86-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-x86.apk
|
||||
mv app/build/outputs/apk/marshmallowGms/release/app-marshmallow-gms-armeabi-v7a-release.apk EhViewer-${{ github.ref_name }}-marshmallow-gms-armeabi-v7a.apk
|
||||
mv app/build/outputs/mapping/marshmallowGmsRelease/mapping.txt EhViewer-${{ github.ref_name }}-marshmallow-gms-mapping.txt
|
||||
mv app/build/outputs/native-debug-symbols/marshmallowGmsRelease/native-debug-symbols.zip EhViewer-${{ github.ref_name }}-marshmallow-gms-native-debug-symbols.zip
|
||||
|
||||
- name: Releases
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: Bump Version
|
||||
files: |
|
||||
app/build/outputs/apk/release/EhViewer-universal.apk
|
||||
app/build/outputs/apk/release/EhViewer-arm64-v8a.apk
|
||||
app/build/outputs/apk/release/EhViewer-x86_64.apk
|
||||
app/build/outputs/apk/release/EhViewer-x86.apk
|
||||
app/build/outputs/apk/release/EhViewer-armeabi-v7a.apk
|
||||
app/build/outputs/mapping/release/mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-default-oss-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-default-oss-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-oss-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-default-oss-x86.apk
|
||||
EhViewer-${{ github.ref_name }}-default-oss-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-oss-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-default-oss-native-debug-symbols.zip
|
||||
|
||||
EhViewer-${{ github.ref_name }}-default-gms-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-default-gms-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-gms-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-default-gms-x86.apk
|
||||
EhViewer-${{ github.ref_name }}-default-gms-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-default-gms-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-default-gms-native-debug-symbols.zip
|
||||
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-oss-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-oss-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-oss-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-oss-x86.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-oss-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-oss-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-oss-native-debug-symbols.zip
|
||||
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-gms-universal.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-gms-arm64-v8a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-gms-x86_64.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-gms-x86.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-gms-armeabi-v7a.apk
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-gms-mapping.txt
|
||||
EhViewer-${{ github.ref_name }}-marshmallow-gms-native-debug-symbols.zip
|
||||
@@ -6,5 +6,4 @@
|
||||
*.iml
|
||||
.idea
|
||||
/captures
|
||||
release
|
||||
google-services.json
|
||||
@@ -0,0 +1,40 @@
|
||||
# 变更日志
|
||||
|
||||
## 1.9.0
|
||||
|
||||
### 重大变更
|
||||
|
||||
* 除阅读器外全部迁移至 Compose
|
||||
|
||||
### 新功能
|
||||
|
||||
* 设置-高级 中增加实验性滑动手势敏感度设置
|
||||
* 支持通过滑动手势打开右侧抽屉
|
||||
* 支持预见式返回动画,相关设置位于 设置-高级
|
||||
|
||||
### 改进
|
||||
|
||||
* \[阅读器] 画廊页数过多时隐藏进度条刻度并禁用触感反馈
|
||||
* 优化原图下载重试策略
|
||||
* 优化小屏设备上的搜索类别布局
|
||||
* 优化过渡动画
|
||||
* 优化历史记录删除动画
|
||||
* 设置-EH 中的缩略图大小设置改为缩略图列数
|
||||
|
||||
### Bug 修复
|
||||
|
||||
* 修复点击通知栏的下载通知闪退 [#175](https://github.com/FooIbar/EhViewer/issues/175)
|
||||
* 修复收藏状态显示错误 [#189](https://github.com/FooIbar/EhViewer/issues/189)
|
||||
* 修复双击导航按钮后无法打开导航抽屉
|
||||
* 修复某些情况下闪退 [#142](https://github.com/FooIbar/EhViewer/issues/142) [#208](https://github.com/FooIbar/EhViewer/issues/208)
|
||||
* 修复收藏界面点击重试崩溃 [#193](https://github.com/FooIbar/EhViewer/issues/193)
|
||||
* 修复本地收藏滚动位置丢失 [#192](https://github.com/FooIbar/EhViewer/issues/192)
|
||||
* 修复手速过快页面切换过快使加载圈圈不自动消失 [#209](https://github.com/FooIbar/EhViewer/issues/209)
|
||||
* 修复打开并关闭右侧抽屉后,状态栏一片白色 [#101](https://github.com/FooIbar/EhViewer/issues/101)
|
||||
* 修复使用深色主题时,切换界面会闪过亮色界面 [#121](https://github.com/FooIbar/EhViewer/issues/121)
|
||||
* \[Marshmallow] 修复小内存设备上图片解码失败 [#229](https://github.com/FooIbar/EhViewer/issues/229) [#372](https://github.com/FooIbar/EhViewer/issues/372)
|
||||
* 修复已舍弃画廊上传者解析
|
||||
|
||||
### 已知问题
|
||||
|
||||
* 画廊信息对话框无法通过返回关闭 [#413](https://github.com/FooIbar/EhViewer/issues/413)
|
||||
@@ -1,15 +1,11 @@
|
||||
# Maintenance Mode
|
||||
|
||||
The project is in maintenance mode, will only receive bugfixes.
|
||||
|
||||
<p align="right">
|
||||
<strong>English</strong>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/docs/README/zh-cn.md">
|
||||
<a href="/docs/README/zh-cn.md">
|
||||
简体中文
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/docs/README/zh-tw.md">
|
||||
<a href="/docs/README/zh-tw.md">
|
||||
正體中文
|
||||
</a>
|
||||
</p>
|
||||
@@ -23,15 +19,12 @@ The project is in maintenance mode, will only receive bugfixes.
|
||||
<a href="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/FooIbar/EhViewer/actions/workflows/ci.yml/badge.svg" alt="Github Actions">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/blob/1.8.x.x/LICENSE">
|
||||
<a href="/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/FooIbar/EhViewer" alt="LICENSE">
|
||||
</a>
|
||||
<a href="https://www.codefactor.io/repository/github/FooIbar/EhViewer">
|
||||
<img src="https://www.codefactor.io/repository/github/FooIbar/EhViewer/badge" alt="CodeFactor">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img src="https://img.shields.io/github/downloads/FooIbar/EhViewer/total.svg" alt="Downloads">
|
||||
</a>
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img src="https://img.shields.io/github/v/release/FooIbar/EhViewer" alt="Release">
|
||||
</a>
|
||||
@@ -42,23 +35,23 @@ The project is in maintenance mode, will only receive bugfixes.
|
||||
|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="https://github.com/FooIbar/EhViewer#description">
|
||||
<a href="#description">
|
||||
Description
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer#download">
|
||||
<a href="#download">
|
||||
Download
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer#screenshot">
|
||||
<a href="#screenshot">
|
||||
Screenshot
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer#thanks">
|
||||
<a href="#thanks">
|
||||
Thanks
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/FooIbar/EhViewer#license">
|
||||
<a href="#license">
|
||||
License
|
||||
</a>
|
||||
</h3>
|
||||
@@ -73,6 +66,17 @@ and [Dynamic Color](https://m3.material.io/styles/color/dynamic-color/overview)
|
||||
|
||||
# Download
|
||||
|
||||
| Flavor | Feature |
|
||||
|-------------|-----------------------------------|
|
||||
| Default | Android 9+, full support |
|
||||
| Marshmallow | Android 6.0-8.1, limited support* |
|
||||
| OSS | FOSS dependencies** |
|
||||
| GMS | Proprietary GMS component |
|
||||
|
||||
\* Archives and GIFs are not supported
|
||||
|
||||
** Fall back to OkHttp on devices that don't support [HttpEngine](https://developer.android.com/reference/android/net/http/HttpEngine)
|
||||
|
||||
<a href="https://github.com/FooIbar/EhViewer/releases">
|
||||
<img alt="Get it on GitHub" src="https://github.com/Ehviewer-Overhauled/Art/blob/master/get-it-on-github.svg" width="200px"/>
|
||||
</a>
|
||||
@@ -89,14 +93,13 @@ Here is the libraries
|
||||
- [Arrow](https://arrow-kt.io/)
|
||||
- [AOSP & AndroidX](http://source.android.com/)
|
||||
- [Kotlin & KotlinX](https://kotlinlang.org/)
|
||||
- [FullDraggableDrawer](https://github.com/PureWriter/FullDraggableDrawer)
|
||||
- [MCA](https://github.com/material-components/material-components-android)
|
||||
- [material-design-icons](https://github.com/google/material-design-icons)
|
||||
- [Okhttp](https://square.github.io/okhttp/)
|
||||
- [RikkaX](https://github.com/RikkaApps/RikkaX)
|
||||
- [Libarchive](http://www.libarchive.org/)
|
||||
- [MDC-Android](https://github.com/material-components/material-components-android)
|
||||
- [Material Icons](https://github.com/google/material-design-icons)
|
||||
- [OkHttp](https://square.github.io/okhttp/)
|
||||
- [Coil](https://coil-kt.github.io/coil/)
|
||||
- [Jsoup](https://jsoup.org/)
|
||||
- [Compose Destinations](https://composedestinations.rafaelcosta.xyz/)
|
||||
- [libarchive](http://www.libarchive.org/)
|
||||
- [jsoup](https://jsoup.org/)
|
||||
|
||||
Specially thanks [Tachiyomi](https://tachiyomi.org/) for its reader implementation!
|
||||
|
||||
|
||||
+123
-111
@@ -8,21 +8,21 @@ val isRelease: Boolean
|
||||
get() = gradle.startParameter.taskNames.any { it.contains("Release") }
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization")
|
||||
id("com.google.devtools.ksp")
|
||||
id("com.diffplug.spotless")
|
||||
id("com.mikepenz.aboutlibraries.plugin")
|
||||
id("org.mozilla.rust-android-gradle.rust-android")
|
||||
id("dev.shreyaspatil.compose-compiler-report-generator")
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.spotless)
|
||||
alias(libs.plugins.aboutlibrariesPlugin)
|
||||
alias(libs.plugins.rustAndroidPlugin)
|
||||
alias(libs.plugins.composeCompilerReportGenerator)
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 34
|
||||
buildToolsVersion = "34.0.0"
|
||||
ndkVersion = "26.0.10792818"
|
||||
ndkVersion = "26.1.10909125"
|
||||
|
||||
splits {
|
||||
abi {
|
||||
@@ -32,7 +32,7 @@ android {
|
||||
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
|
||||
isUniversalApk = true
|
||||
} else {
|
||||
include("arm64-v8a")
|
||||
include("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,8 @@ android {
|
||||
applicationId = "moe.tarsin.ehviewer"
|
||||
minSdk = 28
|
||||
targetSdk = 34
|
||||
versionCode = 180043
|
||||
versionName = "1.8.10.0-SNAPSHOT"
|
||||
versionCode = 180046
|
||||
versionName = "1.9.0"
|
||||
resourceConfigurations.addAll(
|
||||
listOf(
|
||||
"zh",
|
||||
@@ -84,6 +84,38 @@ android {
|
||||
)
|
||||
buildConfigField("String", "COMMIT_SHA", "\"$commitSha\"")
|
||||
buildConfigField("String", "REPO_NAME", "\"$repoName\"")
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += listOf("api", "oss")
|
||||
|
||||
productFlavors {
|
||||
create("default") {
|
||||
dimension = "api"
|
||||
}
|
||||
create("marshmallow") {
|
||||
dimension = "api"
|
||||
minSdk = 23
|
||||
applicationIdSuffix = ".m"
|
||||
versionNameSuffix = "-M"
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
targets += "noop"
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
}
|
||||
create("oss") {
|
||||
dimension = "oss"
|
||||
}
|
||||
create("gms") {
|
||||
dimension = "oss"
|
||||
versionNameSuffix = "-gms"
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
@@ -92,19 +124,12 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs = listOf(
|
||||
// https://kotlinlang.org/docs/compiler-reference.html#progressive
|
||||
"-progressive",
|
||||
"-Xjvm-default=all",
|
||||
"-Xlambdas=indy",
|
||||
"-XXLanguage:+BreakContinueInInlineLambdas",
|
||||
|
||||
"-opt-in=coil.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=androidx.compose.foundation.layout.ExperimentalLayoutApi",
|
||||
@@ -120,13 +145,15 @@ 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:intrinsicRemember=true",
|
||||
)
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = true
|
||||
checkReleaseBuilds = false
|
||||
disable.add("MissingTranslation")
|
||||
disable += setOf("MissingTranslation", "MissingQuantity")
|
||||
fatal += setOf("NewApi", "InlineApi")
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -161,7 +188,7 @@ android {
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.3"
|
||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
||||
}
|
||||
|
||||
namespace = "com.hippo.ehviewer"
|
||||
@@ -169,102 +196,97 @@ android {
|
||||
|
||||
dependencies {
|
||||
// https://developer.android.com/jetpack/androidx/releases/activity
|
||||
implementation("androidx.activity:activity-compose:1.8.0-rc01")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.biometric:biometric-ktx:1.2.0-alpha05")
|
||||
implementation("androidx.browser:browser:1.6.0")
|
||||
implementation("androidx.collection:collection-ktx:1.3.0-rc01")
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.biometric)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/compose-material3
|
||||
// api(platform("androidx.compose:compose-bom:2023.05.00"))
|
||||
api(platform("dev.chrisbanes.compose:compose-bom:2023.07.00-alpha02"))
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material3:material3-window-size-class")
|
||||
implementation("androidx.compose.ui:ui-util")
|
||||
api(platform(libs.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation(libs.compose.destinations.core)
|
||||
ksp(libs.compose.destinations.compiler)
|
||||
|
||||
implementation("androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha12")
|
||||
implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.7.0-alpha05")
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
|
||||
implementation(libs.androidx.constraintlayout.compose)
|
||||
implementation(libs.androidx.coordinatorlayout)
|
||||
// https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.6.2")
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/navigation
|
||||
val navigation = "2.7.3"
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:$navigation")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:$navigation")
|
||||
implementation("androidx.navigation:navigation-compose:$navigation")
|
||||
implementation(libs.bundles.androidx.navigation)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/paging
|
||||
val paging = "3.2.1"
|
||||
implementation("androidx.paging:paging-compose:$paging")
|
||||
implementation("androidx.paging:paging-runtime-ktx:$paging")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.1")
|
||||
implementation("androidx.recyclerview:recyclerview-selection:1.2.0-alpha01")
|
||||
implementation(libs.bundles.androidx.paging)
|
||||
|
||||
implementation(libs.androidx.recyclerview)
|
||||
|
||||
// https://developer.android.com/jetpack/androidx/releases/room
|
||||
val room = "2.6.0-rc01"
|
||||
ksp("androidx.room:room-compiler:$room")
|
||||
implementation("androidx.room:room-paging:$room")
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.paging)
|
||||
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("com.drakeet.drawer:drawer:1.0.3")
|
||||
implementation("com.github.chrisbanes:PhotoView:2.3.0") // Dead Dependency
|
||||
implementation("com.github.tachiyomiorg:DirectionalViewPager:1.0.0") // Dead Dependency
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.photoview) // Dead Dependency
|
||||
implementation(libs.directionalviewpager) // Dead Dependency
|
||||
// https://github.com/google/accompanist/releases
|
||||
val accompanist = "0.32.0"
|
||||
implementation("com.google.accompanist:accompanist-themeadapter-material3:$accompanist")
|
||||
implementation("com.google.accompanist:accompanist-webview:$accompanist")
|
||||
implementation("com.google.android.material:material:1.11.0-alpha03")
|
||||
implementation(libs.bundles.accompanist)
|
||||
implementation(libs.material)
|
||||
|
||||
val splitties = "3.0.0"
|
||||
implementation("com.louiscad.splitties:splitties-appctx:$splitties")
|
||||
implementation("com.louiscad.splitties:splitties-systemservices:$splitties")
|
||||
implementation("com.louiscad.splitties:splitties-preferences:$splitties")
|
||||
implementation("com.louiscad.splitties:splitties-arch-room:$splitties")
|
||||
implementation(libs.bundles.splitties)
|
||||
|
||||
// https://square.github.io/okhttp/changelogs/changelog/
|
||||
implementation(platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.11"))
|
||||
implementation("com.squareup.okhttp3:okhttp-android")
|
||||
implementation("com.squareup.okhttp3:okhttp-coroutines")
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.bundles.okhttp)
|
||||
|
||||
implementation("com.squareup.okio:okio-jvm:3.5.0")
|
||||
implementation(libs.okio.jvm)
|
||||
|
||||
implementation("com.mikepenz:aboutlibraries-core:10.9.1")
|
||||
implementation(libs.diff)
|
||||
|
||||
implementation("dev.chrisbanes.insetter:insetter:0.6.1") // Dead Dependency
|
||||
implementation("dev.rikka.rikkax.core:core-ktx:1.4.1")
|
||||
implementation("dev.rikka.rikkax.insets:insets:1.3.0")
|
||||
implementation("dev.rikka.rikkax.layoutinflater:layoutinflater:1.3.0")
|
||||
implementation(libs.aboutlibraries.core)
|
||||
|
||||
implementation(platform("io.arrow-kt:arrow-stack:1.2.1"))
|
||||
implementation("io.arrow-kt:arrow-fx-coroutines")
|
||||
implementation(libs.insetter) // Dead Dependency
|
||||
|
||||
implementation(libs.reorderable)
|
||||
|
||||
implementation(platform(libs.arrow.stack))
|
||||
implementation(libs.arrow.fx.coroutines)
|
||||
|
||||
// https://coil-kt.github.io/coil/changelog/
|
||||
implementation(platform("io.coil-kt:coil-bom:2.4.0"))
|
||||
implementation("io.coil-kt:coil-compose")
|
||||
implementation("io.coil-kt:coil-gif")
|
||||
implementation(platform(libs.coil.bom))
|
||||
implementation(libs.bundles.coil)
|
||||
|
||||
val ktor = "2.3.4"
|
||||
implementation("io.ktor:ktor-io-jvm:$ktor")
|
||||
implementation("io.ktor:ktor-utils-jvm:$ktor")
|
||||
implementation(libs.bundles.ktor)
|
||||
|
||||
val serialization = "1.6.0"
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-cbor:$serialization")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:$serialization")
|
||||
implementation(libs.bundles.kotlinx.serialization)
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("org.jsoup:jsoup:1.16.1")
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
val chunker = "4.0.0"
|
||||
debugImplementation("com.github.chuckerteam.chucker:library:$chunker")
|
||||
releaseImplementation("com.github.chuckerteam.chucker:library-no-op:$chunker")
|
||||
debugImplementation(libs.chucker)
|
||||
releaseImplementation(libs.chucker.nop)
|
||||
|
||||
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
|
||||
coreLibraryDesugaring(libs.desugar)
|
||||
|
||||
"gmsImplementation"(libs.bundles.cronet)
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
// Workaround IME won't show again once hidden.
|
||||
// Google issue-tracker link?
|
||||
force("androidx.compose.ui:ui-text-android:1.6.0-alpha08")
|
||||
exclude("androidx.navigation", "navigation-compose")
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
ksp {
|
||||
@@ -275,40 +297,30 @@ ksp {
|
||||
aboutLibraries {
|
||||
duplicationMode = MERGE
|
||||
duplicationRule = GROUP
|
||||
exclusionPatterns = listOf(
|
||||
"androidx\\..*".toPattern(),
|
||||
".*annotations".toPattern(),
|
||||
"com\\.google\\.code\\..*".toPattern(),
|
||||
"com\\.google\\.guava:listenablefuture".toPattern(),
|
||||
"com\\.google\\.protobuf:protobuf-javalite".toPattern(),
|
||||
"org\\.checkerframework:checker-qual".toPattern(),
|
||||
"org\\.chromium\\.net:cronet-embedded".toPattern(),
|
||||
"org\\.slf4j:slf4j-api".toPattern(),
|
||||
)
|
||||
}
|
||||
|
||||
cargo {
|
||||
module = "src/main/rust"
|
||||
libname = "ehviewer_rust"
|
||||
targets = if (isRelease) listOf("arm", "x86", "arm64", "x86_64") else listOf("arm64")
|
||||
targets = if (isRelease) listOf("arm", "x86", "arm64", "x86_64") else listOf("arm64", "x86_64")
|
||||
if (isRelease) profile = "release"
|
||||
}
|
||||
|
||||
val ktlintVersion = libs.versions.ktlint.get()
|
||||
|
||||
spotless {
|
||||
kotlin {
|
||||
ktlint()
|
||||
// https://github.com/diffplug/spotless/issues/111
|
||||
target("src/**/*.kt")
|
||||
ktlint(ktlintVersion)
|
||||
}
|
||||
kotlinGradle {
|
||||
ktlint().editorConfigOverride(
|
||||
mapOf(
|
||||
"ktlint_standard_multiline-expression-wrapping" to "disabled",
|
||||
),
|
||||
)
|
||||
ktlint(ktlintVersion)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.configureEach {
|
||||
if ((name == "mergeDebugJniLibFolders" || name == "mergeReleaseJniLibFolders")) {
|
||||
if (name.startsWith("merge") && name.endsWith("JniLibFolders")) {
|
||||
dependsOn("cargoBuild")
|
||||
// fix mergeDebugJniLibFolders UP-TO-DATE
|
||||
inputs.dir(layout.buildDirectory.dir("rustJniLibs/android"))
|
||||
|
||||
Vendored
+11
-21
@@ -1,30 +1,20 @@
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keepclasseswithmembernames,includedescriptorclasses class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# https://issuetracker.google.com/issues/190382641
|
||||
-keepclassmembers class kotlin.SafePublicationLazyImpl {
|
||||
java.lang.Object _value;
|
||||
-keepclassmembers enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
-keep class androidx.viewpager.widget.ViewPager$LayoutParams { int position; }
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keep class com.hippo.ehviewer.client.parser.Torrent { *; }
|
||||
-keep class com.hippo.ehviewer.client.parser.GalleryListResult { *; }
|
||||
-keep class com.hippo.ehviewer.client.parser.Limits { *; }
|
||||
-keep class com.hippo.ehviewer.client.data.BaseGalleryInfo { *; }
|
||||
-keep class com.hippo.ehviewer.ui.scene.SearchBarScene { getBinding(); }
|
||||
# https://issuetracker.google.com/issues/307323842
|
||||
-keepclassmembers class androidx.compose.ui.platform.WindowInfoImpl {
|
||||
setWindowFocused(boolean);
|
||||
}
|
||||
|
||||
# Ktor logger
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
|
||||
-keepattributes LineNumberTable,SourceFile
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
-repackageclasses
|
||||
-allowaccessmodification
|
||||
-overloadaggressively
|
||||
-repackageclasses
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.hippo.ehviewer.cronet
|
||||
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.CHROME_ACCEPT
|
||||
import com.hippo.ehviewer.client.CHROME_ACCEPT_LANGUAGE
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import okio.Buffer
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.chromium.net.CronetEngine
|
||||
import org.chromium.net.CronetException
|
||||
import org.chromium.net.UploadDataProvider
|
||||
import org.chromium.net.UploadDataSink
|
||||
import org.chromium.net.UrlRequest
|
||||
import org.chromium.net.UrlResponseInfo
|
||||
import splitties.init.appCtx
|
||||
|
||||
val cronetHttpClient: CronetEngine = CronetEngine.Builder(appCtx).apply {
|
||||
enableBrotli(true)
|
||||
val cache = (appCtx.cacheDir.toOkioPath() / "http_cache").toFile().apply { mkdirs() }
|
||||
setStoragePath(cache.absolutePath)
|
||||
enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 100 * 1024)
|
||||
setUserAgent(Settings.userAgent)
|
||||
}.build()
|
||||
|
||||
class CronetRequest {
|
||||
lateinit var consumer: (ByteBuffer) -> Unit
|
||||
lateinit var onResponse: CronetRequest.(UrlResponseInfo) -> Unit
|
||||
lateinit var request: UrlRequest
|
||||
lateinit var onError: (Throwable) -> Unit
|
||||
lateinit var readerCont: Continuation<Unit>
|
||||
val callback = object : UrlRequest.Callback() {
|
||||
override fun onRedirectReceived(req: UrlRequest, info: UrlResponseInfo, url: String) {
|
||||
req.followRedirect()
|
||||
}
|
||||
|
||||
override fun onResponseStarted(req: UrlRequest, info: UrlResponseInfo) {
|
||||
onResponse(info)
|
||||
}
|
||||
|
||||
override fun onReadCompleted(req: UrlRequest, info: UrlResponseInfo, data: ByteBuffer) {
|
||||
consumer(data)
|
||||
}
|
||||
|
||||
override fun onSucceeded(req: UrlRequest, info: UrlResponseInfo) {
|
||||
readerCont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun onFailed(req: UrlRequest, info: UrlResponseInfo?, e: CronetException) {
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun cronetRequest(url: String, referer: String? = null, origin: String? = null, conf: UrlRequest.Builder.() -> Unit = {}) = CronetRequest().apply {
|
||||
request = cronetHttpClient.newUrlRequestBuilder(url, callback, cronetHttpClientExecutor).apply {
|
||||
addHeader("Cookie", EhCookieStore.getCookieHeader(url.toHttpUrl()))
|
||||
addHeader("Accept", CHROME_ACCEPT)
|
||||
addHeader("Accept-Language", CHROME_ACCEPT_LANGUAGE)
|
||||
referer?.let { addHeader("Referer", it) }
|
||||
origin?.let { addHeader("Origin", it) }
|
||||
}.apply(conf).build()
|
||||
}
|
||||
|
||||
suspend inline fun <R> CronetRequest.execute(crossinline callback: suspend CronetRequest.(UrlResponseInfo) -> R): R {
|
||||
contract {
|
||||
callsInPlace(callback, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
return coroutineScope {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
onResponse = { launch { cont.resume(callback(it)) } }
|
||||
cont.invokeOnCancellation { request.cancel() }
|
||||
onError = { cont.resumeWithException(it) }
|
||||
request.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun UrlRequest.Builder.withRequestBody(body: RequestBody) {
|
||||
addHeader("Content-Type", body.contentType().toString())
|
||||
val buffer = Buffer().apply { body.writeTo(this) }
|
||||
val provider = object : UploadDataProvider() {
|
||||
override fun getLength() = body.contentLength()
|
||||
override fun read(uploadDataSink: UploadDataSink, byteBuffer: ByteBuffer) {
|
||||
buffer.read(byteBuffer)
|
||||
uploadDataSink.onReadSucceeded(false)
|
||||
}
|
||||
override fun rewind(uploadDataSink: UploadDataSink) {
|
||||
error("OneShot!")
|
||||
}
|
||||
}
|
||||
setUploadDataProvider(provider, cronetHttpClientExecutor)
|
||||
}
|
||||
|
||||
fun UrlRequest.Builder.noCache(): UrlRequest.Builder = disableCache()
|
||||
fun UrlResponseInfo.getHeadersMap(): Map<String, List<String>> = allHeaders
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import com.google.android.gms.net.CronetProviderInstaller
|
||||
import com.hippo.ehviewer.Settings
|
||||
import org.chromium.net.CronetProvider
|
||||
import splitties.init.appCtx
|
||||
|
||||
val isCronetSupported: Boolean
|
||||
get() = Settings.enableCronet && CronetProvider.getAllProviders(appCtx).any { it.isEnabled }.also {
|
||||
if (!it) {
|
||||
CronetProviderInstaller.installProvider(appCtx)
|
||||
}
|
||||
}
|
||||
@@ -23,16 +23,20 @@
|
||||
android:allowBackup="true"
|
||||
android:appCategory="image"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_scheme"
|
||||
android:hasFragileUserData="true"
|
||||
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"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:targetApi="tiramisu">
|
||||
<profileable android:shell="true" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ConfigureActivity"
|
||||
android:exported="false"
|
||||
@@ -44,6 +48,7 @@
|
||||
android:configChanges="screenSize"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/AppTheme.Splash"
|
||||
android:windowSoftInputMode="adjustResize|stateAlwaysHidden">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -134,8 +139,7 @@
|
||||
android:launchMode="singleTask"
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
<activity
|
||||
android:name=".ui.WebViewActivity" />
|
||||
<activity android:name=".ui.WebViewActivity" />
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
|
||||
@@ -24,7 +24,7 @@ set(BUILD_TESTING 0)
|
||||
|
||||
FetchContent_Declare(liblzma
|
||||
GIT_REPOSITORY https://github.com/FooIbar/xz.git
|
||||
GIT_TAG v5.4.4
|
||||
GIT_TAG v5.4.5
|
||||
)
|
||||
|
||||
FetchContent_MakeAvailable(liblzma)
|
||||
@@ -78,3 +78,5 @@ include_directories(${libarchive_SOURCE_DIR}/libarchive)
|
||||
# Build and link our app's native lib
|
||||
add_library(${PROJECT_NAME} SHARED archive.c gifutils.c natsort/strnatcmp.c)
|
||||
target_link_libraries(${PROJECT_NAME} archive_static log)
|
||||
|
||||
add_custom_target("noop")
|
||||
@@ -279,7 +279,7 @@ static int archive_get_ctx(archive_ctx **ctxptr, int idx) {
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_openArchive(JNIEnv *env, jclass thiz, jint fd,
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_openArchive(JNIEnv *env, jclass thiz, jint fd,
|
||||
jlong size) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
@@ -359,7 +359,7 @@ Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_openArchive(JNIEnv *env, jcl
|
||||
#pragma ide diagnostic ignored "UnreachableCode"
|
||||
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_extractToByteBuffer(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToByteBuffer(JNIEnv *env, jclass thiz,
|
||||
jint index) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
@@ -389,7 +389,7 @@ Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_extractToByteBuffer(JNIEnv *
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_closeArchive(JNIEnv *env, jclass thiz) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
for (int i = 0; i < CTX_POOL_SIZE; i++)
|
||||
@@ -408,14 +408,14 @@ Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_closeArchive(JNIEnv *env, jc
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_needPassword(JNIEnv *env, jclass thiz) {
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_needPassword(JNIEnv *env, jclass thiz) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
return need_encrypt;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_providePassword(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_providePassword(JNIEnv *env, jclass thiz,
|
||||
jstring str) {
|
||||
EH_UNUSED(thiz);
|
||||
struct archive_entry *entry;
|
||||
@@ -446,7 +446,7 @@ Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_providePassword(JNIEnv *env,
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_getFilename(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_getFilename(JNIEnv *env, jclass thiz,
|
||||
jint index) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
@@ -462,7 +462,7 @@ Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_getFilename(JNIEnv *env, jcl
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_extractToFd(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_extractToFd(JNIEnv *env, jclass thiz,
|
||||
jint index, jint fd) {
|
||||
EH_UNUSED(env);
|
||||
EH_UNUSED(thiz);
|
||||
@@ -477,7 +477,7 @@ Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_extractToFd(JNIEnv *env, jcl
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_gallery_ArchivePageLoaderKt_releaseByteBuffer(JNIEnv *env, jclass thiz,
|
||||
Java_com_hippo_ehviewer_jni_ArchiveKt_releaseByteBuffer(JNIEnv *env, jclass thiz,
|
||||
jobject buffer) {
|
||||
EH_UNUSED(thiz);
|
||||
void *addr = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <jni.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
#include <unistd.h>
|
||||
#include <android/log.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/mman.h>
|
||||
@@ -11,6 +12,7 @@
|
||||
|
||||
#define GIF_HEADER_87A "GIF87a"
|
||||
#define GIF_HEADER_89A "GIF89a"
|
||||
#define GIF_HEADER_LENGTH 6
|
||||
|
||||
static int FRAME_DELAY_START_MARKER = 0x0021F904;
|
||||
|
||||
@@ -21,7 +23,8 @@ typedef signed char byte;
|
||||
#define DEFAULT_FRAME_DELAY 10
|
||||
|
||||
static inline bool isGif(void *addr) {
|
||||
return !memcmp(addr, GIF_HEADER_87A, 6) || !memcmp(addr, GIF_HEADER_89A, 6);
|
||||
return !memcmp(addr, GIF_HEADER_87A, GIF_HEADER_LENGTH) ||
|
||||
!memcmp(addr, GIF_HEADER_89A, GIF_HEADER_LENGTH);
|
||||
}
|
||||
|
||||
static void doRewrite(byte *addr, size_t size) {
|
||||
@@ -42,20 +45,33 @@ static void doRewrite(byte *addr, size_t size) {
|
||||
}
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL
|
||||
Java_com_hippo_ehviewer_jni_GifUtilsKt_isGif(JNIEnv *env, jclass clazz, jint fd) {
|
||||
byte buffer[GIF_HEADER_LENGTH];
|
||||
return read(fd, buffer, GIF_HEADER_LENGTH) == GIF_HEADER_LENGTH &&
|
||||
isGif(buffer);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_image_ImageKt_rewriteGifSource(JNIEnv *env, jclass clazz, jobject buffer) {
|
||||
Java_com_hippo_ehviewer_jni_GifUtilsKt_rewriteGifSource(JNIEnv *env, jclass clazz, jobject buffer) {
|
||||
byte *addr = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
|
||||
doRewrite(addr, size);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_image_ImageKt_rewriteGifSource2(JNIEnv *env, jclass clazz, jint fd) {
|
||||
JNIEXPORT jobject JNICALL
|
||||
Java_com_hippo_ehviewer_jni_GifUtilsKt_mmap(JNIEnv *env, jclass clazz, jint fd) {
|
||||
struct stat64 st;
|
||||
fstat64(fd, &st);
|
||||
size_t size = st.st_size;
|
||||
byte *addr = mmap64(0, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (addr == MAP_FAILED) return;
|
||||
doRewrite(addr, size);
|
||||
byte *addr = mmap64(0, size, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
|
||||
if (addr == MAP_FAILED) return NULL;
|
||||
return (*env)->NewDirectByteBuffer(env, addr, size);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_hippo_ehviewer_jni_GifUtilsKt_munmap(JNIEnv *env, jclass clazz, jobject buffer) {
|
||||
byte *addr = (*env)->GetDirectBufferAddress(env, buffer);
|
||||
size_t size = (*env)->GetDirectBufferCapacity(env, buffer);
|
||||
munmap(addr, size);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package androidx.navigation.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.MenuItem
|
||||
import androidx.annotation.IdRes
|
||||
import androidx.navigation.ActivityNavigator
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavOptions
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
fun onNavDestinationSelected2(item: MenuItem, navController: NavController): Boolean {
|
||||
val builder = NavOptions.Builder().setLaunchSingleTop(true)
|
||||
if (
|
||||
navController.currentDestination!!.parent!!.findNode(item.itemId)
|
||||
is ActivityNavigator.Destination
|
||||
) {
|
||||
builder.setEnterAnim(R.anim.nav_default_enter_anim)
|
||||
.setExitAnim(R.anim.nav_default_exit_anim)
|
||||
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
|
||||
.setPopExitAnim(R.anim.nav_default_pop_exit_anim)
|
||||
} else {
|
||||
builder.setEnterAnim(androidx.fragment.R.animator.fragment_open_enter)
|
||||
.setExitAnim(androidx.fragment.R.animator.fragment_open_exit)
|
||||
.setPopEnterAnim(androidx.fragment.R.animator.fragment_close_enter)
|
||||
.setPopExitAnim(androidx.fragment.R.animator.fragment_close_exit)
|
||||
}
|
||||
val options = builder.build()
|
||||
return try {
|
||||
// TODO provide proper API instead of using Exceptions as Control-Flow.
|
||||
navController.navigate(item.itemId, null, options)
|
||||
// Return true only if the destination we've navigated to matches the MenuItem
|
||||
navController.currentDestination?.matchDestination(item.itemId) == true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun NavDestination.matchDestination(@IdRes destId: Int): Boolean =
|
||||
hierarchy.any { it.id == destId }
|
||||
@@ -1,25 +0,0 @@
|
||||
package androidx.swiperefreshlayout.widget
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.ShapeDrawable
|
||||
import android.util.AttributeSet
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import rikka.core.res.resolveColor
|
||||
|
||||
@SuppressLint("PrivateResource")
|
||||
class ThemedSwipeRefreshLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : SwipeRefreshLayout(context, attrs) {
|
||||
|
||||
init {
|
||||
val surfaceColor =
|
||||
context.theme.resolveColor(com.google.android.material.R.attr.colorSurface)
|
||||
val overlayColor =
|
||||
context.getColor(com.google.android.material.R.color.m3_popupmenu_overlay_color)
|
||||
val backgroundColor = ColorUtils.compositeColors(overlayColor, surfaceColor)
|
||||
(mCircleView.background as ShapeDrawable).paint.color = backgroundColor
|
||||
setColorSchemeColors(context.theme.resolveColor(com.google.android.material.R.attr.colorAccent))
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
/**
|
||||
* Helper class used to set the source and additional attributes from a variety of sources. Supports
|
||||
* use of a bitmap, asset, resource, external file or any other URI.
|
||||
*
|
||||
*
|
||||
* When you are using a preview image, you must set the dimensions of the full size image on the
|
||||
* ImageSource object for the full size image using the [.dimensions] method.
|
||||
*/
|
||||
class ImageSource {
|
||||
val bitmap: Bitmap?
|
||||
val provider: InputProvider?
|
||||
|
||||
private var sWidth = 0
|
||||
private var sHeight = 0
|
||||
|
||||
var sRegion = Rect(0, 0, 0, 0)
|
||||
private set
|
||||
var isCached = false
|
||||
private set
|
||||
|
||||
private constructor(bitmap: Bitmap, cached: Boolean) {
|
||||
this.bitmap = bitmap
|
||||
provider = null
|
||||
sWidth = bitmap.width
|
||||
sHeight = bitmap.height
|
||||
isCached = cached
|
||||
}
|
||||
|
||||
private constructor(provider: InputProvider) {
|
||||
bitmap = null
|
||||
this.provider = provider
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a region of the source image. Region must be set independently for the full size image and the preview if
|
||||
* you are using one.
|
||||
*
|
||||
* @param sRegion the region of the source image to be displayed.
|
||||
* @return this instance for chaining.
|
||||
*/
|
||||
fun region(sRegion: Rect): ImageSource {
|
||||
this.sRegion = sRegion
|
||||
setInvariants()
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the dimensions of the image. This is only required for a full size image, when you are specifying a URI
|
||||
* and also a preview image. When displaying a bitmap object, or not using a preview, you do not need to declare
|
||||
* the image dimensions. Note if the declared dimensions are found to be incorrect, the view will reset.
|
||||
*
|
||||
* @param sWidth width of the source image.
|
||||
* @param sHeight height of the source image.
|
||||
* @return this instance for chaining.
|
||||
*/
|
||||
fun dimensions(sWidth: Int, sHeight: Int): ImageSource {
|
||||
if (bitmap == null) {
|
||||
this.sWidth = sWidth
|
||||
this.sHeight = sHeight
|
||||
}
|
||||
setInvariants()
|
||||
return this
|
||||
}
|
||||
|
||||
private fun setInvariants() {
|
||||
if (!sRegion.isEmpty) {
|
||||
sWidth = sRegion.width()
|
||||
sHeight = sRegion.height()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create an instance from an input provider.
|
||||
*
|
||||
* @param provider input stream provider.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun provider(provider: InputProvider): ImageSource {
|
||||
return ImageSource(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a loaded bitmap for display.
|
||||
*
|
||||
* @param bitmap bitmap to be displayed.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun bitmap(bitmap: Bitmap): ImageSource {
|
||||
return ImageSource(bitmap, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide a loaded and cached bitmap for display. This bitmap will not be recycled when it is no
|
||||
* longer needed. Use this method if you loaded the bitmap with an image loader such as Picasso
|
||||
* or Volley.
|
||||
*
|
||||
* @param bitmap bitmap to be displayed.
|
||||
* @return an [ImageSource] instance.
|
||||
*/
|
||||
fun cachedBitmap(bitmap: Bitmap): ImageSource {
|
||||
return ImageSource(bitmap, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview
|
||||
|
||||
import android.graphics.PointF
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Wraps the scale, center and orientation of a displayed image for easy restoration on screen rotate.
|
||||
*/
|
||||
class ImageViewState(val scale: Float, center: PointF) : Serializable {
|
||||
private val centerX: Float = center.x
|
||||
private val centerY: Float = center.y
|
||||
|
||||
val center: PointF
|
||||
get() = PointF(centerX, centerY)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Point
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
/**
|
||||
* Interface for image decoding classes.
|
||||
*/
|
||||
interface Decoder {
|
||||
/**
|
||||
* Initialise the decoder. When possible, perform initial setup work once in this method. The
|
||||
* dimensions of the image must be returned.
|
||||
*
|
||||
* @param context Application context. A reference may be held, but must be cleared on recycle.
|
||||
* @param provider Provider of the image.
|
||||
* @return Dimensions of the image.
|
||||
* @throws Exception if initialisation fails.
|
||||
*/
|
||||
fun init(context: Context, provider: InputProvider): Point
|
||||
|
||||
/**
|
||||
* Decode a region of the image with the given sample size. This method is called off the UI
|
||||
* thread so it can safely load the image on the current thread. It is called from
|
||||
* [android.os.AsyncTask]s running in an executor that may have multiple threads, so
|
||||
* implementations must be thread safe. Adding `synchronized` to the method signature
|
||||
* is the simplest way to achieve this, but bear in mind the [.recycle] method can be
|
||||
* called concurrently.
|
||||
*
|
||||
* @param sRect Source image rectangle to decode.
|
||||
* @param sampleSize Sample size.
|
||||
* @return The decoded region. It is safe to return null if decoding fails.
|
||||
*/
|
||||
fun decodeRegion(sRect: Rect, sampleSize: Int): Bitmap
|
||||
|
||||
/**
|
||||
* Status check. Should return false before initialisation and after recycle.
|
||||
*
|
||||
* @return true if the decoder is ready to be used.
|
||||
*/
|
||||
val isReady: Boolean
|
||||
|
||||
/**
|
||||
* This method will be called when the decoder is no longer required. It should clean up any
|
||||
* resources still in use.
|
||||
*/
|
||||
fun recycle()
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview.decoder
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Point
|
||||
import android.graphics.Rect
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.davemorrissey.labs.subscaleview.provider.InputProvider
|
||||
|
||||
class ImageDecoder(private val cropBorders: Boolean, bitmapConfig: Bitmap.Config? = null) : Decoder {
|
||||
init {
|
||||
val globalBitmapConfig = SubsamplingScaleImageView.preferredBitmapConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the decoder. When possible, perform initial setup work once in this method. The
|
||||
* dimensions of the image must be returned.
|
||||
*
|
||||
* @param context Application context. A reference may be held, but must be cleared on recycle.
|
||||
* @param provider Provider of the image.
|
||||
* @return Dimensions of the image.
|
||||
* @throws Exception if initialisation fails.
|
||||
*/
|
||||
override fun init(context: Context, provider: InputProvider): Point {
|
||||
TODO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a region of the image with the given sample size. This method is called off the UI
|
||||
* thread so it can safely load the image on the current thread. It is called from
|
||||
* [android.os.AsyncTask]s running in an executor that may have multiple threads, so
|
||||
* implementations must be thread safe. Adding `synchronized` to the method signature
|
||||
* is the simplest way to achieve this, but bear in mind the [.recycle] method can be
|
||||
* called concurrently.
|
||||
*
|
||||
* @param sRect Source image rectangle to decode.
|
||||
* @param sampleSize Sample size.
|
||||
* @return The decoded region. It is safe to return null if decoding fails.
|
||||
*/
|
||||
override fun decodeRegion(sRect: Rect, sampleSize: Int): Bitmap {
|
||||
TODO()
|
||||
}
|
||||
|
||||
/**
|
||||
* Status check. Should return false before initialisation and after recycle.
|
||||
*
|
||||
* @return true if the decoder is ready to be used.
|
||||
*/
|
||||
override val isReady: Boolean = TODO()
|
||||
|
||||
/**
|
||||
* This method will be called when the decoder is no longer required. It should clean up any
|
||||
* resources still in use.
|
||||
*/
|
||||
override fun recycle() {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.davemorrissey.labs.subscaleview.provider
|
||||
|
||||
interface InputProvider
|
||||
@@ -1,37 +0,0 @@
|
||||
/*
|
||||
* 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.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class GalleryComment(
|
||||
// 0 for uploader comment. can't vote
|
||||
var id: Long = 0,
|
||||
var score: Int = 0,
|
||||
var editable: Boolean = false,
|
||||
var voteUpAble: Boolean = false,
|
||||
var voteUpEd: Boolean = false,
|
||||
var voteDownAble: Boolean = false,
|
||||
var voteDownEd: Boolean = false,
|
||||
var uploader: Boolean = false,
|
||||
var voteState: String? = null,
|
||||
var time: Long = 0,
|
||||
var user: String? = null,
|
||||
var comment: String? = null,
|
||||
var lastEdited: Long = 0,
|
||||
) : Parcelable
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.hippo.ehviewer.client.parser
|
||||
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.NOT_FAVORITED
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.ParseException
|
||||
|
||||
object GalleryListParser {
|
||||
suspend fun parse(body: String) = runCatching {
|
||||
parseGalleryInfoList(body).apply {
|
||||
galleryInfoList.onEach {
|
||||
if (it.favoriteSlot == NOT_FAVORITED && EhDB.containLocalFavorites(it.gid)) {
|
||||
it.favoriteSlot = LOCAL_FAVORITED
|
||||
}
|
||||
it.generateSLang()
|
||||
}
|
||||
}
|
||||
}.getOrElse {
|
||||
if (body.contains("<p>You do not have any watched tags")) {
|
||||
throw EhException(R.string.gallery_list_empty_hit_subscription)
|
||||
}
|
||||
if (body.contains("No hits found</p>")) {
|
||||
throw EhException(R.string.gallery_list_empty_hit)
|
||||
}
|
||||
throw ParseException("Can't parse gallery list", it)
|
||||
}
|
||||
}
|
||||
|
||||
class GalleryListResult(
|
||||
val prev: String?,
|
||||
val next: String?,
|
||||
val galleryInfoList: ArrayList<BaseGalleryInfo>,
|
||||
)
|
||||
|
||||
private external fun parseGalleryInfoList(e: String): GalleryListResult
|
||||
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
* Copyright 2019 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.ParseException
|
||||
import com.hippo.ehviewer.client.parseAs
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object GalleryPageApiParser {
|
||||
private val PATTERN_IMAGE_URL = Regex("<img[^>]*src=\"([^\"]+)\" style")
|
||||
private val PATTERN_SKIP_HATH_KEY = Regex("onclick=\"return nl\\('([^)]+)'\\)")
|
||||
private val PATTERN_ORIGIN_IMAGE_URL = Regex("<a href=\"([^\"]+)fullimg.php([^\"]+)\">")
|
||||
|
||||
fun parse(body: String): Result {
|
||||
runCatching { body.parseAs<Error>() }.onSuccess { throw ParseException(it.error) }
|
||||
val res = body.parseAs<JsonResult>()
|
||||
val imageUrl = PATTERN_IMAGE_URL.find(res.imageUrl)?.run {
|
||||
groupValues[1].trim().unescapeXml()
|
||||
}
|
||||
val skipHathKey = PATTERN_SKIP_HATH_KEY.find(res.skipHathKey)?.run {
|
||||
groupValues[1].trim().unescapeXml()
|
||||
}
|
||||
val originImageUrl = PATTERN_ORIGIN_IMAGE_URL.find(res.originImageUrl)?.run {
|
||||
groupValues[1].unescapeXml() + "fullimg.php" + groupValues[2].unescapeXml()
|
||||
}
|
||||
if (!imageUrl.isNullOrEmpty()) {
|
||||
return Result(imageUrl, skipHathKey, originImageUrl)
|
||||
} else {
|
||||
throw ParseException("Parse image url and skip hath key error")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class JsonResult(
|
||||
@SerialName("i3")
|
||||
val imageUrl: String,
|
||||
@SerialName("i6")
|
||||
val skipHathKey: String,
|
||||
@SerialName("i7")
|
||||
val originImageUrl: String,
|
||||
)
|
||||
|
||||
class Result(val imageUrl: String, val skipHathKey: String?, val originImageUrl: String?)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/*
|
||||
* 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.ParseException
|
||||
import com.hippo.ehviewer.util.unescapeXml
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object GalleryPageParser {
|
||||
private val PATTERN_IMAGE_URL = Pattern.compile("<img[^>]*src=\"([^\"]+)\" style")
|
||||
private val PATTERN_SKIP_HATH_KEY = Pattern.compile("onclick=\"return nl\\('([^)]+)'\\)")
|
||||
private val PATTERN_ORIGIN_IMAGE_URL =
|
||||
Pattern.compile("<a href=\"([^\"]+)fullimg.php([^\"]+)\">")
|
||||
|
||||
// TODO Not sure about the size of show keys
|
||||
private val PATTERN_SHOW_KEY = Pattern.compile("var showkey=\"([0-9a-z]+)\";")
|
||||
|
||||
fun parse(body: String): Result {
|
||||
var m = PATTERN_IMAGE_URL.matcher(body)
|
||||
val imageUrl = if (m.find()) {
|
||||
m.group(1)!!.trim().unescapeXml()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
m = PATTERN_SKIP_HATH_KEY.matcher(body)
|
||||
val skipHathKey = if (m.find()) {
|
||||
m.group(1)!!.trim().unescapeXml()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
m = PATTERN_ORIGIN_IMAGE_URL.matcher(body)
|
||||
val originImageUrl = if (m.find()) {
|
||||
m.group(1)!!.unescapeXml() + "fullimg.php" + m.group(2)!!.unescapeXml()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
m = PATTERN_SHOW_KEY.matcher(body)
|
||||
val showKey = if (m.find()) {
|
||||
m.group(1)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return if (!imageUrl.isNullOrEmpty() && !showKey.isNullOrEmpty()) {
|
||||
Result(imageUrl, skipHathKey, originImageUrl, showKey)
|
||||
} else {
|
||||
throw ParseException("Parse image url and show error")
|
||||
}
|
||||
}
|
||||
|
||||
class Result(
|
||||
val imageUrl: String,
|
||||
val skipHathKey: String?,
|
||||
val originImageUrl: String?,
|
||||
val showKey: String,
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Tarsin Norbin
|
||||
*
|
||||
* This file is part of EhViewer
|
||||
*
|
||||
* EhViewer is free software: you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* EhViewer is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with EhViewer.
|
||||
* If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.hippo.ehviewer.image
|
||||
|
||||
import android.graphics.ColorSpace
|
||||
import android.graphics.ImageDecoder
|
||||
import android.graphics.ImageDecoder.ImageInfo
|
||||
import android.graphics.ImageDecoder.Source
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import com.hippo.ehviewer.R
|
||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||
import splitties.init.appCtx
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.min
|
||||
|
||||
class Image private constructor(private val src: CloseableSource) {
|
||||
var mObtainedDrawable: Drawable? =
|
||||
ImageDecoder.decodeDrawable(src.source) { decoder: ImageDecoder, info: ImageInfo, _: Source ->
|
||||
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) {
|
||||
// Allocating hardware bitmap may cause a crash on framework versions prior to Android Q
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
}
|
||||
decoder.setTargetColorSpace(colorSpace)
|
||||
decoder.setTargetSampleSize(
|
||||
calculateSampleSize(info, 2 * screenHeight, 2 * screenWidth),
|
||||
)
|
||||
}.also {
|
||||
if (it !is Animatable) src.close()
|
||||
}
|
||||
private set
|
||||
|
||||
val size: Int
|
||||
get() = mObtainedDrawable!!.run { intrinsicHeight * intrinsicWidth * 4 * if (this is Animatable) 4 else 1 }
|
||||
|
||||
@Synchronized
|
||||
fun recycle() {
|
||||
(mObtainedDrawable as? Animatable)?.stop()
|
||||
(mObtainedDrawable as? BitmapDrawable)?.bitmap?.recycle()
|
||||
mObtainedDrawable?.callback = null
|
||||
if (mObtainedDrawable is Animatable) src.close()
|
||||
mObtainedDrawable = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun calculateSampleSize(info: ImageInfo, targetHeight: Int, targetWeight: Int): Int {
|
||||
return min(
|
||||
info.size.width / targetWeight,
|
||||
info.size.height / targetHeight,
|
||||
).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
private val imageSearchMaxSize = appCtx.resources.getDimensionPixelOffset(R.dimen.image_search_max_size)
|
||||
|
||||
val imageSearchDecoderSampleListener =
|
||||
ImageDecoder.OnHeaderDecodedListener { decoder, info, _ ->
|
||||
decoder.setTargetSampleSize(
|
||||
calculateSampleSize(info, imageSearchMaxSize, imageSearchMaxSize),
|
||||
)
|
||||
}
|
||||
val screenWidth = appCtx.resources.displayMetrics.widthPixels
|
||||
val screenHeight = appCtx.resources.displayMetrics.heightPixels
|
||||
val isWideColorGamut = appCtx.resources.configuration.isScreenWideColorGamut
|
||||
var colorSpace = ColorSpace.get(
|
||||
if (isWideColorGamut && ReaderPreferences.wideColorGamut().get()) {
|
||||
ColorSpace.Named.DISPLAY_P3
|
||||
} else {
|
||||
ColorSpace.Named.SRGB
|
||||
},
|
||||
)
|
||||
|
||||
fun decode(src: CloseableSource): Image? {
|
||||
return runCatching {
|
||||
Image(src)
|
||||
}.onFailure {
|
||||
src.close()
|
||||
it.printStackTrace()
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
interface CloseableSource : AutoCloseable {
|
||||
val source: Source
|
||||
}
|
||||
}
|
||||
|
||||
external fun rewriteGifSource(buffer: ByteBuffer)
|
||||
external fun rewriteGifSource2(fd: Int)
|
||||
@@ -1,126 +0,0 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhCookieStore
|
||||
import com.hippo.ehviewer.ui.login.CookieSignInScene
|
||||
import com.hippo.ehviewer.ui.login.SelectSiteScreen
|
||||
import com.hippo.ehviewer.ui.login.SignInScreen
|
||||
import com.hippo.ehviewer.ui.login.WebViewSignInScreen
|
||||
import com.hippo.ehviewer.ui.settings.AboutScreen
|
||||
import com.hippo.ehviewer.ui.settings.AdvancedScreen
|
||||
import com.hippo.ehviewer.ui.settings.BaseScreen
|
||||
import com.hippo.ehviewer.ui.settings.DownloadScreen
|
||||
import com.hippo.ehviewer.ui.settings.EhScreen
|
||||
import com.hippo.ehviewer.ui.settings.FilterScreen
|
||||
import com.hippo.ehviewer.ui.settings.LicenseScreen
|
||||
import com.hippo.ehviewer.ui.settings.MyTagsScreen
|
||||
import com.hippo.ehviewer.ui.settings.PrivacyScreen
|
||||
import com.hippo.ehviewer.ui.settings.UConfigScreen
|
||||
|
||||
class ConfigureActivity : EhActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setMD3Content {
|
||||
val navController = rememberNavController()
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
CompositionLocalProvider(LocalNavController provides navController) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (Settings.needSignIn) {
|
||||
if (EhCookieStore.hasSignedIn()) SELECT_SITE_ROUTE_NAME else SIGN_IN_ROUTE_NAME
|
||||
} else {
|
||||
BASE_SETTINGS_SCREEN
|
||||
},
|
||||
modifier = Modifier.imePadding(),
|
||||
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up, tween(200)) },
|
||||
exitTransition = { fadeOut(tween(200)) },
|
||||
popEnterTransition = { fadeIn(tween(200)) },
|
||||
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Down, tween(200)) },
|
||||
) {
|
||||
composable(SIGN_IN_ROUTE_NAME) {
|
||||
SignInScreen(windowSizeClass)
|
||||
}
|
||||
composable(WEBVIEW_SIGN_IN_ROUTE_NAME) {
|
||||
WebViewSignInScreen()
|
||||
}
|
||||
composable(COOKIE_SIGN_IN_ROUTE_NAME) {
|
||||
CookieSignInScene(windowSizeClass)
|
||||
}
|
||||
composable(SELECT_SITE_ROUTE_NAME) {
|
||||
SelectSiteScreen()
|
||||
}
|
||||
composable(BASE_SETTINGS_SCREEN) {
|
||||
BaseScreen()
|
||||
}
|
||||
composable(ABOUT_SETTINGS_SCREEN) {
|
||||
AboutScreen()
|
||||
}
|
||||
composable(LICENSE_SCREEN) {
|
||||
LicenseScreen()
|
||||
}
|
||||
composable(PRIVACY_SETTINGS_SCREEN) {
|
||||
PrivacyScreen()
|
||||
}
|
||||
composable(ADVANCED_SETTINGS_SCREEN) {
|
||||
AdvancedScreen()
|
||||
}
|
||||
composable(DOWNLOAD_SETTINGS_SCREEN) {
|
||||
DownloadScreen()
|
||||
}
|
||||
composable(EH_SETTINGS_SCREEN) {
|
||||
EhScreen()
|
||||
}
|
||||
composable(UCONFIG_SCREEN) {
|
||||
UConfigScreen()
|
||||
}
|
||||
composable(MYTAGS_SCREEN) {
|
||||
MyTagsScreen()
|
||||
}
|
||||
composable(FILTER_SCREEN) {
|
||||
FilterScreen()
|
||||
}
|
||||
composable(FINISH_ROUTE_NAME) {
|
||||
SideEffect {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("CompositionLocal LocalNavController not present!") }
|
||||
|
||||
const val BASE_SETTINGS_SCREEN = "Base"
|
||||
const val EH_SETTINGS_SCREEN = "Eh"
|
||||
const val DOWNLOAD_SETTINGS_SCREEN = "Download"
|
||||
const val PRIVACY_SETTINGS_SCREEN = "Privacy"
|
||||
const val ADVANCED_SETTINGS_SCREEN = "Advanced"
|
||||
const val ABOUT_SETTINGS_SCREEN = "About"
|
||||
const val LICENSE_SCREEN = "License"
|
||||
const val UCONFIG_SCREEN = "UConfig"
|
||||
const val MYTAGS_SCREEN = "Mytags"
|
||||
const val FILTER_SCREEN = "Filter"
|
||||
const val SIGN_IN_ROUTE_NAME = "SignIn"
|
||||
const val WEBVIEW_SIGN_IN_ROUTE_NAME = "WebViewSignIn"
|
||||
const val COOKIE_SIGN_IN_ROUTE_NAME = "CookieSignIn"
|
||||
const val SELECT_SITE_ROUTE_NAME = "SelectSite"
|
||||
const val FINISH_ROUTE_NAME = "Finish"
|
||||
@@ -1,103 +0,0 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhUrl
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo.Companion.LOCAL_FAVORITED
|
||||
import com.hippo.ehviewer.client.thumbUrl
|
||||
import com.hippo.ehviewer.databinding.InfoSheetBinding
|
||||
import com.hippo.ehviewer.ui.scene.navWithUrl
|
||||
import com.hippo.ehviewer.util.tellClipboardWithToast
|
||||
|
||||
private const val INDEX_URL = 2
|
||||
private const val INDEX_PARENT = 9
|
||||
|
||||
private fun GalleryDetail.generateContent() = arrayOf(
|
||||
R.string.key_gid to gid.toString(),
|
||||
R.string.key_token to token,
|
||||
R.string.key_url to EhUrl.getGalleryDetailUrl(gid, token),
|
||||
R.string.key_title to title,
|
||||
R.string.key_title_jpn to titleJpn,
|
||||
R.string.key_thumb to thumbUrl,
|
||||
R.string.key_category to EhUtils.getCategory(category),
|
||||
R.string.key_uploader to uploader,
|
||||
R.string.key_posted to posted,
|
||||
R.string.key_parent to parent,
|
||||
R.string.key_visible to visible,
|
||||
R.string.key_language to language,
|
||||
R.string.key_pages to pages.toString(),
|
||||
R.string.key_size to size,
|
||||
R.string.key_favorite_count to favoriteCount.toString(),
|
||||
R.string.key_favorited to (favoriteSlot > LOCAL_FAVORITED).toString(),
|
||||
R.string.key_rating_count to ratingCount.toString(),
|
||||
R.string.key_rating to rating.toString(),
|
||||
R.string.key_torrents to torrentCount.toString(),
|
||||
R.string.key_torrent_url to torrentUrl,
|
||||
R.string.favorite_name to favoriteName,
|
||||
)
|
||||
|
||||
class GalleryInfoBottomSheet(private val detail: GalleryDetail) : BottomSheetDialogFragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = InfoSheetBinding.inflate(layoutInflater, container, false).apply {
|
||||
content.setMD3Content {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
val context = LocalContext.current
|
||||
val navController = remember { findNavController() }
|
||||
Text(
|
||||
text = stringResource(id = R.string.gallery_info),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
val data = remember(detail) { detail.generateContent() }
|
||||
ProvideTextStyle(MaterialTheme.typography.labelLarge) {
|
||||
LazyColumn {
|
||||
itemsIndexed(data) { index, (key, content) ->
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.keyline_margin)).clickable {
|
||||
if (index == INDEX_PARENT) {
|
||||
if (content != null) {
|
||||
navController.navWithUrl(content)
|
||||
}
|
||||
} else {
|
||||
context tellClipboardWithToast content
|
||||
if (index == INDEX_URL) {
|
||||
// Save it to avoid detect the gallery
|
||||
Settings.clipboardTextHashCode = data[index].hashCode()
|
||||
}
|
||||
}
|
||||
}.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(id = key), modifier = Modifier.width(90.dp).padding(8.dp))
|
||||
Text(content.orEmpty(), modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.root
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
/*
|
||||
* 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.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.assist.AssistContent
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.verify.domain.DomainVerificationManager
|
||||
import android.content.pm.verify.domain.DomainVerificationUserState
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.customview.widget.Openable
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import androidx.navigation.ui.NavigationUI
|
||||
import androidx.navigation.ui.onNavDestinationSelected2
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.Settings.launchPage
|
||||
import com.hippo.ehviewer.client.EhUtils
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
|
||||
import com.hippo.ehviewer.databinding.ActivityMainBinding
|
||||
import com.hippo.ehviewer.download.DownloadService
|
||||
import com.hippo.ehviewer.download.downloadLocation
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
|
||||
import com.hippo.ehviewer.ui.legacy.EditTextDialogBuilder
|
||||
import com.hippo.ehviewer.ui.scene.BaseScene
|
||||
import com.hippo.ehviewer.ui.scene.GalleryDetailScene
|
||||
import com.hippo.ehviewer.ui.scene.GalleryListScene
|
||||
import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs
|
||||
import com.hippo.ehviewer.ui.scene.ProgressScene
|
||||
import com.hippo.ehviewer.ui.scene.navAnimated
|
||||
import com.hippo.ehviewer.ui.scene.navWithUrl
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.hippo.ehviewer.util.getParcelableExtraCompat
|
||||
import com.hippo.ehviewer.util.getUrlFromClipboard
|
||||
import eu.kanade.tachiyomi.util.lang.launchUI
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.systemservices.clipboardManager
|
||||
import splitties.systemservices.connectivityManager
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
class MainActivity : EhActivity() {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var navController: NavController
|
||||
|
||||
private fun saveImageToTempFile(uri: Uri): File? {
|
||||
val src = ImageDecoder.createSource(contentResolver, uri)
|
||||
val bitmap = runCatching {
|
||||
ImageDecoder.decodeBitmap(src, Image.imageSearchDecoderSampleListener)
|
||||
}.getOrNull() ?: return null
|
||||
val temp = AppConfig.createTempFile() ?: return null
|
||||
return runCatching {
|
||||
FileOutputStream(temp).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
temp
|
||||
}.getOrElse {
|
||||
it.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
lifecycleScope.launchUI {
|
||||
if (!handleIntent(intent)) {
|
||||
if (intent != null && Intent.ACTION_VIEW == intent.action) {
|
||||
if (intent.data != null) {
|
||||
val url = intent.data.toString()
|
||||
EditTextDialogBuilder(this@MainActivity, url, "")
|
||||
.setTitle(R.string.error_cannot_parse_the_url)
|
||||
.setPositiveButton(android.R.string.copy) { _: DialogInterface?, _: Int ->
|
||||
this@MainActivity.addTextToClipboard(
|
||||
url,
|
||||
false,
|
||||
)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?): Boolean {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
val uri = intent.data ?: return false
|
||||
return navController.navWithUrl(uri.toString())
|
||||
}
|
||||
|
||||
Intent.ACTION_SEND -> {
|
||||
val type = intent.type
|
||||
if ("text/plain" == type) {
|
||||
val builder = ListUrlBuilder()
|
||||
builder.keyword = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
navController.navAnimated(
|
||||
R.id.galleryListScene,
|
||||
builder.toStartArgs(),
|
||||
)
|
||||
return true
|
||||
} else if (type != null && type.startsWith("image/")) {
|
||||
val uri = intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM)
|
||||
if (null != uri) {
|
||||
val temp = saveImageToTempFile(uri)
|
||||
if (null != temp) {
|
||||
val builder = ListUrlBuilder()
|
||||
builder.mode = ListUrlBuilder.MODE_IMAGE_SEARCH
|
||||
builder.imagePath = temp.path
|
||||
builder.isUseSimilarityScan = true
|
||||
navController.navAnimated(
|
||||
R.id.galleryListScene,
|
||||
builder.toStartArgs(),
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DownloadService.ACTION_START_DOWNLOADSCENE -> {
|
||||
val args = intent.getBundleExtra(DownloadService.ACTION_START_DOWNLOADSCENE_ARGS)
|
||||
navController.navAnimated(R.id.nav_downloads, args)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun setNavGraph() {
|
||||
navController.apply {
|
||||
graph = navInflater.inflate(R.navigation.nav_graph).apply {
|
||||
when (
|
||||
when (val value = launchPage) {
|
||||
0 -> GalleryListScene.ACTION_HOMEPAGE
|
||||
1 -> GalleryListScene.ACTION_SUBSCRIPTION
|
||||
2 -> GalleryListScene.ACTION_WHATS_HOT
|
||||
3 -> GalleryListScene.ACTION_TOP_LIST
|
||||
else -> throw IllegalStateException("Unexpected value: $value")
|
||||
}
|
||||
) {
|
||||
GalleryListScene.ACTION_HOMEPAGE -> setStartDestination(R.id.nav_homepage)
|
||||
GalleryListScene.ACTION_SUBSCRIPTION -> setStartDestination(R.id.nav_subscription)
|
||||
GalleryListScene.ACTION_WHATS_HOT -> setStartDestination(R.id.nav_whats_hot)
|
||||
GalleryListScene.ACTION_TOP_LIST -> setStartDestination(R.id.nav_toplist)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
val navHostFragment =
|
||||
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
|
||||
navController = navHostFragment.navController
|
||||
if (!EhUtils.needSignedIn()) setNavGraph()
|
||||
binding.drawView.addDrawerListener(mDrawerOnBackPressedCallback)
|
||||
binding.navView.setupWithNavController(navController)
|
||||
|
||||
// Trick: Tweak NavigationUI to disable multiple backstack
|
||||
binding.navView.setNavigationItemSelectedListener {
|
||||
val navigationView = binding.navView
|
||||
val handled = navigationView.checkedItem?.itemId == it.itemId || onNavDestinationSelected2(it, navController)
|
||||
if (handled) {
|
||||
val parent = navigationView.parent
|
||||
if (parent is Openable) {
|
||||
parent.close()
|
||||
} else {
|
||||
@SuppressLint("RestrictedApi")
|
||||
val bottomSheetBehavior = NavigationUI.findBottomSheetBehavior(navigationView)
|
||||
if (bottomSheetBehavior != null) {
|
||||
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
|
||||
}
|
||||
}
|
||||
}
|
||||
handled
|
||||
}
|
||||
// Trick End
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
if (intent.action != Intent.ACTION_MAIN) {
|
||||
onNewIntent(intent)
|
||||
}
|
||||
checkDownloadLocation()
|
||||
if (Settings.meteredNetworkWarning) {
|
||||
checkMeteredNetwork()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (!Settings.appLinkVerifyTip) {
|
||||
try {
|
||||
checkAppLinkVerify()
|
||||
} catch (ignored: PackageManager.NameNotFoundException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
private fun checkAppLinkVerify() {
|
||||
val manager = getSystemService(DomainVerificationManager::class.java)
|
||||
val userState = manager.getDomainVerificationUserState(packageName) ?: return
|
||||
var hasUnverified = false
|
||||
val hostToStateMap = userState.hostToStateMap
|
||||
for (key in hostToStateMap.keys) {
|
||||
val stateValue = hostToStateMap[key]
|
||||
if (stateValue == null || stateValue == DomainVerificationUserState.DOMAIN_STATE_VERIFIED || stateValue == DomainVerificationUserState.DOMAIN_STATE_SELECTED) {
|
||||
continue
|
||||
}
|
||||
hasUnverified = true
|
||||
break
|
||||
}
|
||||
if (hasUnverified) {
|
||||
BaseDialogBuilder(this)
|
||||
.setTitle(R.string.app_link_not_verified_title)
|
||||
.setMessage(R.string.app_link_not_verified_message)
|
||||
.setPositiveButton(R.string.open_settings) { _: DialogInterface?, _: Int ->
|
||||
try {
|
||||
val intent = Intent(
|
||||
android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS,
|
||||
Uri.parse("package:$packageName"),
|
||||
)
|
||||
startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
val intent = Intent(
|
||||
android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.parse("package:$packageName"),
|
||||
)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setNeutralButton(R.string.dont_show_again) { _: DialogInterface?, _: Int ->
|
||||
Settings.appLinkVerifyTip = true
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadLocation() {
|
||||
val uniFile = downloadLocation
|
||||
// null == uniFile for first start
|
||||
if (uniFile.ensureDir()) {
|
||||
return
|
||||
}
|
||||
BaseDialogBuilder(this)
|
||||
.setTitle(R.string.waring)
|
||||
.setMessage(R.string.invalid_download_location)
|
||||
.setPositiveButton(R.string.get_it, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun checkMeteredNetwork() {
|
||||
if (connectivityManager.isActiveNetworkMetered) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
Snackbar.make(
|
||||
binding.drawView,
|
||||
R.string.metered_network_warning,
|
||||
Snackbar.LENGTH_LONG,
|
||||
)
|
||||
.setAction(R.string.settings) {
|
||||
val panelIntent =
|
||||
Intent(android.provider.Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
|
||||
startActivity(panelIntent)
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
showTip(R.string.metered_network_warning, BaseScene.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val loginLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
setNavGraph()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
if (EhUtils.needSignedIn()) {
|
||||
loginLauncher.launch(Intent(this, ConfigureActivity::class.java))
|
||||
}
|
||||
super.onResume()
|
||||
lifecycleScope.launch {
|
||||
delay(300)
|
||||
mDrawerOnBackPressedCallback.remove()
|
||||
onBackPressedDispatcher.addCallback(mDrawerOnBackPressedCallback)
|
||||
checkClipboardUrl()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkClipboardUrl() {
|
||||
val text = clipboardManager.getUrlFromClipboard(this)
|
||||
val hashCode = text?.hashCode() ?: 0
|
||||
if (text != null && hashCode != 0 && Settings.clipboardTextHashCode != hashCode) {
|
||||
val result1 = GalleryDetailUrlParser.parse(text, false)
|
||||
var launch: (() -> Unit)? = null
|
||||
if (result1 != null) {
|
||||
val args = Bundle()
|
||||
args.putString(GalleryDetailScene.KEY_ACTION, GalleryDetailScene.ACTION_GID_TOKEN)
|
||||
args.putLong(GalleryDetailScene.KEY_GID, result1.gid)
|
||||
args.putString(GalleryDetailScene.KEY_TOKEN, result1.token)
|
||||
launch = { navController.navAnimated(R.id.galleryDetailScene, args) }
|
||||
}
|
||||
val result2 = GalleryPageUrlParser.parse(text, false)
|
||||
if (result2 != null) {
|
||||
val args = Bundle()
|
||||
args.putString(ProgressScene.KEY_ACTION, ProgressScene.ACTION_GALLERY_TOKEN)
|
||||
args.putLong(ProgressScene.KEY_GID, result2.gid)
|
||||
args.putString(ProgressScene.KEY_PTOKEN, result2.pToken)
|
||||
args.putInt(ProgressScene.KEY_PAGE, result2.page)
|
||||
launch = { navController.navAnimated(R.id.progressScene, args) }
|
||||
}
|
||||
launch?.let {
|
||||
withUIContext {
|
||||
val snackbar = Snackbar.make(
|
||||
binding.drawView,
|
||||
R.string.clipboard_gallery_url_snack_message,
|
||||
Snackbar.LENGTH_SHORT,
|
||||
)
|
||||
snackbar.setAction(R.string.clipboard_gallery_url_snack_action) {
|
||||
it()
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
Settings.clipboardTextHashCode = hashCode
|
||||
}
|
||||
|
||||
fun addAboveSnackView(view: View) {
|
||||
binding.absnacker.addAboveSnackView(view)
|
||||
}
|
||||
|
||||
fun removeAboveSnackView(view: View) {
|
||||
binding.absnacker.removeAboveSnackView(view)
|
||||
}
|
||||
|
||||
fun setDrawerLockMode(lockMode: Int, edgeGravity: Int) {
|
||||
binding.drawView.setDrawerLockMode(lockMode, edgeGravity)
|
||||
}
|
||||
|
||||
fun getDrawerLockMode(edgeGravity: Int): Int {
|
||||
return binding.drawView.getDrawerLockMode(edgeGravity)
|
||||
}
|
||||
|
||||
fun isDrawerOpen(drawerGravity: Int) = binding.drawView.isDrawerOpen(drawerGravity)
|
||||
|
||||
fun openDrawer(drawerGravity: Int) {
|
||||
binding.drawView.openDrawer(drawerGravity)
|
||||
}
|
||||
|
||||
fun closeDrawer(drawerGravity: Int) {
|
||||
binding.drawView.closeDrawer(drawerGravity)
|
||||
}
|
||||
|
||||
fun toggleDrawer(drawerGravity: Int) {
|
||||
if (isDrawerOpen(drawerGravity)) {
|
||||
closeDrawer(drawerGravity)
|
||||
} else {
|
||||
openDrawer(drawerGravity)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearNavCheckedItem() {
|
||||
binding.navView.setCheckedItem(R.id.nav_stub)
|
||||
}
|
||||
|
||||
fun showTip(@StringRes id: Int, length: Int, useToast: Boolean = false) {
|
||||
showTip(getString(id), length, useToast)
|
||||
}
|
||||
|
||||
/**
|
||||
* If activity is running, show snack bar, otherwise show toast
|
||||
*/
|
||||
fun showTip(message: CharSequence, length: Int, useToast: Boolean = false) {
|
||||
findViewById<View>(R.id.snackbar)?.takeUnless { useToast }?.apply {
|
||||
Snackbar.make(
|
||||
this,
|
||||
message,
|
||||
if (length == BaseScene.LENGTH_LONG) Snackbar.LENGTH_LONG else Snackbar.LENGTH_SHORT,
|
||||
).show()
|
||||
} ?: Toast.makeText(
|
||||
this,
|
||||
message,
|
||||
if (length == BaseScene.LENGTH_LONG) Toast.LENGTH_LONG else Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
|
||||
var mShareUrl: String? = null
|
||||
override fun onProvideAssistContent(outContent: AssistContent?) {
|
||||
super.onProvideAssistContent(outContent)
|
||||
mShareUrl?.let { outContent?.webUri = Uri.parse(mShareUrl) }
|
||||
}
|
||||
|
||||
private val mDrawerOnBackPressedCallback =
|
||||
object : OnBackPressedCallback(false), DrawerLayout.DrawerListener {
|
||||
val slideThreshold = 0.05
|
||||
override fun handleOnBackPressed() {
|
||||
binding.drawView.closeDrawers()
|
||||
}
|
||||
|
||||
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
|
||||
isEnabled = slideOffset > slideThreshold
|
||||
}
|
||||
|
||||
override fun onDrawerOpened(drawerView: View) {}
|
||||
override fun onDrawerClosed(drawerView: View) {}
|
||||
override fun onDrawerStateChanged(newState: Int) {}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.hippo.ehviewer.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import com.google.accompanist.themeadapter.material3.Mdc3Theme
|
||||
|
||||
// To make sure compose theme and legacy view theme are in sync, we only use Mdc3Theme
|
||||
fun ComposeView.setMD3Content(content: @Composable () -> Unit) = setContent { Mdc3Theme(content = content) }
|
||||
fun ComponentActivity.setMD3Content(content: @Composable () -> Unit) = setContent { Mdc3Theme(content = content) }
|
||||
@@ -1,217 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.legacy
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout.AttachedBehavior
|
||||
import androidx.core.util.ObjectsCompat
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.drawerlayout.widget.DrawerLayout.DrawerListener
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import com.drakeet.drawer.FullDraggableHelper
|
||||
import com.google.android.material.snackbar.Snackbar.SnackbarLayout
|
||||
import com.hippo.ehviewer.util.dp2px
|
||||
|
||||
class AboveSnackerLayout : FrameLayout, AttachedBehavior, FullDraggableHelper.Callback {
|
||||
private val helper: FullDraggableHelper
|
||||
private var drawerLayout: DrawerLayout? = null
|
||||
private var mAboveSnackViewList: MutableList<View>? = null
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) {
|
||||
helper = FullDraggableHelper(context, this)
|
||||
}
|
||||
|
||||
constructor(context: Context?, attrs: AttributeSet?, defStyle: Int) :
|
||||
super(context!!, attrs, defStyle) {
|
||||
helper = FullDraggableHelper(context, this)
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
ensureDrawerLayout()
|
||||
}
|
||||
|
||||
private fun ensureDrawerLayout() {
|
||||
val parent = parent.parent
|
||||
check(parent is DrawerLayout) { "This $this must be added to a DrawerLayout" }
|
||||
drawerLayout = parent
|
||||
}
|
||||
|
||||
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
||||
return helper.onInterceptTouchEvent(event)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
return helper.onTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun getDrawerMainContainer(): View {
|
||||
return this
|
||||
}
|
||||
|
||||
override fun isDrawerOpen(gravity: Int): Boolean {
|
||||
return drawerLayout!!.isDrawerOpen(gravity)
|
||||
}
|
||||
|
||||
override fun hasEnabledDrawer(gravity: Int): Boolean {
|
||||
return drawerLayout!!.getDrawerLockMode(gravity) == DrawerLayout.LOCK_MODE_UNLOCKED &&
|
||||
findDrawerWithGravity(gravity) != null
|
||||
}
|
||||
|
||||
override fun offsetDrawer(gravity: Int, offset: Float) {
|
||||
setDrawerToOffset(gravity, offset)
|
||||
drawerLayout!!.invalidate()
|
||||
}
|
||||
|
||||
override fun smoothOpenDrawer(gravity: Int) {
|
||||
drawerLayout!!.openDrawer(gravity, true)
|
||||
}
|
||||
|
||||
override fun smoothCloseDrawer(gravity: Int) {
|
||||
drawerLayout!!.closeDrawer(gravity, true)
|
||||
}
|
||||
|
||||
override fun onDrawerDragging() {
|
||||
val drawerListeners = drawerListeners
|
||||
if (drawerListeners != null) {
|
||||
val listenerCount = drawerListeners.size
|
||||
for (i in listenerCount - 1 downTo 0) {
|
||||
drawerListeners[i].onDrawerStateChanged(DrawerLayout.STATE_DRAGGING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val drawerListeners: List<DrawerListener>?
|
||||
get() = try {
|
||||
val listeners = DrawerLayout::class.java.getDeclaredField("mListeners")
|
||||
listeners.isAccessible = true
|
||||
listeners[drawerLayout] as List<DrawerListener>
|
||||
} catch (e: Exception) {
|
||||
// throw to let developer know the api is changed
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
|
||||
private fun setDrawerToOffset(gravity: Int, offset: Float) {
|
||||
val drawerView = findDrawerWithGravity(gravity)
|
||||
val slideOffsetPercent =
|
||||
(offset / ObjectsCompat.requireNonNull(drawerView).width).coerceIn(0f, 1f)
|
||||
try {
|
||||
val method = DrawerLayout::class.java.getDeclaredMethod(
|
||||
"moveDrawerToOffset",
|
||||
View::class.java,
|
||||
Float::class.javaPrimitiveType,
|
||||
)
|
||||
method.isAccessible = true
|
||||
method.invoke(drawerLayout, drawerView, slideOffsetPercent)
|
||||
drawerView!!.visibility = VISIBLE
|
||||
} catch (e: Exception) {
|
||||
// throw to let developer know the api is changed
|
||||
throw RuntimeException(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Copied from DrawerLayout
|
||||
private fun findDrawerWithGravity(gravity: Int): View? {
|
||||
val absHorizontalGravity = GravityCompat.getAbsoluteGravity(
|
||||
gravity,
|
||||
ViewCompat.getLayoutDirection(drawerLayout!!),
|
||||
) and Gravity.HORIZONTAL_GRAVITY_MASK
|
||||
val childCount = drawerLayout!!.childCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = drawerLayout!!.getChildAt(i)
|
||||
val childAbsGravity = getDrawerViewAbsoluteGravity(child)
|
||||
if (childAbsGravity and Gravity.HORIZONTAL_GRAVITY_MASK == absHorizontalGravity) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Copied from DrawerLayout
|
||||
private fun getDrawerViewAbsoluteGravity(drawerView: View): Int {
|
||||
val gravity = (drawerView.layoutParams as DrawerLayout.LayoutParams).gravity
|
||||
return GravityCompat.getAbsoluteGravity(
|
||||
gravity,
|
||||
ViewCompat.getLayoutDirection(drawerLayout!!),
|
||||
)
|
||||
}
|
||||
|
||||
fun addAboveSnackView(view: View) {
|
||||
if (null == mAboveSnackViewList) {
|
||||
mAboveSnackViewList = ArrayList()
|
||||
}
|
||||
mAboveSnackViewList!!.add(view)
|
||||
}
|
||||
|
||||
fun removeAboveSnackView(view: View) {
|
||||
if (null == mAboveSnackViewList) {
|
||||
return
|
||||
}
|
||||
mAboveSnackViewList!!.remove(view)
|
||||
}
|
||||
|
||||
val aboveSnackViewCount: Int
|
||||
get() = if (null == mAboveSnackViewList) 0 else mAboveSnackViewList!!.size
|
||||
|
||||
fun getAboveSnackViewAt(index: Int): View? {
|
||||
return if (null == mAboveSnackViewList || index < 0 || index >= mAboveSnackViewList!!.size) {
|
||||
null
|
||||
} else {
|
||||
mAboveSnackViewList!![index]
|
||||
}
|
||||
}
|
||||
|
||||
override fun getBehavior(): Behavior {
|
||||
return Behavior()
|
||||
}
|
||||
|
||||
class Behavior : CoordinatorLayout.Behavior<AboveSnackerLayout>() {
|
||||
override fun layoutDependsOn(
|
||||
parent: CoordinatorLayout,
|
||||
child: AboveSnackerLayout,
|
||||
dependency: View,
|
||||
): Boolean {
|
||||
return dependency is SnackbarLayout
|
||||
}
|
||||
|
||||
override fun onDependentViewChanged(
|
||||
parent: CoordinatorLayout,
|
||||
child: AboveSnackerLayout,
|
||||
dependency: View,
|
||||
): Boolean {
|
||||
for (i in 0 until child.aboveSnackViewCount) {
|
||||
val view = child.getAboveSnackViewAt(i)
|
||||
if (view != null) {
|
||||
val translationY =
|
||||
(dependency.translationY - dependency.height - dp2px(view.context, 8f))
|
||||
.coerceAtMost(0f)
|
||||
ViewCompat.animate(view).setInterpolator(FastOutSlowInInterpolator())
|
||||
.translationY(translationY).setDuration(150).start()
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onDependentViewRemoved(
|
||||
parent: CoordinatorLayout,
|
||||
child: AboveSnackerLayout,
|
||||
dependency: View,
|
||||
) {
|
||||
for (i in 0 until child.aboveSnackViewCount) {
|
||||
val view = child.getAboveSnackViewAt(i)
|
||||
if (view != null) {
|
||||
ViewCompat.animate(view).setInterpolator(FastOutSlowInInterpolator())
|
||||
.translationY(0f).setDuration(75).start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 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.ui.legacy
|
||||
|
||||
import android.animation.ObjectAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.Keep
|
||||
import com.google.android.material.math.MathUtils
|
||||
import com.hippo.ehviewer.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* @param context used to get the configuration for the drawable from
|
||||
*/
|
||||
class AddDeleteDrawable(context: Context, color: Int) : Drawable() {
|
||||
private val mPaint = Paint()
|
||||
private val mPath = Path()
|
||||
private val mSize: Int
|
||||
private var mProgress = 0f
|
||||
private var mAutoUpdateMirror = false
|
||||
private var mVerticalMirror = false
|
||||
|
||||
init {
|
||||
val resources = context.resources
|
||||
mSize = resources.getDimensionPixelSize(R.dimen.add_size)
|
||||
val barThickness = resources.getDimension(R.dimen.add_thickness).roundToInt().toFloat()
|
||||
mPaint.color = color
|
||||
mPaint.style = Paint.Style.STROKE
|
||||
mPaint.strokeJoin = Paint.Join.MITER
|
||||
mPaint.strokeCap = Paint.Cap.BUTT
|
||||
mPaint.strokeWidth = barThickness
|
||||
val halfSize = (mSize / 2).toFloat()
|
||||
mPath.moveTo(0f, -halfSize)
|
||||
mPath.lineTo(0f, halfSize)
|
||||
mPath.moveTo(-halfSize, 0f)
|
||||
mPath.lineTo(halfSize, 0f)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val bounds = bounds
|
||||
val canvasRotate = if (mVerticalMirror) {
|
||||
MathUtils.lerp(270f, 135f, mProgress)
|
||||
} else {
|
||||
MathUtils.lerp(0f, 135f, mProgress)
|
||||
}
|
||||
canvas.save()
|
||||
canvas.translate(bounds.centerX().toFloat(), bounds.centerY().toFloat())
|
||||
canvas.rotate(canvasRotate)
|
||||
canvas.drawPath(mPath, mPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
fun setColor(color: Int) {
|
||||
mPaint.color = color
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
mPaint.alpha = alpha
|
||||
}
|
||||
|
||||
override fun setColorFilter(cf: ColorFilter?) {
|
||||
mPaint.colorFilter = cf
|
||||
}
|
||||
|
||||
override fun getIntrinsicHeight(): Int {
|
||||
return mSize * 6 / 5
|
||||
}
|
||||
|
||||
override fun getIntrinsicWidth(): Int {
|
||||
return mSize * 6 / 5
|
||||
}
|
||||
|
||||
/**
|
||||
* If set, canvas is flipped when progress reached to end and going back to start.
|
||||
*/
|
||||
private fun setVerticalMirror(verticalMirror: Boolean) {
|
||||
mVerticalMirror = verticalMirror
|
||||
}
|
||||
|
||||
fun setAutoUpdateMirror(autoUpdateMirror: Boolean) {
|
||||
mAutoUpdateMirror = autoUpdateMirror
|
||||
}
|
||||
|
||||
@get:Keep
|
||||
@set:Keep
|
||||
var progress: Float
|
||||
get() = mProgress
|
||||
set(progress) {
|
||||
if (mAutoUpdateMirror) {
|
||||
if (progress == 1f) {
|
||||
setVerticalMirror(true)
|
||||
} else if (progress == 0f) {
|
||||
setVerticalMirror(false)
|
||||
}
|
||||
}
|
||||
mProgress = progress
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
fun setAdd(duration: Long) {
|
||||
setShape(false, duration)
|
||||
}
|
||||
|
||||
fun setDelete(duration: Long) {
|
||||
setShape(true, duration)
|
||||
}
|
||||
|
||||
private fun setShape(delete: Boolean, duration: Long) {
|
||||
if (!(!delete && mProgress == 0f || delete && mProgress == 1f)) {
|
||||
val endProgress = if (delete) 1f else 0f
|
||||
if (duration <= 0) {
|
||||
progress = endProgress
|
||||
} else {
|
||||
val oa = ObjectAnimator.ofFloat(this, "progress", endProgress)
|
||||
oa.duration = duration
|
||||
oa.setAutoCancel(true)
|
||||
oa.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"Deprecated in Java",
|
||||
ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"),
|
||||
)
|
||||
override fun getOpacity(): Int {
|
||||
return PixelFormat.TRANSLUCENT
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/*
|
||||
* 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.ui.legacy
|
||||
|
||||
import android.view.View
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import com.hippo.ehviewer.Settings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class AutoStaggeredGridLayoutManager(columnSize: Int, orientation: Int) :
|
||||
StaggeredGridLayoutManager(1, orientation) {
|
||||
private var mColumnSize = columnSize
|
||||
private var mColumnSizeChanged = true
|
||||
private var mStrategy = 0
|
||||
var supportsPredictiveItemAnimations = true
|
||||
|
||||
fun setColumnSize(columnSize: Int) {
|
||||
if (columnSize == mColumnSize) {
|
||||
return
|
||||
}
|
||||
mColumnSize = columnSize
|
||||
mColumnSizeChanged = true
|
||||
}
|
||||
|
||||
fun setStrategy(strategy: Int) {
|
||||
if (strategy == mStrategy) {
|
||||
return
|
||||
}
|
||||
mStrategy = strategy
|
||||
mColumnSizeChanged = true
|
||||
}
|
||||
|
||||
override fun supportsPredictiveItemAnimations(): Boolean {
|
||||
return supportsPredictiveItemAnimations && super.supportsPredictiveItemAnimations()
|
||||
}
|
||||
|
||||
override fun onMeasure(
|
||||
recycler: RecyclerView.Recycler,
|
||||
state: RecyclerView.State,
|
||||
widthSpec: Int,
|
||||
heightSpec: Int,
|
||||
) {
|
||||
if (mColumnSizeChanged && mColumnSize > 0) {
|
||||
val totalSpace = if (orientation == VERTICAL) {
|
||||
check(
|
||||
View.MeasureSpec.EXACTLY == View.MeasureSpec.getMode(
|
||||
widthSpec,
|
||||
),
|
||||
) { "RecyclerView need a fixed width for AutoStaggeredGridLayoutManager" }
|
||||
View.MeasureSpec.getSize(widthSpec) - paddingRight - paddingLeft
|
||||
} else {
|
||||
check(
|
||||
View.MeasureSpec.EXACTLY == View.MeasureSpec.getMode(
|
||||
heightSpec,
|
||||
),
|
||||
) { "RecyclerView need a fixed height for AutoStaggeredGridLayoutManager" }
|
||||
View.MeasureSpec.getSize(heightSpec) - paddingTop - paddingBottom
|
||||
}
|
||||
val spanCount = when (mStrategy) {
|
||||
STRATEGY_MIN_SIZE -> getSpanCountForMinSize(
|
||||
totalSpace,
|
||||
mColumnSize,
|
||||
)
|
||||
|
||||
STRATEGY_SUITABLE_SIZE -> getSpanCountForSuitableSize(
|
||||
totalSpace,
|
||||
mColumnSize,
|
||||
)
|
||||
|
||||
else -> getSpanCountForMinSize(
|
||||
totalSpace,
|
||||
mColumnSize,
|
||||
)
|
||||
}
|
||||
setSpanCount(spanCount)
|
||||
mColumnSizeChanged = false
|
||||
}
|
||||
super.onMeasure(recycler, state, widthSpec, heightSpec)
|
||||
}
|
||||
}
|
||||
|
||||
const val STRATEGY_MIN_SIZE = 0
|
||||
const val STRATEGY_SUITABLE_SIZE = 1
|
||||
fun getSpanCountForSuitableSize(total: Int, single: Int): Int {
|
||||
return (total / single.toFloat()).roundToInt().coerceAtLeast(1)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun calculateSuitableSpanCount(): Int {
|
||||
val totalSpace = LocalConfiguration.current.screenWidthDp
|
||||
return getSpanCountForSuitableSize(totalSpace, Settings.thumbSizeDp)
|
||||
}
|
||||
|
||||
fun getSpanCountForMinSize(total: Int, single: Int): Int {
|
||||
return (total / single).coerceAtLeast(1)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 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.ui.legacy
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class BringOutTransition(contentLayout: FrameLayout, mSearchLayout: SearchLayout) :
|
||||
ViewTransition(contentLayout, mSearchLayout) {
|
||||
override fun startAnimations(hiddenView: View, shownView: View) {
|
||||
mAnimator1 = hiddenView.animate().alpha(0f).scaleY(0.7f).scaleX(0.7f).apply {
|
||||
setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
hiddenView.visibility = View.GONE
|
||||
mAnimator1 = null
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
shownView.alpha = 0f
|
||||
shownView.scaleX = 0.7f
|
||||
shownView.scaleY = 0.7f
|
||||
shownView.visibility = View.VISIBLE
|
||||
mAnimator2 = shownView.animate().alpha(1f).scaleX(1f).scaleY(1f).apply {
|
||||
setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
mAnimator2 = null
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* 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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.CheckBox
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class CheckBoxDialogBuilder(
|
||||
context: Context,
|
||||
message: String?,
|
||||
checkText: String?,
|
||||
checked: Boolean,
|
||||
) : BaseDialogBuilder(
|
||||
context,
|
||||
) {
|
||||
private val mCheckBox: CheckBox
|
||||
val isChecked: Boolean
|
||||
get() = mCheckBox.isChecked
|
||||
|
||||
init {
|
||||
val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_checkbox_builder, null)
|
||||
setView(view)
|
||||
setMessage(message)
|
||||
mCheckBox = view.findViewById(R.id.checkbox)
|
||||
mCheckBox.text = checkText
|
||||
mCheckBox.isChecked = checked
|
||||
view.setOnClickListener { mCheckBox.toggle() }
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
|
||||
class DrawerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : FrameLayout(context, attrs) {
|
||||
private var mMaxWidth = 0
|
||||
|
||||
init {
|
||||
val a = context.obtainStyledAttributes(attrs, SIZE_ATTRS)
|
||||
mMaxWidth = a.getDimensionPixelOffset(0, 0)
|
||||
a.recycle()
|
||||
}
|
||||
|
||||
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
|
||||
var widthSpec = widthSpec
|
||||
when (MeasureSpec.getMode(widthSpec)) {
|
||||
MeasureSpec.EXACTLY -> {}
|
||||
MeasureSpec.AT_MOST -> widthSpec = MeasureSpec.makeMeasureSpec(
|
||||
MeasureSpec.getSize(widthSpec).coerceAtMost(mMaxWidth),
|
||||
MeasureSpec.EXACTLY,
|
||||
)
|
||||
|
||||
MeasureSpec.UNSPECIFIED ->
|
||||
widthSpec = MeasureSpec.makeMeasureSpec(mMaxWidth, MeasureSpec.EXACTLY)
|
||||
}
|
||||
// Let super sort out the height
|
||||
super.onMeasure(widthSpec, heightSpec)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val SIZE_ATTRS = intArrayOf(android.R.attr.maxWidth)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* 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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.TextView.OnEditorActionListener
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
class EditTextDialogBuilder(
|
||||
context: Context,
|
||||
text: String?,
|
||||
hint: String?,
|
||||
) : BaseDialogBuilder(
|
||||
context,
|
||||
),
|
||||
OnEditorActionListener {
|
||||
private val mTextInputLayout: TextInputLayout
|
||||
val editText: EditText
|
||||
private var mDialog: AlertDialog? = null
|
||||
val text: String
|
||||
get() = editText.text.toString()
|
||||
|
||||
fun setError(error: CharSequence?) {
|
||||
mTextInputLayout.error = error
|
||||
}
|
||||
|
||||
override fun create(): AlertDialog {
|
||||
mDialog = super.create()
|
||||
return mDialog as AlertDialog
|
||||
}
|
||||
|
||||
override fun onEditorAction(v: TextView?, p1: Int, event: KeyEvent?): Boolean {
|
||||
return if (mDialog != null) {
|
||||
val button = mDialog!!.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
button?.performClick()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val view = LayoutInflater.from(getContext()).inflate(R.layout.dialog_edittext_builder, null)
|
||||
setView(view)
|
||||
mTextInputLayout = view as TextInputLayout
|
||||
editText = view.findViewById(R.id.edit_text)
|
||||
editText.setText(text)
|
||||
editText.setSelection(editText.text.length)
|
||||
editText.setOnEditorActionListener(this)
|
||||
mTextInputLayout.hint = hint
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 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.ui.legacy
|
||||
|
||||
import android.animation.Animator
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.Interpolator
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.util.AnimationUtils
|
||||
import com.hippo.ehviewer.util.SimpleAnimatorListener
|
||||
import com.hippo.ehviewer.util.getParcelableCompat
|
||||
|
||||
class FabLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : ViewGroup(context, attrs, defStyleAttr), View.OnClickListener {
|
||||
private var mFabSize = 0
|
||||
private var mFabMiniSize = 0
|
||||
private var mIntervalPrimary = 0
|
||||
private var mIntervalSecondary = 0
|
||||
private var mExpanded = true
|
||||
private var mAutoCancel = true
|
||||
private var mHidePrimaryFab = false
|
||||
private var mMainFabCenterY = -1f
|
||||
private var mOnExpandListeners = arrayListOf<OnExpandListener>()
|
||||
private var mOnClickFabListener: OnClickFabListener? = null
|
||||
|
||||
private val mOnBackPressedCallback = object : OnBackPressedCallback(false), OnExpandListener {
|
||||
override fun handleOnBackPressed() {
|
||||
isExpanded = false
|
||||
}
|
||||
|
||||
override fun onExpand(expanded: Boolean) {
|
||||
isEnabled = expanded
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
(context as ComponentActivity).onBackPressedDispatcher.addCallback(mOnBackPressedCallback)
|
||||
mOnExpandListeners.add(mOnBackPressedCallback)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
mOnBackPressedCallback.remove()
|
||||
mOnExpandListeners.remove(mOnBackPressedCallback)
|
||||
}
|
||||
|
||||
init {
|
||||
isSoundEffectsEnabled = false
|
||||
clipToPadding = false
|
||||
mFabSize = context.resources.getDimensionPixelOffset(R.dimen.fab_size)
|
||||
mFabMiniSize = context.resources.getDimensionPixelOffset(R.dimen.fab_min_size)
|
||||
mIntervalPrimary =
|
||||
context.resources.getDimensionPixelOffset(R.dimen.fab_layout_primary_margin)
|
||||
mIntervalSecondary =
|
||||
context.resources.getDimensionPixelOffset(R.dimen.fab_layout_secondary_margin)
|
||||
}
|
||||
|
||||
override fun addView(child: View, index: Int, params: LayoutParams) {
|
||||
check(child is FloatingActionButton) { "FloatingActionBarLayout should only " + "contain FloatingActionButton, but try to add " + child.javaClass.name }
|
||||
super.addView(child, index, params)
|
||||
}
|
||||
|
||||
val primaryFab: FloatingActionButton?
|
||||
get() = getChildAt(childCount - 1) as? FloatingActionButton
|
||||
|
||||
private val secondaryFabCount: Int
|
||||
get() = 0.coerceAtLeast(childCount - 1)
|
||||
|
||||
private fun getSecondaryFabAt(index: Int): FloatingActionButton? {
|
||||
return if (index < 0 || index >= secondaryFabCount) {
|
||||
null
|
||||
} else {
|
||||
getChildAt(index) as FloatingActionButton
|
||||
}
|
||||
}
|
||||
|
||||
fun setSecondaryFabVisibilityAt(index: Int, visible: Boolean) {
|
||||
getSecondaryFabAt(index)?.run {
|
||||
if (visible && visibility == GONE) {
|
||||
animate().cancel()
|
||||
visibility = if (mExpanded) VISIBLE else INVISIBLE
|
||||
} else if (!visible && visibility != GONE) {
|
||||
animate().cancel()
|
||||
visibility = GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChildMeasureSpec(parentMeasureSpec: Int): Int {
|
||||
val parentMode = MeasureSpec.getMode(parentMeasureSpec)
|
||||
val parentSize = MeasureSpec.getSize(parentMeasureSpec)
|
||||
val childMode: Int
|
||||
val childSize: Int
|
||||
when (parentMode) {
|
||||
MeasureSpec.AT_MOST, MeasureSpec.EXACTLY -> {
|
||||
childMode = MeasureSpec.AT_MOST
|
||||
childSize = parentSize
|
||||
}
|
||||
|
||||
MeasureSpec.UNSPECIFIED -> {
|
||||
childMode = MeasureSpec.UNSPECIFIED
|
||||
childSize = parentSize
|
||||
}
|
||||
|
||||
else -> {
|
||||
childMode = MeasureSpec.AT_MOST
|
||||
childSize = parentSize
|
||||
}
|
||||
}
|
||||
return MeasureSpec.makeMeasureSpec(childSize, childMode)
|
||||
}
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec)
|
||||
val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec)
|
||||
measureChildren(childWidthMeasureSpec, childHeightMeasureSpec)
|
||||
var maxWidth = 0
|
||||
var maxHeight = 0
|
||||
val count = childCount
|
||||
for (i in 0 until count) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == GONE) {
|
||||
continue
|
||||
}
|
||||
maxWidth = maxWidth.coerceAtLeast(child.measuredWidth)
|
||||
maxHeight += child.measuredHeight
|
||||
}
|
||||
maxWidth += paddingLeft + paddingRight
|
||||
maxHeight += paddingTop + paddingBottom
|
||||
maxHeight = maxHeight.coerceAtLeast(suggestedMinimumHeight)
|
||||
maxWidth = maxWidth.coerceAtLeast(suggestedMinimumWidth)
|
||||
setMeasuredDimension(
|
||||
resolveSizeAndState(maxWidth, widthMeasureSpec, 0),
|
||||
resolveSizeAndState(maxHeight, heightMeasureSpec, 0),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||
var centerX = 0
|
||||
var bottom = measuredHeight - paddingBottom
|
||||
val count = childCount
|
||||
var i = count
|
||||
while (--i >= 0) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == GONE) {
|
||||
continue
|
||||
}
|
||||
val childWidth = child.measuredWidth
|
||||
val childHeight = child.measuredHeight
|
||||
var layoutBottom: Int
|
||||
var layoutRight: Int
|
||||
if (i == count - 1) {
|
||||
layoutBottom = bottom + (childHeight - mFabSize) / 2
|
||||
layoutRight = measuredWidth - paddingRight + (childWidth - mFabSize) / 2
|
||||
bottom -= mFabSize + mIntervalPrimary
|
||||
centerX = layoutRight - childWidth / 2
|
||||
mMainFabCenterY = layoutBottom - childHeight / 2f
|
||||
} else {
|
||||
layoutBottom = bottom + (childHeight - mFabMiniSize) / 2
|
||||
layoutRight = centerX + childWidth / 2
|
||||
bottom -= mFabMiniSize + mIntervalSecondary
|
||||
}
|
||||
child.layout(
|
||||
layoutRight - childWidth,
|
||||
layoutBottom - childHeight,
|
||||
layoutRight,
|
||||
layoutBottom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun addOnExpandListener(listener: OnExpandListener) {
|
||||
mOnExpandListeners.add(listener)
|
||||
}
|
||||
|
||||
fun setOnClickFabListener(listener: OnClickFabListener?) {
|
||||
mOnClickFabListener = listener
|
||||
if (listener != null) {
|
||||
var i = 0
|
||||
val n = childCount
|
||||
while (i < n) {
|
||||
getChildAt(i).setOnClickListener(this)
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
var i = 0
|
||||
val n = childCount
|
||||
while (i < n) {
|
||||
getChildAt(i).isClickable = false
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setHidePrimaryFab(hidePrimaryFab: Boolean) {
|
||||
if (mHidePrimaryFab != hidePrimaryFab) {
|
||||
mHidePrimaryFab = hidePrimaryFab
|
||||
val expanded = mExpanded
|
||||
val count = childCount
|
||||
if (!expanded && count > 0) {
|
||||
getChildAt(count - 1).visibility = if (hidePrimaryFab) INVISIBLE else VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoCancel(autoCancel: Boolean) {
|
||||
if (mAutoCancel != autoCancel) {
|
||||
mAutoCancel = autoCancel
|
||||
if (mExpanded) {
|
||||
if (autoCancel) {
|
||||
setOnClickListener(this)
|
||||
} else {
|
||||
isClickable = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle() {
|
||||
isExpanded = !mExpanded
|
||||
}
|
||||
|
||||
var isExpanded: Boolean
|
||||
get() = mExpanded
|
||||
set(expanded) {
|
||||
setExpanded(expanded, true)
|
||||
}
|
||||
|
||||
fun setExpanded(expanded: Boolean, animation: Boolean) {
|
||||
if (mExpanded != expanded) {
|
||||
mExpanded = expanded
|
||||
if (mAutoCancel) {
|
||||
if (expanded) {
|
||||
setOnClickListener(this)
|
||||
} else {
|
||||
isClickable = false
|
||||
}
|
||||
}
|
||||
val count = childCount
|
||||
if (count > 0) {
|
||||
if (mMainFabCenterY == -1f || !animation) {
|
||||
// It is before first onLayout
|
||||
val checkCount = if (mHidePrimaryFab) count else count - 1
|
||||
for (i in 0 until checkCount) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == GONE) {
|
||||
continue
|
||||
}
|
||||
child.visibility = if (expanded) VISIBLE else INVISIBLE
|
||||
if (expanded) {
|
||||
child.alpha = 1f
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mHidePrimaryFab) {
|
||||
setPrimaryFabAnimation(getChildAt(count - 1), expanded, !expanded)
|
||||
}
|
||||
for (i in 0 until count - 1) {
|
||||
val child = getChildAt(i)
|
||||
if (child.visibility == GONE) {
|
||||
continue
|
||||
}
|
||||
setSecondaryFabAnimation(child, expanded, expanded)
|
||||
}
|
||||
}
|
||||
}
|
||||
mOnExpandListeners.forEach { it.onExpand(expanded) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPrimaryFabAnimation(child: View, expanded: Boolean, delay: Boolean) {
|
||||
val startRotation: Float
|
||||
val endRotation: Float
|
||||
val startScale: Float
|
||||
val endScale: Float
|
||||
val interpolator: Interpolator
|
||||
if (expanded) {
|
||||
startRotation = -45.0f
|
||||
endRotation = 0.0f
|
||||
startScale = 0.0f
|
||||
endScale = 1.0f
|
||||
interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR
|
||||
} else {
|
||||
startRotation = 0.0f
|
||||
endRotation = 0.0f
|
||||
startScale = 1.0f
|
||||
endScale = 0.0f
|
||||
interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR
|
||||
}
|
||||
child.scaleX = startScale
|
||||
child.scaleY = startScale
|
||||
child.rotation = startRotation
|
||||
child.animate()
|
||||
.scaleX(endScale)
|
||||
.scaleY(endScale)
|
||||
.rotation(endRotation)
|
||||
.setStartDelay(if (delay) ANIMATE_TIME else 0L)
|
||||
.setDuration(ANIMATE_TIME)
|
||||
.setInterpolator(interpolator)
|
||||
.setListener(object : SimpleAnimatorListener() {
|
||||
override fun onAnimationStart(animation: Animator) {
|
||||
if (expanded) {
|
||||
child.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (!expanded) {
|
||||
child.visibility = INVISIBLE
|
||||
}
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
|
||||
private fun setSecondaryFabAnimation(child: View, expanded: Boolean, delay: Boolean) {
|
||||
val startTranslationY: Float
|
||||
val endTranslationY: Float
|
||||
val startAlpha: Float
|
||||
val endAlpha: Float
|
||||
val interpolator: Interpolator
|
||||
if (expanded) {
|
||||
startTranslationY = mMainFabCenterY - child.top - child.height / 2
|
||||
endTranslationY = 0f
|
||||
startAlpha = 0f
|
||||
endAlpha = 1f
|
||||
interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR
|
||||
} else {
|
||||
startTranslationY = 0f
|
||||
endTranslationY = mMainFabCenterY - child.top - child.height / 2
|
||||
startAlpha = 1f
|
||||
endAlpha = 0f
|
||||
interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR
|
||||
}
|
||||
child.alpha = startAlpha
|
||||
child.translationY = startTranslationY
|
||||
child.animate()
|
||||
.alpha(endAlpha)
|
||||
.translationY(endTranslationY)
|
||||
.setStartDelay(if (delay) ANIMATE_TIME else 0L)
|
||||
.setDuration(ANIMATE_TIME)
|
||||
.setInterpolator(interpolator)
|
||||
.setListener(object : SimpleAnimatorListener() {
|
||||
override fun onAnimationStart(animation: Animator) {
|
||||
if (expanded) {
|
||||
child.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (!expanded) {
|
||||
child.visibility = INVISIBLE
|
||||
}
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
if (this === v) {
|
||||
isExpanded = false
|
||||
} else {
|
||||
mOnClickFabListener?.let {
|
||||
val position = indexOfChild(v)
|
||||
if (position == childCount - 1) {
|
||||
it.onClickPrimaryFab(this, v as FloatingActionButton)
|
||||
} else if (position >= 0 && mExpanded) {
|
||||
it.onClickSecondaryFab(this, v as FloatingActionButton, position)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchSetPressed(pressed: Boolean) {
|
||||
// Don't dispatch it to children
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val state = Bundle()
|
||||
state.putParcelable(STATE_KEY_SUPER, super.onSaveInstanceState())
|
||||
state.putBoolean(STATE_KEY_AUTO_CANCEL, mAutoCancel)
|
||||
state.putBoolean(STATE_KEY_EXPANDED, mExpanded)
|
||||
return state
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable) {
|
||||
if (state is Bundle) {
|
||||
super.onRestoreInstanceState(state.getParcelableCompat(STATE_KEY_SUPER))
|
||||
setAutoCancel(state.getBoolean(STATE_KEY_AUTO_CANCEL))
|
||||
setExpanded(state.getBoolean(STATE_KEY_EXPANDED), false)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnExpandListener {
|
||||
fun onExpand(expanded: Boolean)
|
||||
}
|
||||
|
||||
interface OnClickFabListener {
|
||||
fun onClickPrimaryFab(view: FabLayout, fab: FloatingActionButton)
|
||||
fun onClickSecondaryFab(view: FabLayout, fab: FloatingActionButton, position: Int)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANIMATE_TIME = 300L
|
||||
private const val STATE_KEY_SUPER = "super"
|
||||
private const val STATE_KEY_AUTO_CANCEL = "auto_cancel"
|
||||
private const val STATE_KEY_EXPANDED = "expanded"
|
||||
}
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
/*
|
||||
* Copyright 2015 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.ui.legacy
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ObjectAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
|
||||
import com.google.android.material.math.MathUtils.dist
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.util.AnimationUtils
|
||||
import com.hippo.ehviewer.util.SimpleAnimatorListener
|
||||
import com.hippo.ehviewer.util.SimpleHandler
|
||||
import com.hippo.ehviewer.util.dp2px
|
||||
import kotlin.math.abs
|
||||
|
||||
class FastScroller : View {
|
||||
private var mDraggable = false
|
||||
private var mMinHandlerHeight = 0
|
||||
private var mRecyclerView: RecyclerView? = null
|
||||
private var mOnScrollChangeListener: RecyclerView.OnScrollListener? = null
|
||||
private var mAdapter: RecyclerView.Adapter<*>? = null
|
||||
private var mAdapterDataObserver: AdapterDataObserver? = null
|
||||
private var mHandler: Drawable? = null
|
||||
private var mHandlerOffset = INVALID
|
||||
private var mHandlerHeight = INVALID
|
||||
private var mDownX = INVALID.toFloat()
|
||||
private var mDownY = INVALID.toFloat()
|
||||
private var mLastMotionY = INVALID.toFloat()
|
||||
private var mDragged = false
|
||||
private var mCantDrag = false
|
||||
private var mTouchSlop = 0
|
||||
private var mListener: OnDragHandlerListener? = null
|
||||
private var mShowAnimator: ObjectAnimator? = null
|
||||
private var mHideAnimator: ObjectAnimator? = null
|
||||
private val mHideRunnable = Runnable { mHideAnimator!!.start() }
|
||||
|
||||
constructor(context: Context) : super(context) {
|
||||
init(context, null, 0)
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
|
||||
init(context, attrs, 0)
|
||||
}
|
||||
|
||||
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(
|
||||
context,
|
||||
attrs,
|
||||
defStyleAttr,
|
||||
) {
|
||||
init(context, attrs, defStyleAttr)
|
||||
}
|
||||
|
||||
private fun init(context: Context, attrs: AttributeSet?, defStyleAttr: Int) {
|
||||
val a = context.obtainStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr, 0)
|
||||
mHandler = a.getDrawable(R.styleable.FastScroller_handler)
|
||||
mDraggable = a.getBoolean(R.styleable.FastScroller_draggable, true)
|
||||
a.recycle()
|
||||
alpha = 0.0f
|
||||
visibility = INVISIBLE
|
||||
mMinHandlerHeight = dp2px(context, MIN_HANDLER_HEIGHT_DP.toFloat())
|
||||
mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
mShowAnimator = ObjectAnimator.ofFloat(this, "alpha", 1.0f).apply {
|
||||
interpolator = AnimationUtils.FAST_SLOW_INTERPOLATOR
|
||||
duration = SCROLL_BAR_FADE_DURATION.toLong()
|
||||
}
|
||||
mHideAnimator = ObjectAnimator.ofFloat(this, "alpha", 0.0f).apply {
|
||||
interpolator = AnimationUtils.SLOW_FAST_INTERPOLATOR
|
||||
duration = SCROLL_BAR_FADE_DURATION.toLong()
|
||||
addListener(object : SimpleAnimatorListener() {
|
||||
private var mCancel = false
|
||||
override fun onAnimationCancel(animation: Animator) {
|
||||
mCancel = true
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (mCancel) {
|
||||
mCancel = false
|
||||
} else {
|
||||
visibility = INVISIBLE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun setOnDragHandlerListener(listener: OnDragHandlerListener?) {
|
||||
mListener = listener
|
||||
}
|
||||
|
||||
private fun updatePosition(show: Boolean) {
|
||||
if (mRecyclerView == null) {
|
||||
return
|
||||
}
|
||||
val paddingTop = paddingTop
|
||||
val paddingBottom = paddingBottom
|
||||
val height = height - paddingTop - paddingBottom
|
||||
val offset = mRecyclerView!!.computeVerticalScrollOffset()
|
||||
val extent = mRecyclerView!!.computeVerticalScrollExtent()
|
||||
val range = mRecyclerView!!.computeVerticalScrollRange()
|
||||
if (height <= 0 || extent >= range || extent <= 0) {
|
||||
return
|
||||
}
|
||||
var endOffset = height * offset / range
|
||||
var endHeight = height * extent / range
|
||||
endHeight = endHeight.coerceAtLeast(mMinHandlerHeight)
|
||||
endOffset = endOffset.coerceAtMost(height - endHeight)
|
||||
mHandlerOffset = endOffset + paddingTop
|
||||
mHandlerHeight = endHeight
|
||||
if (show) {
|
||||
if (mHideAnimator!!.isRunning) {
|
||||
mHideAnimator!!.cancel()
|
||||
mShowAnimator!!.start()
|
||||
} else if (visibility != VISIBLE && !mShowAnimator!!.isRunning) {
|
||||
visibility = VISIBLE
|
||||
mShowAnimator!!.start()
|
||||
}
|
||||
SimpleHandler.removeCallbacks(mHideRunnable)
|
||||
if (!mDragged) {
|
||||
SimpleHandler.postDelayed(mHideRunnable, SCROLL_BAR_DELAY.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setHandlerDrawable(drawable: Drawable?) {
|
||||
mHandler = drawable
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var isDraggable: Boolean
|
||||
get() = mDraggable
|
||||
set(draggable) {
|
||||
mDraggable = draggable
|
||||
if (mDragged) {
|
||||
mDragged = false
|
||||
}
|
||||
SimpleHandler.removeCallbacks(mHideRunnable)
|
||||
mHideRunnable.run()
|
||||
}
|
||||
val isAttached: Boolean
|
||||
get() = mRecyclerView != null
|
||||
|
||||
fun attachToRecyclerView(recyclerView: RecyclerView?) {
|
||||
if (recyclerView == null) {
|
||||
return
|
||||
}
|
||||
check(mRecyclerView == null) {
|
||||
"The FastScroller is already attached to a RecyclerView, " +
|
||||
"call detachedFromRecyclerView first"
|
||||
}
|
||||
mRecyclerView = recyclerView
|
||||
mOnScrollChangeListener = object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {}
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
updatePosition(true)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
recyclerView.addOnScrollListener(mOnScrollChangeListener!!)
|
||||
mAdapter = recyclerView.adapter
|
||||
if (mAdapter != null) {
|
||||
mAdapterDataObserver = object : AdapterDataObserver() {
|
||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
|
||||
super.onItemRangeChanged(positionStart, itemCount)
|
||||
updatePosition(false)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
|
||||
super.onItemRangeChanged(positionStart, itemCount, payload)
|
||||
updatePosition(false)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
super.onItemRangeInserted(positionStart, itemCount)
|
||||
updatePosition(false)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
|
||||
super.onItemRangeRemoved(positionStart, itemCount)
|
||||
updatePosition(false)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
|
||||
super.onItemRangeMoved(fromPosition, toPosition, itemCount)
|
||||
updatePosition(false)
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
mAdapter!!.registerAdapterDataObserver(mAdapterDataObserver!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun detachedFromRecyclerView() {
|
||||
if (mRecyclerView != null && mOnScrollChangeListener != null) {
|
||||
mRecyclerView!!.removeOnScrollListener(mOnScrollChangeListener!!)
|
||||
}
|
||||
mRecyclerView = null
|
||||
mOnScrollChangeListener = null
|
||||
if (mAdapter != null && mAdapterDataObserver != null) {
|
||||
mAdapter!!.unregisterAdapterDataObserver(mAdapterDataObserver!!)
|
||||
}
|
||||
mAdapter = null
|
||||
mAdapterDataObserver = null
|
||||
alpha = 0.0f
|
||||
visibility = INVISIBLE
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (mRecyclerView == null || mHandler == null) {
|
||||
return
|
||||
}
|
||||
if (mHandlerHeight == INVALID) {
|
||||
updatePosition(false)
|
||||
}
|
||||
if (mHandlerHeight == INVALID) {
|
||||
return
|
||||
}
|
||||
val paddingLeft = paddingLeft
|
||||
val saved = canvas.save()
|
||||
canvas.translate(paddingLeft.toFloat(), mHandlerOffset.toFloat())
|
||||
mHandler!!.setBounds(0, 0, width - paddingLeft - paddingRight, mHandlerHeight)
|
||||
mHandler!!.draw(canvas)
|
||||
canvas.restoreToCount(saved)
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (!mDraggable || visibility != VISIBLE || mRecyclerView == null || mHandlerHeight == INVALID) {
|
||||
return false
|
||||
}
|
||||
val action = event.action
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
mCantDrag = false
|
||||
}
|
||||
if (mCantDrag) {
|
||||
return false
|
||||
}
|
||||
when (action) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
mDragged = false
|
||||
mDownX = event.x
|
||||
mDownY = event.y
|
||||
if (mDownY < mHandlerOffset || mDownY > mHandlerOffset + mHandlerHeight) {
|
||||
mCantDrag = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
if (!mDragged) {
|
||||
val x = event.x
|
||||
val y = event.y
|
||||
// Check touch slop
|
||||
if (dist(x, y, mDownX, mDownY) < mTouchSlop) {
|
||||
return true
|
||||
}
|
||||
if (abs(x - mDownX) > abs(y - mDownY) || y < mHandlerOffset || y > mHandlerOffset + mHandlerHeight) {
|
||||
mCantDrag = true
|
||||
return false
|
||||
} else {
|
||||
mDragged = true
|
||||
SimpleHandler.removeCallbacks(mHideRunnable)
|
||||
// Update mLastMotionY
|
||||
mLastMotionY =
|
||||
if (mDownY < mHandlerOffset || mDownY >= mHandlerOffset + mHandlerHeight) {
|
||||
// the point out of handler, make the point in handler center
|
||||
(mHandlerOffset + mHandlerHeight / 2).toFloat()
|
||||
} else {
|
||||
mDownY
|
||||
}
|
||||
// Notify
|
||||
if (mListener != null) {
|
||||
mListener!!.onStartDragHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
val range = mRecyclerView!!.computeVerticalScrollRange()
|
||||
if (range <= 0) {
|
||||
return true
|
||||
}
|
||||
val y = event.y
|
||||
val scroll =
|
||||
(range * (y - mLastMotionY) / (height - paddingTop - paddingBottom)).toInt()
|
||||
mRecyclerView!!.scrollBy(0, scroll)
|
||||
mLastMotionY = y
|
||||
}
|
||||
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
// Notify
|
||||
if (mDragged && mListener != null) {
|
||||
mListener!!.onEndDragHandler()
|
||||
}
|
||||
mDragged = false
|
||||
SimpleHandler.postDelayed(mHideRunnable, SCROLL_BAR_DELAY.toLong())
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
interface OnDragHandlerListener {
|
||||
fun onStartDragHandler()
|
||||
fun onEndDragHandler()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val INVALID = -1
|
||||
private const val SCROLL_BAR_FADE_DURATION = 500
|
||||
private const val SCROLL_BAR_DELAY = 1000
|
||||
private const val MIN_HANDLER_HEIGHT_DP = 32
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* Copyright 2015 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.ui.legacy
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
class HandlerDrawable : Drawable() {
|
||||
private val mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val mTemp = RectF()
|
||||
private var mColor = Color.BLACK
|
||||
|
||||
init {
|
||||
mPaint.color = mColor
|
||||
mPaint.style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
val width = bounds.width()
|
||||
val height = bounds.height()
|
||||
if (width > height) {
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), mPaint)
|
||||
} else {
|
||||
mTemp[0f, 0f, width.toFloat()] = width.toFloat()
|
||||
canvas.drawArc(mTemp, -180f, 180f, true, mPaint)
|
||||
mTemp[0f, (height - width).toFloat(), width.toFloat()] = height.toFloat()
|
||||
canvas.drawArc(mTemp, 0f, 180f, true, mPaint)
|
||||
val halfWidth = width.toFloat() / 2.0f
|
||||
canvas.drawRect(0f, halfWidth, width.toFloat(), height - halfWidth, mPaint)
|
||||
}
|
||||
}
|
||||
|
||||
fun setColor(color: Int) {
|
||||
if (mColor != color) {
|
||||
mColor = color
|
||||
mPaint.color = color
|
||||
}
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) {
|
||||
mPaint.alpha = alpha
|
||||
}
|
||||
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
||||
mPaint.colorFilter = colorFilter
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun getOpacity(): Int {
|
||||
return when (Color.alpha(mColor)) {
|
||||
0xff -> {
|
||||
PixelFormat.OPAQUE
|
||||
}
|
||||
|
||||
0x00 -> {
|
||||
PixelFormat.TRANSPARENT
|
||||
}
|
||||
|
||||
else -> {
|
||||
PixelFormat.TRANSLUCENT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
* 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.ui.legacy
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.widget.ListView
|
||||
import com.hippo.ehviewer.R
|
||||
|
||||
@SuppressLint("CustomViewStyleable")
|
||||
class IndicatingListView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
) : ListView(context, attrs) {
|
||||
private val mPaint = Paint()
|
||||
private val mTemp = Rect()
|
||||
private var mIndicatorHeight = 0
|
||||
|
||||
init {
|
||||
val a = context.obtainStyledAttributes(attrs, R.styleable.Indicating)
|
||||
mIndicatorHeight = a.getDimensionPixelOffset(R.styleable.Indicating_indicatorHeight, 1)
|
||||
mPaint.color = a.getColor(
|
||||
R.styleable.Indicating_indicatorColor,
|
||||
Color.BLACK,
|
||||
)
|
||||
mPaint.style = Paint.Style.FILL
|
||||
a.recycle()
|
||||
}
|
||||
|
||||
private fun fillTopIndicatorDrawRect() {
|
||||
mTemp[0, 0, width] = mIndicatorHeight
|
||||
}
|
||||
|
||||
private fun fillBottomIndicatorDrawRect() {
|
||||
mTemp[0, height - mIndicatorHeight, width] = height
|
||||
}
|
||||
|
||||
private fun needShowTopIndicator(): Boolean {
|
||||
return canScrollVertically(-1)
|
||||
}
|
||||
|
||||
private fun needShowBottomIndicator(): Boolean {
|
||||
return canScrollVertically(1)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
super.draw(canvas)
|
||||
val restoreCount = canvas.save()
|
||||
canvas.translate(scrollX.toFloat(), scrollY.toFloat())
|
||||
|
||||
// Draw top indicator
|
||||
if (needShowTopIndicator()) {
|
||||
fillTopIndicatorDrawRect()
|
||||
canvas.drawRect(mTemp, mPaint)
|
||||
}
|
||||
// Draw bottom indicator
|
||||
if (needShowBottomIndicator()) {
|
||||
fillBottomIndicatorDrawRect()
|
||||
canvas.drawRect(mTemp, mPaint)
|
||||
}
|
||||
canvas.restoreToCount(restoreCount)
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
/*
|
||||
* Copyright 2015 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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PointF
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView.LayoutManager
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import com.google.android.material.math.MathUtils
|
||||
import com.hippo.ehviewer.util.SimpleHandler
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Method
|
||||
import kotlin.math.abs
|
||||
|
||||
object LayoutManagerUtils {
|
||||
private var sCsdfp: Method? = null
|
||||
|
||||
init {
|
||||
try {
|
||||
sCsdfp = StaggeredGridLayoutManager::class.java.getDeclaredMethod(
|
||||
"calculateScrollDirectionForPosition",
|
||||
Int::class.javaPrimitiveType,
|
||||
).apply { isAccessible = true }
|
||||
} catch (e: NoSuchMethodException) {
|
||||
// Ignore
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun scrollToPositionWithOffset(
|
||||
layoutManager: LayoutManager,
|
||||
position: Int,
|
||||
offset: Int,
|
||||
) {
|
||||
when (layoutManager) {
|
||||
is LinearLayoutManager -> layoutManager.scrollToPositionWithOffset(position, offset)
|
||||
|
||||
is StaggeredGridLayoutManager ->
|
||||
layoutManager.scrollToPositionWithOffset(position, offset)
|
||||
|
||||
else -> throw IllegalStateException("Can't do scrollToPositionWithOffset for " + layoutManager.javaClass.name)
|
||||
}
|
||||
}
|
||||
|
||||
fun smoothScrollToPosition(
|
||||
layoutManager: LayoutManager,
|
||||
context: Context,
|
||||
position: Int,
|
||||
millisecondsPerInch: Int = -1,
|
||||
) {
|
||||
val smoothScroller: SimpleSmoothScroller
|
||||
when (layoutManager) {
|
||||
is LinearLayoutManager -> {
|
||||
smoothScroller =
|
||||
object : SimpleSmoothScroller(context, millisecondsPerInch.toFloat()) {
|
||||
override fun computeScrollVectorForPosition(targetPosition: Int): PointF? {
|
||||
return layoutManager.computeScrollVectorForPosition(targetPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is StaggeredGridLayoutManager -> {
|
||||
smoothScroller =
|
||||
object : SimpleSmoothScroller(context, millisecondsPerInch.toFloat()) {
|
||||
override fun computeScrollVectorForPosition(targetPosition: Int): PointF? {
|
||||
var direction = 0
|
||||
try {
|
||||
direction = sCsdfp!!.invoke(layoutManager, targetPosition) as Int
|
||||
} catch (e: IllegalAccessException) {
|
||||
e.printStackTrace()
|
||||
} catch (e: InvocationTargetException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
if (direction == 0) {
|
||||
return null
|
||||
}
|
||||
return if (layoutManager.orientation == StaggeredGridLayoutManager.HORIZONTAL) {
|
||||
PointF(direction.toFloat(), 0f)
|
||||
} else {
|
||||
PointF(0f, direction.toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> throw IllegalStateException("Can't do smoothScrollToPosition for " + layoutManager.javaClass.name)
|
||||
}
|
||||
smoothScroller.targetPosition = position
|
||||
layoutManager.startSmoothScroll(smoothScroller)
|
||||
}
|
||||
|
||||
fun scrollToPositionProperly(
|
||||
layoutManager: LayoutManager,
|
||||
context: Context,
|
||||
position: Int,
|
||||
listener: OnScrollToPositionListener?,
|
||||
) {
|
||||
SimpleHandler.postDelayed({
|
||||
val first = layoutManager.firstVisibleItemPosition
|
||||
val last = getLastVisibleItemPosition(layoutManager)
|
||||
val offset = abs(position - first)
|
||||
val max = last - first
|
||||
if (offset < max && max > 0) {
|
||||
smoothScrollToPosition(
|
||||
layoutManager,
|
||||
context,
|
||||
position,
|
||||
MathUtils.lerp(100F, 25F, (offset / max).toFloat()).toInt(),
|
||||
)
|
||||
} else {
|
||||
scrollToPositionWithOffset(layoutManager, position, 0)
|
||||
listener?.onScrollToPosition(position)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
val LayoutManager.firstVisibleItemPosition: Int
|
||||
get() = when (this) {
|
||||
is LinearLayoutManager -> findFirstVisibleItemPosition()
|
||||
is StaggeredGridLayoutManager -> findFirstVisibleItemPositions(null).min()
|
||||
else -> error("Can't do getFirstVisibleItemPosition for " + javaClass.name)
|
||||
}
|
||||
|
||||
fun getLastVisibleItemPosition(layoutManager: LayoutManager): Int {
|
||||
return when (layoutManager) {
|
||||
is LinearLayoutManager -> layoutManager.findLastVisibleItemPosition()
|
||||
is StaggeredGridLayoutManager -> {
|
||||
val positions = layoutManager.findLastVisibleItemPositions(null)
|
||||
positions.max()
|
||||
}
|
||||
|
||||
else -> throw IllegalStateException("Can't do getLastVisibleItemPosition for " + layoutManager.javaClass.name)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface OnScrollToPositionListener {
|
||||
fun onScrollToPosition(position: Int)
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* 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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.databinding.DialogListCheckboxBuilderBinding
|
||||
|
||||
class ListCheckBoxDialogBuilder(
|
||||
context: Context,
|
||||
items: List<CharSequence>,
|
||||
listener: (ListCheckBoxDialogBuilder, AlertDialog?, Int) -> Unit,
|
||||
checkText: String?,
|
||||
checked: Boolean,
|
||||
) : BaseDialogBuilder(
|
||||
context,
|
||||
) {
|
||||
private val binding =
|
||||
DialogListCheckboxBuilderBinding.inflate(LayoutInflater.from(getContext()))
|
||||
private var mDialog: AlertDialog? = null
|
||||
val isChecked get() = binding.checkbox.isChecked
|
||||
|
||||
override fun create(): AlertDialog {
|
||||
mDialog = super.create()
|
||||
return mDialog as AlertDialog
|
||||
}
|
||||
|
||||
init {
|
||||
setView(binding.root)
|
||||
binding.listView.adapter = ArrayAdapter(getContext(), R.layout.item_select_dialog, items)
|
||||
binding.listView.onItemClickListener =
|
||||
AdapterView.OnItemClickListener { _, _, position, _ ->
|
||||
listener(this@ListCheckBoxDialogBuilder, mDialog, position)
|
||||
mDialog?.dismiss()
|
||||
}
|
||||
binding.checkbox.text = checkText
|
||||
binding.checkbox.isChecked = checked
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 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.ui.legacy
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
|
||||
|
||||
class MarginItemDecoration(
|
||||
margin: Int,
|
||||
paddingLeft: Int,
|
||||
paddingTop: Int,
|
||||
paddingRight: Int,
|
||||
paddingBottom: Int,
|
||||
) : ItemDecoration() {
|
||||
private var mMargin = 0
|
||||
private var mPaddingLeft = 0
|
||||
private var mPaddingTop = 0
|
||||
private var mPaddingRight = 0
|
||||
private var mPaddingBottom = 0
|
||||
|
||||
init {
|
||||
setMargin(margin, paddingLeft, paddingTop, paddingRight, paddingBottom)
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
outRect[mMargin, mMargin, mMargin] = mMargin
|
||||
}
|
||||
|
||||
/**
|
||||
* @param margin gap between two item
|
||||
* @param paddingLeft gap between RecyclerView left and left item left
|
||||
* @param paddingTop gap between RecyclerView top and top item top
|
||||
* @param paddingRight gap between RecyclerView right and right item right
|
||||
* @param paddingBottom gap between RecyclerView bottom and bottom item bottom
|
||||
*/
|
||||
fun setMargin(
|
||||
margin: Int,
|
||||
paddingLeft: Int,
|
||||
paddingTop: Int,
|
||||
paddingRight: Int,
|
||||
paddingBottom: Int,
|
||||
) {
|
||||
val halfMargin = margin / 2
|
||||
mMargin = halfMargin
|
||||
mPaddingLeft = paddingLeft - halfMargin
|
||||
mPaddingTop = paddingTop - halfMargin
|
||||
mPaddingRight = paddingRight - halfMargin
|
||||
mPaddingBottom = paddingBottom - halfMargin
|
||||
}
|
||||
|
||||
fun applyPaddings(view: View) {
|
||||
view.setPadding(mPaddingLeft, mPaddingTop, mPaddingRight, mPaddingBottom)
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.util.AttributeSet
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
||||
import androidx.lifecycle.get
|
||||
import com.google.accompanist.themeadapter.material3.Mdc3Theme
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.image.Image
|
||||
import com.hippo.ehviewer.ui.main.AdvancedSearchOption
|
||||
import com.hippo.ehviewer.ui.main.ImageSearch
|
||||
import com.hippo.ehviewer.ui.main.NormalSearch
|
||||
import com.hippo.ehviewer.ui.main.SearchAdvanced
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.pickVisualMedia
|
||||
import com.hippo.unifile.UniFile
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SearchViewModel : ViewModel() {
|
||||
var isNormalMode by mutableStateOf(true) // else ImageSearch mode
|
||||
var isAdvancedMode by mutableStateOf(false)
|
||||
var mCategory by mutableIntStateOf(Settings.searchCategory)
|
||||
var mSearchMode by mutableIntStateOf(1)
|
||||
var advancedState by mutableStateOf(AdvancedSearchOption())
|
||||
var uss by mutableStateOf(false)
|
||||
var osc by mutableStateOf(false)
|
||||
var path by mutableStateOf("")
|
||||
}
|
||||
|
||||
class SearchLayout @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0,
|
||||
) : AbstractComposeView(context, attrs, defStyle) {
|
||||
private val vm by lazy {
|
||||
ViewModelProvider(findViewTreeViewModelStoreOwner()!!).get<SearchViewModel>()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun selectImage() = coroutineScope.launch {
|
||||
context.pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly)?.let {
|
||||
vm.path = it.toString()
|
||||
}
|
||||
}
|
||||
Mdc3Theme {
|
||||
Column(modifier = Modifier.imePadding().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(horizontal = dimensionResource(id = R.dimen.search_layout_margin_h))) {
|
||||
AnimatedVisibility(visible = vm.isNormalMode) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().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))) {
|
||||
Text(text = stringResource(id = R.string.search_normal), modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
||||
NormalSearch(
|
||||
category = vm.mCategory,
|
||||
onCategoryChanged = {
|
||||
Settings.searchCategory = it
|
||||
vm.mCategory = it
|
||||
},
|
||||
searchMode = vm.mSearchMode,
|
||||
onSearchModeChanged = { vm.mSearchMode = it },
|
||||
isAdvanced = vm.isAdvancedMode,
|
||||
onAdvancedChanged = { vm.isAdvancedMode = it },
|
||||
showInfo = { BaseDialogBuilder(context).setMessage(R.string.search_tip).show() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = vm.isNormalMode && vm.isAdvancedMode) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().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))) {
|
||||
Text(text = stringResource(id = R.string.search_advance), modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
|
||||
SearchAdvanced(
|
||||
state = vm.advancedState,
|
||||
onStateChanged = { vm.advancedState = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(visible = !vm.isNormalMode) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth().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))) {
|
||||
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(
|
||||
imagePath = vm.path,
|
||||
onSelectImage = ::selectImage,
|
||||
uss = vm.uss,
|
||||
onUssChecked = { vm.uss = it },
|
||||
osc = vm.osc,
|
||||
onOscChecked = { vm.osc = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TabRow(
|
||||
selectedTabIndex = if (vm.isNormalMode) 0 else 1,
|
||||
divider = {},
|
||||
) {
|
||||
Tab(
|
||||
selected = vm.isNormalMode,
|
||||
onClick = { vm.isNormalMode = true },
|
||||
text = { Text(text = stringResource(id = R.string.keyword_search)) },
|
||||
)
|
||||
Tab(
|
||||
selected = !vm.isNormalMode,
|
||||
onClick = { vm.isNormalMode = false },
|
||||
text = { Text(text = stringResource(id = R.string.search_image)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchMyTags(isMyTags: Boolean) {
|
||||
if (isMyTags) vm.mSearchMode = 2
|
||||
}
|
||||
|
||||
fun setCategory(category: Int) {
|
||||
vm.mCategory = category
|
||||
}
|
||||
|
||||
fun formatListUrlBuilder(urlBuilder: ListUrlBuilder, query: String?) {
|
||||
urlBuilder.reset()
|
||||
when (vm.isNormalMode) {
|
||||
true -> {
|
||||
when (vm.mSearchMode) {
|
||||
1 -> urlBuilder.mode = ListUrlBuilder.MODE_NORMAL
|
||||
2 -> urlBuilder.mode = ListUrlBuilder.MODE_SUBSCRIPTION
|
||||
3 -> urlBuilder.mode = ListUrlBuilder.MODE_UPLOADER
|
||||
4 -> urlBuilder.mode = ListUrlBuilder.MODE_TAG
|
||||
}
|
||||
urlBuilder.keyword = query
|
||||
urlBuilder.category = vm.mCategory
|
||||
if (vm.isAdvancedMode) {
|
||||
urlBuilder.advanceSearch = vm.advancedState.advanceSearch
|
||||
urlBuilder.minRating = vm.advancedState.minRating
|
||||
val pageFrom = vm.advancedState.fromPage
|
||||
val pageTo = vm.advancedState.toPage
|
||||
if (pageTo != -1 && pageTo < 10) {
|
||||
throw EhException(context.getString(R.string.search_sp_err1))
|
||||
} else if (pageFrom != -1 && pageTo != -1 && pageTo - pageFrom < 20) {
|
||||
throw EhException(context.getString(R.string.search_sp_err2))
|
||||
}
|
||||
urlBuilder.pageFrom = pageFrom
|
||||
urlBuilder.pageTo = pageTo
|
||||
}
|
||||
}
|
||||
|
||||
false -> {
|
||||
urlBuilder.mode = ListUrlBuilder.MODE_IMAGE_SEARCH
|
||||
if (vm.path.isBlank()) throw EhException(context.getString(R.string.select_image_first))
|
||||
val uri = Uri.parse(vm.path)
|
||||
val src = UniFile.fromUri(context, uri)?.imageSource ?: return
|
||||
val temp = AppConfig.createTempFile() ?: return
|
||||
val bitmap = ImageDecoder.decodeBitmap(src, Image.imageSearchDecoderSampleListener)
|
||||
temp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) }
|
||||
urlBuilder.imagePath = temp.path
|
||||
urlBuilder.isUseSimilarityScan = vm.uss
|
||||
urlBuilder.isOnlySearchCovers = vm.osc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import com.google.accompanist.themeadapter.material3.Mdc3Theme
|
||||
import com.hippo.ehviewer.ui.tools.GalleryListCardRating
|
||||
|
||||
class SimpleRatingView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyle: Int = 0,
|
||||
) : AbstractComposeView(context, attrs, defStyle) {
|
||||
var rating by mutableFloatStateOf(0f)
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
Mdc3Theme {
|
||||
GalleryListCardRating(rating)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 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.ui.legacy
|
||||
|
||||
import android.content.Context
|
||||
import androidx.recyclerview.widget.LinearSmoothScroller
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
|
||||
abstract class SimpleSmoothScroller(context: Context, millisecondsPerInch: Float) :
|
||||
LinearSmoothScroller(context) {
|
||||
private val mMillisecondsPerPx: Float
|
||||
|
||||
init {
|
||||
mMillisecondsPerPx = millisecondsPerInch / context.resources.displayMetrics.densityDpi
|
||||
}
|
||||
|
||||
override fun calculateTimeForScrolling(dx: Int): Int {
|
||||
return if (mMillisecondsPerPx <= 0) {
|
||||
super.calculateTimeForScrolling(dx)
|
||||
} else {
|
||||
ceil((abs(dx) * mMillisecondsPerPx).toDouble()).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 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.ui.legacy
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.view.View
|
||||
import android.view.ViewPropertyAnimator
|
||||
|
||||
open class ViewTransition(vararg views: View) {
|
||||
private val mViews: Array<View>
|
||||
protected var mAnimator1: ViewPropertyAnimator? = null
|
||||
protected var mAnimator2: ViewPropertyAnimator? = null
|
||||
var shownViewIndex = -1
|
||||
private set
|
||||
private var mOnShowViewListener: OnShowViewListener? = null
|
||||
|
||||
init {
|
||||
check(views.size >= 2) { "You must pass view to ViewTransition" }
|
||||
mViews = arrayOf(*views)
|
||||
showView(0, false)
|
||||
}
|
||||
|
||||
fun setOnShowViewListener(listener: OnShowViewListener?) {
|
||||
mOnShowViewListener = listener
|
||||
}
|
||||
|
||||
fun showView(shownView: Int, animation: Boolean = true): Boolean {
|
||||
val views = mViews
|
||||
val length = views.size
|
||||
if (shownView >= length || shownView < 0) {
|
||||
throw IndexOutOfBoundsException(
|
||||
"Only " + length + " view(s) in " +
|
||||
"the ViewTransition, but attempt to show " + shownView,
|
||||
)
|
||||
}
|
||||
return if (shownViewIndex != shownView) {
|
||||
val oldShownView = shownViewIndex
|
||||
shownViewIndex = shownView
|
||||
|
||||
// Cancel animation
|
||||
if (mAnimator1 != null) {
|
||||
mAnimator1!!.cancel()
|
||||
}
|
||||
if (mAnimator2 != null) {
|
||||
mAnimator2!!.cancel()
|
||||
}
|
||||
if (animation) {
|
||||
startAnimations(views[oldShownView], views[shownView])
|
||||
} else {
|
||||
for (i in 0 until length) {
|
||||
val v = views[i]
|
||||
if (i == shownView) {
|
||||
v.alpha = 1f
|
||||
v.visibility = View.VISIBLE
|
||||
} else {
|
||||
v.alpha = 0f
|
||||
v.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
if (null != mOnShowViewListener) {
|
||||
mOnShowViewListener!!.onShowView(views[oldShownView], views[shownView])
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun startAnimations(hiddenView: View, shownView: View) {
|
||||
mAnimator1 = hiddenView.animate().alpha(0f)
|
||||
mAnimator1!!.setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
hiddenView.visibility = View.GONE
|
||||
mAnimator1 = null
|
||||
}
|
||||
}).start()
|
||||
shownView.alpha = 0f
|
||||
shownView.visibility = View.VISIBLE
|
||||
mAnimator2 = shownView.animate().alpha(1f)
|
||||
mAnimator2!!.setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
mAnimator2 = null
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
|
||||
interface OnShowViewListener {
|
||||
fun onShowView(hiddenView: View, shownView: View)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ANIMATE_TIME = 300L
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 Tarsin Norbin
|
||||
*
|
||||
* This file is part of EhViewer
|
||||
*
|
||||
* EhViewer is free software: you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* EhViewer is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with EhViewer.
|
||||
* If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.hippo.ehviewer.ui.legacy
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.core.view.WindowInsetsAnimationCompat.BoundsCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.google.android.material.math.MathUtils
|
||||
|
||||
class WindowInsetsAnimationHelper(dispatchMode: Int, vararg views: View) :
|
||||
WindowInsetsAnimationCompat.Callback(dispatchMode) {
|
||||
private val views: Array<View>
|
||||
private val startPaddings = HashMap<View, Int>()
|
||||
private val endPaddings = HashMap<View, Int>()
|
||||
var animation: WindowInsetsAnimationCompat? = null
|
||||
|
||||
init {
|
||||
this.views = arrayOf(*views)
|
||||
}
|
||||
|
||||
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
|
||||
super.onPrepare(animation)
|
||||
this.animation = animation
|
||||
for (view in views) {
|
||||
startPaddings[view] = view.paddingBottom
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart(
|
||||
animation: WindowInsetsAnimationCompat,
|
||||
bounds: BoundsCompat,
|
||||
): BoundsCompat {
|
||||
this.animation = animation
|
||||
for (view in views) {
|
||||
endPaddings[view] = view.paddingBottom
|
||||
val startPadding = if (startPaddings.containsKey(view)) startPaddings[view]!! else 0
|
||||
view.translationY = -(startPadding - view.paddingBottom).toFloat()
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
override fun onProgress(
|
||||
insets: WindowInsetsCompat,
|
||||
runningAnimations: List<WindowInsetsAnimationCompat>,
|
||||
): WindowInsetsCompat {
|
||||
if (animation == null) {
|
||||
return insets
|
||||
}
|
||||
var imeAnimation: WindowInsetsAnimationCompat? = null
|
||||
for (animation in runningAnimations) {
|
||||
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) {
|
||||
imeAnimation = animation
|
||||
break
|
||||
}
|
||||
}
|
||||
if (imeAnimation != null) {
|
||||
for (view in views) {
|
||||
val startPadding = if (startPaddings.containsKey(view)) startPaddings[view]!! else 0
|
||||
val endPadding = if (endPaddings.containsKey(view)) endPaddings[view]!! else 0
|
||||
view.translationY = MathUtils.lerp(
|
||||
(endPadding - startPadding).toFloat(),
|
||||
0f,
|
||||
animation!!.interpolatedFraction,
|
||||
)
|
||||
}
|
||||
}
|
||||
return insets
|
||||
}
|
||||
|
||||
override fun onEnd(animation: WindowInsetsAnimationCompat) {
|
||||
super.onEnd(animation)
|
||||
startPaddings.clear()
|
||||
endPaddings.clear()
|
||||
this.animation = null
|
||||
for (view in views) {
|
||||
view.translationY = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
/*
|
||||
* 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.ui.scene
|
||||
|
||||
import android.content.res.Resources.Theme
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.util.SparseArray
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.SoftwareKeyboardControllerCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.SavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.google.android.material.sidesheet.SideSheetDialog
|
||||
import com.hippo.ehviewer.ui.MainActivity
|
||||
import com.hippo.ehviewer.util.getSparseParcelableArrayCompat
|
||||
import rikka.core.res.resolveDrawable
|
||||
|
||||
abstract class BaseScene : Fragment() {
|
||||
private var drawerView: View? = null
|
||||
private var drawerViewState: SparseArray<Parcelable>? = null
|
||||
private var sideSheetDialog: SideSheetDialog? = null
|
||||
fun addAboveSnackView(view: View) {
|
||||
val activity = activity
|
||||
if (activity is MainActivity) {
|
||||
activity.addAboveSnackView(view)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAboveSnackView(view: View) {
|
||||
val activity = activity
|
||||
if (activity is MainActivity) {
|
||||
activity.removeAboveSnackView(view)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDrawerLockMode(lockMode: Int, edgeGravity: Int) {
|
||||
val activity = activity
|
||||
if (activity is MainActivity) {
|
||||
activity.setDrawerLockMode(lockMode, edgeGravity)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDrawerLockMode(edgeGravity: Int): Int? {
|
||||
val activity = activity
|
||||
return if (activity is MainActivity) {
|
||||
activity.getDrawerLockMode(edgeGravity)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isDrawerOpen(drawerGravity: Int): Boolean {
|
||||
val activity = activity
|
||||
return activity is MainActivity && activity.isDrawerOpen(drawerGravity)
|
||||
}
|
||||
|
||||
fun openDrawer(drawerGravity: Int) {
|
||||
val activity = activity
|
||||
if (activity is MainActivity) {
|
||||
activity.openDrawer(drawerGravity)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeDrawer(drawerGravity: Int) {
|
||||
val activity = activity
|
||||
if (activity is MainActivity) {
|
||||
activity.closeDrawer(drawerGravity)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDrawer(drawerGravity: Int) {
|
||||
val activity = activity
|
||||
if (activity is MainActivity) {
|
||||
activity.toggleDrawer(drawerGravity)
|
||||
}
|
||||
}
|
||||
|
||||
fun openSideSheet() = sideSheetDialog!!.show()
|
||||
fun closeSideSheet() = sideSheetDialog!!.hide()
|
||||
|
||||
fun showTip(message: CharSequence?, length: Int) {
|
||||
val activity = activity
|
||||
if (activity is MainActivity) {
|
||||
activity.showTip(message!!, length)
|
||||
}
|
||||
}
|
||||
|
||||
fun showTip(@StringRes id: Int, length: Int) {
|
||||
val activity = activity
|
||||
if (activity is MainActivity) {
|
||||
activity.showTip(id, length)
|
||||
}
|
||||
}
|
||||
|
||||
open val showLeftDrawer = true
|
||||
|
||||
private fun createDrawerView(savedInstanceState: Bundle?): View? {
|
||||
drawerView = onCreateDrawerView(layoutInflater)
|
||||
if (drawerView != null) {
|
||||
val saved = drawerViewState ?: savedInstanceState?.getSparseParcelableArrayCompat(KEY_DRAWER_VIEW_STATE)
|
||||
saved?.let {
|
||||
drawerView!!.restoreHierarchyState(saved)
|
||||
}
|
||||
}
|
||||
return drawerView
|
||||
}
|
||||
|
||||
open fun onCreateDrawerView(inflater: LayoutInflater): View? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun destroyDrawerView() {
|
||||
if (drawerView != null) {
|
||||
drawerViewState = SparseArray()
|
||||
drawerView!!.saveHierarchyState(drawerViewState)
|
||||
}
|
||||
onDestroyDrawerView()
|
||||
drawerView = null
|
||||
}
|
||||
|
||||
open fun onDestroyDrawerView() {
|
||||
sideSheetDialog?.dismiss()
|
||||
sideSheetDialog = null
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.background = requireActivity().theme.resolveDrawable(android.R.attr.windowBackground)
|
||||
|
||||
// Update left drawer locked state
|
||||
if (showLeftDrawer) {
|
||||
setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START)
|
||||
} else {
|
||||
setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START)
|
||||
}
|
||||
hideSoftInput()
|
||||
createDrawerView(savedInstanceState)?.let {
|
||||
sideSheetDialog = object : SideSheetDialog(requireContext()) {
|
||||
// Dirty workaround
|
||||
override fun cancel() = hide()
|
||||
}.apply {
|
||||
window?.decorView?.apply {
|
||||
val owner = viewLifecycleOwner
|
||||
setViewTreeLifecycleOwner(owner)
|
||||
setViewTreeViewModelStoreOwner(owner as ViewModelStoreOwner)
|
||||
setViewTreeSavedStateRegistryOwner(owner as SavedStateRegistryOwner)
|
||||
}
|
||||
setContentView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
destroyDrawerView()
|
||||
}
|
||||
|
||||
val mainActivity: MainActivity?
|
||||
get() {
|
||||
val activity = activity
|
||||
return if (activity is MainActivity) {
|
||||
activity
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun hideSoftInput() = activity?.window?.decorView?.run { SoftwareKeyboardControllerCompat(this) }?.hide()
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
if (drawerView != null) {
|
||||
drawerViewState = SparseArray()
|
||||
drawerView!!.saveHierarchyState(drawerViewState)
|
||||
outState.putSparseParcelableArray(KEY_DRAWER_VIEW_STATE, drawerViewState)
|
||||
}
|
||||
}
|
||||
|
||||
val theme: Theme
|
||||
get() = requireActivity().theme
|
||||
|
||||
companion object {
|
||||
const val LENGTH_SHORT = 0
|
||||
const val LENGTH_LONG = 1
|
||||
const val KEY_DRAWER_VIEW_STATE = "com.hippo.ehviewer.ui.scene.BaseScene:DRAWER_VIEW_STATE"
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
* 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.ui.scene
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.databinding.SceneToolbarBinding
|
||||
|
||||
abstract class BaseToolbarScene : BaseScene(), ToolBarScene {
|
||||
private var _binding: SceneToolbarBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = SceneToolbarBinding.inflate(inflater, container, false)
|
||||
onCreateViewWithToolbar(inflater, binding.root, savedInstanceState)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.toolbar.apply {
|
||||
val menuResId = getMenuResId()
|
||||
if (menuResId != 0) {
|
||||
inflateMenu(menuResId)
|
||||
setOnMenuItemClickListener { item: MenuItem -> onMenuItemClick(item) }
|
||||
}
|
||||
setNavigationOnClickListener { onNavigationClick() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMenuResId(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
return if (item.itemId == R.id.action_open_side_sheet) {
|
||||
openSideSheet()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNavigationClick() {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
fun setNavigationIcon(@DrawableRes resId: Int) {
|
||||
binding.toolbar.setNavigationIcon(resId)
|
||||
}
|
||||
|
||||
fun setTitle(@StringRes resId: Int) {
|
||||
setTitle(getString(resId))
|
||||
}
|
||||
|
||||
fun setTitle(title: CharSequence?) {
|
||||
_binding ?: return
|
||||
binding.toolbar.title = title
|
||||
}
|
||||
|
||||
override fun setLiftOnScrollTargetView(view: View?) {
|
||||
binding.appbar.setLiftOnScrollTargetView(view)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,582 +0,0 @@
|
||||
/*
|
||||
* 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.ui.scene
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.currentRecomposeScope
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.datepicker.CalendarConstraints
|
||||
import com.google.android.material.datepicker.CalendarConstraints.DateValidator
|
||||
import com.google.android.material.datepicker.CompositeDateValidator
|
||||
import com.google.android.material.datepicker.DateValidatorPointBackward
|
||||
import com.google.android.material.datepicker.DateValidatorPointForward
|
||||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import com.hippo.ehviewer.EhDB
|
||||
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.EhUrl
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.client.ehUrl
|
||||
import com.hippo.ehviewer.databinding.SceneFavoritesBinding
|
||||
import com.hippo.ehviewer.ui.CommonOperations
|
||||
import com.hippo.ehviewer.ui.legacy.AddDeleteDrawable
|
||||
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
|
||||
import com.hippo.ehviewer.ui.legacy.FabLayout
|
||||
import com.hippo.ehviewer.ui.legacy.FabLayout.OnClickFabListener
|
||||
import com.hippo.ehviewer.ui.legacy.FastScroller.OnDragHandlerListener
|
||||
import com.hippo.ehviewer.ui.legacy.HandlerDrawable
|
||||
import com.hippo.ehviewer.ui.legacy.ViewTransition
|
||||
import com.hippo.ehviewer.ui.legacy.WindowInsetsAnimationHelper
|
||||
import com.hippo.ehviewer.ui.setMD3Content
|
||||
import com.hippo.ehviewer.ui.tools.DialogState
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import com.hippo.ehviewer.util.SimpleHandler
|
||||
import com.hippo.ehviewer.util.getValue
|
||||
import com.hippo.ehviewer.util.lazyMut
|
||||
import com.hippo.ehviewer.util.setValue
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import rikka.core.res.resolveColor
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
// Note that we do not really follow mvvm structure, just use it as ... storage
|
||||
class VMStorage : ViewModel() {
|
||||
var urlBuilder = FavListUrlBuilder(favCat = Settings.recentFavCat)
|
||||
private val cloudDataFlow = Pager(PagingConfig(25)) {
|
||||
object : PagingSource<String, BaseGalleryInfo>() {
|
||||
override fun getRefreshKey(state: PagingState<String, BaseGalleryInfo>): String? = null
|
||||
override suspend fun load(params: LoadParams<String>) = withIOContext {
|
||||
when (params) {
|
||||
is LoadParams.Prepend -> urlBuilder.setIndex(params.key, isNext = false)
|
||||
is LoadParams.Append -> urlBuilder.setIndex(params.key, isNext = true)
|
||||
is LoadParams.Refresh -> {
|
||||
val key = params.key
|
||||
if (key.isNullOrBlank()) {
|
||||
if (urlBuilder.jumpTo != null) {
|
||||
urlBuilder.mNext ?: urlBuilder.setIndex("2", true)
|
||||
}
|
||||
} else {
|
||||
urlBuilder.setIndex(key, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
val r = runSuspendCatching {
|
||||
EhEngine.getFavorites(urlBuilder.build())
|
||||
}.onFailure {
|
||||
return@withIOContext LoadResult.Error(it)
|
||||
}.getOrThrow()
|
||||
Settings.favCat = r.catArray
|
||||
Settings.favCount = r.countArray
|
||||
Settings.favCloudCount = r.countArray.sum()
|
||||
urlBuilder.jumpTo = null
|
||||
LoadResult.Page(r.galleryInfoList, r.prev, r.next)
|
||||
}
|
||||
}
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
|
||||
private val localFavDataFlow = Pager(PagingConfig(20, enablePlaceholders = false, jumpThreshold = 40)) {
|
||||
val keyword = urlBuilder.keyword
|
||||
if (keyword.isNullOrBlank()) {
|
||||
EhDB.localFavLazyList
|
||||
} else {
|
||||
EhDB.searchLocalFav(keyword)
|
||||
}
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
fun dataflow() = if (urlBuilder.favCat == -2) localFavDataFlow else cloudDataFlow
|
||||
val localFavCount = EhDB.localFavCount
|
||||
}
|
||||
|
||||
class FavoritesScene : SearchBarScene() {
|
||||
private val vm: VMStorage by viewModels()
|
||||
private var urlBuilder by lazyMut { vm::urlBuilder }
|
||||
private var _binding: SceneFavoritesBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var mAdapter: GalleryAdapter? = null
|
||||
private val tracker get() = mAdapter!!.tracker!!
|
||||
private val showNormalFabsRunnable = Runnable {
|
||||
updateJumpFab() // index: 0, 2
|
||||
binding.fabLayout.run {
|
||||
setSecondaryFabVisibilityAt(1, true)
|
||||
(3..6).forEach { setSecondaryFabVisibilityAt(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
private val dialogState = DialogState()
|
||||
|
||||
override val fabLayout get() = binding.fabLayout
|
||||
|
||||
private fun onItemClick(position: Int) {
|
||||
// Skip if in search mode
|
||||
if (!tracker.isInCustomChoice) {
|
||||
switchFav(position - 2)
|
||||
updateJumpFab()
|
||||
closeSideSheet()
|
||||
}
|
||||
}
|
||||
|
||||
private var collectJob: Job? = null
|
||||
|
||||
private fun switchFav(newCat: Int, keyword: String? = null) {
|
||||
_binding ?: return
|
||||
urlBuilder.keyword = keyword
|
||||
urlBuilder.favCat = newCat
|
||||
urlBuilder.jumpTo = null
|
||||
urlBuilder.setIndex(null, true)
|
||||
collectJob?.cancel()
|
||||
collectJob = viewLifecycleOwner.lifecycleScope.launchIO {
|
||||
vm.dataflow().collectLatest {
|
||||
mAdapter?.submitData(it)
|
||||
}
|
||||
}
|
||||
mAdapter?.refresh()
|
||||
val favCatName: String = when (val favCat = urlBuilder.favCat) {
|
||||
in 0..9 -> Settings.favCat[favCat]
|
||||
FavListUrlBuilder.FAV_CAT_LOCAL -> getString(R.string.local_favorites)
|
||||
else -> getString(R.string.cloud_favorites)
|
||||
}
|
||||
if (keyword.isNullOrEmpty()) {
|
||||
setSearchBarHint(getString(R.string.favorites_title, favCatName))
|
||||
setSearchBarText(null)
|
||||
} else {
|
||||
setSearchBarHint(getString(R.string.favorites_title_2, favCatName, keyword))
|
||||
}
|
||||
setEditTextHint(getString(R.string.search_bar_hint, favCatName))
|
||||
Settings.recentFavCat = urlBuilder.favCat
|
||||
}
|
||||
|
||||
override fun getMenuResId() = R.menu.scene_favorites
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
mAdapter?.let { tracker.saveSelection(outState) }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mAdapter?.type = Settings.listMode
|
||||
}
|
||||
|
||||
private val SceneFavoritesBinding.fastScroller get() = contentLayout.fastScroller
|
||||
private val SceneFavoritesBinding.refreshLayout get() = contentLayout.refreshLayout
|
||||
private val SceneFavoritesBinding.recyclerView get() = contentLayout.recyclerView
|
||||
private val SceneFavoritesBinding.progress get() = contentLayout.progress
|
||||
private val SceneFavoritesBinding.tip get() = contentLayout.tip
|
||||
|
||||
override fun onCreateViewWithToolbar(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = SceneFavoritesBinding.inflate(inflater, container!!)
|
||||
container.addView(ComposeView(inflater.context).apply { setMD3Content { dialogState.Intercept() } })
|
||||
setOnApplySearch {
|
||||
if (!tracker.isInCustomChoice) {
|
||||
switchFav(urlBuilder.favCat, it)
|
||||
}
|
||||
}
|
||||
binding.fastScroller.attachToRecyclerView(binding.recyclerView)
|
||||
binding.fastScroller.setHandlerDrawable(HandlerDrawable().apply { setColor(inflater.context.theme.resolveColor(androidx.appcompat.R.attr.colorPrimary)) })
|
||||
binding.fabLayout.run {
|
||||
ViewCompat.setWindowInsetsAnimationCallback(binding.root, WindowInsetsAnimationHelper(WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP, this))
|
||||
updateJumpFab()
|
||||
val colorID = theme.resolveColor(com.google.android.material.R.attr.colorOnSurface)
|
||||
val addDelete = AddDeleteDrawable(context, colorID)
|
||||
primaryFab!!.setImageDrawable(addDelete)
|
||||
setExpanded(expanded = false, animation = false)
|
||||
setAutoCancel(true)
|
||||
setHidePrimaryFab(false)
|
||||
setOnClickFabListener(object : OnClickFabListener {
|
||||
override fun onClickPrimaryFab(view: FabLayout, fab: FloatingActionButton) {
|
||||
if (tracker.isInCustomChoice) {
|
||||
tracker.clearSelection()
|
||||
} else {
|
||||
binding.fabLayout.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClickSecondaryFab(view: FabLayout, fab: FloatingActionButton, position: Int) {
|
||||
if (!tracker.isInCustomChoice) {
|
||||
when (position) {
|
||||
0 -> showGoToDialog()
|
||||
1 -> switchFav(urlBuilder.favCat)
|
||||
2 -> {
|
||||
urlBuilder.setIndex("1-0", false)
|
||||
mAdapter?.refresh()
|
||||
}
|
||||
}
|
||||
view.isExpanded = false
|
||||
return
|
||||
}
|
||||
when (position) {
|
||||
// Check all
|
||||
3 -> tracker.selectAll()
|
||||
// Download
|
||||
4 -> CommonOperations.startDownload(mainActivity!!, takeCheckedInfo(), false)
|
||||
// Delete
|
||||
5 -> {
|
||||
val helper = DeleteDialogHelper()
|
||||
BaseDialogBuilder(context)
|
||||
.setTitle(R.string.delete_favorites_dialog_title)
|
||||
.setMessage(getString(R.string.delete_favorites_dialog_message, checkedSize()))
|
||||
.setPositiveButton(android.R.string.ok, helper)
|
||||
.show()
|
||||
}
|
||||
// Move
|
||||
6 -> {
|
||||
val helper = MoveDialogHelper()
|
||||
// First is local favorite, the other 10 is cloud favorite
|
||||
val localFav = getString(R.string.local_favorites)
|
||||
val array = if (EhCookieStore.hasSignedIn()) {
|
||||
arrayOf(localFav, *Settings.favCat)
|
||||
} else {
|
||||
arrayOf(localFav)
|
||||
}
|
||||
BaseDialogBuilder(context).setTitle(R.string.move_favorites_dialog_title).setItems(array, helper).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
addOnExpandListener {
|
||||
if (it) {
|
||||
addDelete.setDelete(ANIMATE_TIME)
|
||||
} else {
|
||||
addDelete.setAdd(ANIMATE_TIME)
|
||||
if (tracker.isInCustomChoice) tracker.clearSelection()
|
||||
}
|
||||
}
|
||||
addAboveSnackView(this)
|
||||
}
|
||||
binding.fastScroller.setOnDragHandlerListener(object : OnDragHandlerListener {
|
||||
override fun onStartDragHandler() {}
|
||||
override fun onEndDragHandler() {
|
||||
showSearchBar()
|
||||
}
|
||||
})
|
||||
binding.recyclerView.run {
|
||||
mAdapter = GalleryAdapter(
|
||||
this@run,
|
||||
false,
|
||||
{ info ->
|
||||
navAnimated(
|
||||
R.id.galleryDetailScene,
|
||||
bundleOf(
|
||||
GalleryDetailScene.KEY_ACTION to GalleryDetailScene.ACTION_GALLERY_INFO,
|
||||
GalleryDetailScene.KEY_GALLERY_INFO to info,
|
||||
),
|
||||
)
|
||||
},
|
||||
{},
|
||||
).also { adapter ->
|
||||
val drawable = ContextCompat.getDrawable(context, R.drawable.big_sad_pandroid)!!
|
||||
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
binding.tip.setCompoundDrawables(null, drawable, null, null)
|
||||
binding.tip.setOnClickListener { mAdapter?.refresh() }
|
||||
binding.refreshLayout.setOnRefreshListener { switchFav(urlBuilder.favCat) }
|
||||
val transition = ViewTransition(binding.refreshLayout, binding.progress, binding.tip)
|
||||
val empty = getString(R.string.gallery_list_empty_hit)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
adapter.loadStateFlow.collectLatest {
|
||||
when (val state = it.refresh) {
|
||||
is LoadState.Loading -> {
|
||||
showSearchBar()
|
||||
if (!binding.refreshLayout.isRefreshing) {
|
||||
transition.showView(1)
|
||||
}
|
||||
}
|
||||
is LoadState.Error -> {
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
binding.tip.text = ExceptionUtils.getReadableString(state.error)
|
||||
transition.showView(2)
|
||||
}
|
||||
is LoadState.NotLoading -> {
|
||||
delay(500)
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
if (mAdapter?.itemCount == 0) {
|
||||
binding.tip.text = empty
|
||||
transition.showView(2)
|
||||
} else {
|
||||
transition.showView(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
adapter.tracker = GallerySelectionTracker(
|
||||
"favorite-selection",
|
||||
this,
|
||||
{ adapter.snapshot().items },
|
||||
{ (this as GalleryHolder).galleryId },
|
||||
).apply {
|
||||
addCustomChoiceListener({
|
||||
showSelectionFab()
|
||||
binding.fabLayout.setAutoCancel(false)
|
||||
// Delay expanding action to make layout work fine
|
||||
SimpleHandler.post { binding.fabLayout.isExpanded = true }
|
||||
binding.refreshLayout.isEnabled = false
|
||||
// Lock drawer
|
||||
setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, GravityCompat.START)
|
||||
}) {
|
||||
showNormalFab()
|
||||
binding.fabLayout.setAutoCancel(true)
|
||||
binding.fabLayout.isExpanded = false
|
||||
binding.refreshLayout.isEnabled = true
|
||||
// Unlock drawer
|
||||
setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, GravityCompat.START)
|
||||
}
|
||||
restoreSelection(savedInstanceState)
|
||||
}
|
||||
}
|
||||
switchFav(Settings.recentFavCat)
|
||||
}
|
||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
|
||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
return if (tracker.isInCustomChoice) {
|
||||
0
|
||||
} else {
|
||||
makeMovementFlags(0, ItemTouchHelper.LEFT)
|
||||
}
|
||||
}
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder,
|
||||
) = false
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.bindingAdapterPosition
|
||||
val info = mAdapter!!.peek(position)!!
|
||||
lifecycleScope.launchIO {
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
confirmText = R.string.delete,
|
||||
dismissText = android.R.string.cancel,
|
||||
title = R.string.delete_favorites_dialog_title,
|
||||
onDismiss = { mAdapter!!.notifyItemChanged(position) },
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.delete_favorites_dialog_message, 1))
|
||||
}
|
||||
if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
|
||||
EhDB.removeLocalFavorites(info)
|
||||
} else {
|
||||
EhEngine.modifyFavorites(info.gid, info.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
itemTouchHelper.attachToRecyclerView(binding.recyclerView)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
// Hide jump fab on local fav cat
|
||||
private fun updateJumpFab() {
|
||||
binding.fabLayout.setSecondaryFabVisibilityAt(
|
||||
0,
|
||||
urlBuilder.favCat != FavListUrlBuilder.FAV_CAT_LOCAL,
|
||||
)
|
||||
binding.fabLayout.setSecondaryFabVisibilityAt(
|
||||
2,
|
||||
urlBuilder.favCat != FavListUrlBuilder.FAV_CAT_LOCAL,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
binding.recyclerView.stopScroll()
|
||||
removeAboveSnackView(binding.fabLayout)
|
||||
mAdapter = null
|
||||
_binding = null
|
||||
}
|
||||
|
||||
override fun onCreateDrawerView(inflater: LayoutInflater) = ComposeView(inflater.context).apply {
|
||||
setMD3Content {
|
||||
val localFavCount by vm.localFavCount.collectAsState(0)
|
||||
ElevatedCard {
|
||||
TopAppBar(title = { Text(text = stringResource(id = R.string.collections)) })
|
||||
val scope = currentRecomposeScope
|
||||
LaunchedEffect(Unit) {
|
||||
Settings.favChangesFlow.collect {
|
||||
scope.invalidate()
|
||||
}
|
||||
}
|
||||
val localFav = stringResource(id = R.string.local_favorites) to localFavCount
|
||||
val faves = if (EhCookieStore.hasSignedIn()) {
|
||||
arrayOf(
|
||||
localFav,
|
||||
stringResource(id = R.string.cloud_favorites) to Settings.favCloudCount,
|
||||
*Settings.favCat.zip(Settings.favCount.toTypedArray()).toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
arrayOf(localFav)
|
||||
}
|
||||
LazyColumn {
|
||||
itemsIndexed(faves) { index, (name, count) ->
|
||||
ListItem(
|
||||
headlineContent = { Text(text = name) },
|
||||
trailingContent = { Text(text = count.toString(), style = MaterialTheme.typography.bodyLarge) },
|
||||
modifier = Modifier.clickable { onItemClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSearchViewExpanded() {
|
||||
hideActionFab(true)
|
||||
super.onSearchViewExpanded()
|
||||
}
|
||||
|
||||
override fun onSearchViewHidden() {
|
||||
super.onSearchViewHidden()
|
||||
showActionFab(true)
|
||||
}
|
||||
|
||||
private fun showGoToDialog() {
|
||||
context ?: return
|
||||
val local = LocalDateTime.of(2007, 3, 21, 0, 0)
|
||||
val fromDate = local.atZone(ZoneId.ofOffset("UTC", ZoneOffset.UTC)).toInstant().toEpochMilli()
|
||||
val toDate = MaterialDatePicker.todayInUtcMilliseconds()
|
||||
val listValidators = ArrayList<DateValidator>()
|
||||
listValidators.add(DateValidatorPointForward.from(fromDate))
|
||||
listValidators.add(DateValidatorPointBackward.before(toDate))
|
||||
val constraintsBuilder = CalendarConstraints.Builder()
|
||||
.setStart(fromDate)
|
||||
.setEnd(toDate)
|
||||
.setValidator(CompositeDateValidator.allOf(listValidators))
|
||||
val datePicker = MaterialDatePicker.Builder.datePicker()
|
||||
.setCalendarConstraints(constraintsBuilder.build())
|
||||
.setTitleText(R.string.go_to)
|
||||
.setSelection(toDate)
|
||||
.build()
|
||||
datePicker.show(requireActivity().supportFragmentManager, "date-picker")
|
||||
datePicker.addOnPositiveButtonClickListener { time: Long ->
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US).withZone(ZoneOffset.UTC)
|
||||
val jumpTo = formatter.format(Instant.ofEpochMilli(time))
|
||||
urlBuilder.jumpTo = jumpTo
|
||||
mAdapter?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun takeCheckedInfo() = tracker.getAndClearSelection()
|
||||
|
||||
private fun checkedSize() = tracker.selectionSize
|
||||
|
||||
private fun showNormalFab() {
|
||||
// Delay showing normal fab to avoid mutation
|
||||
SimpleHandler.removeCallbacks(showNormalFabsRunnable)
|
||||
SimpleHandler.postDelayed(showNormalFabsRunnable, 300)
|
||||
}
|
||||
|
||||
private fun showSelectionFab() {
|
||||
SimpleHandler.removeCallbacks(showNormalFabsRunnable)
|
||||
binding.fabLayout.run {
|
||||
(0..2).forEach { setSecondaryFabVisibilityAt(it, false) }
|
||||
(3..6).forEach { setSecondaryFabVisibilityAt(it, true) }
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DeleteDialogHelper : DialogInterface.OnClickListener {
|
||||
override fun onClick(dialog: DialogInterface, which: Int) {
|
||||
val info = takeCheckedInfo()
|
||||
lifecycleScope.launchIO {
|
||||
if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav
|
||||
EhDB.removeLocalFavorites(info)
|
||||
} else {
|
||||
val delList = info.map { it.gid to it.token!! }
|
||||
EhEngine.modifyFavoritesRange(delList, -1)
|
||||
}
|
||||
mAdapter?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class MoveDialogHelper : DialogInterface.OnClickListener {
|
||||
override fun onClick(dialog: DialogInterface, which: Int) {
|
||||
val srcCat = urlBuilder.favCat
|
||||
val dstCat = if (which == 0) { FavListUrlBuilder.FAV_CAT_LOCAL } else { which - 1 }
|
||||
if (srcCat == dstCat) return
|
||||
val info = takeCheckedInfo()
|
||||
lifecycleScope.launchIO {
|
||||
if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
|
||||
// Move from local to cloud
|
||||
EhDB.removeLocalFavorites(info)
|
||||
val galleryList = info.map { it.gid to it.token!! }
|
||||
runCatching {
|
||||
EhEngine.modifyFavoritesRange(galleryList, dstCat)
|
||||
}
|
||||
} else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
|
||||
// Move from cloud to local
|
||||
EhDB.putLocalFavorites(info)
|
||||
} else {
|
||||
// Move from cloud to cloud
|
||||
val gidArray = info.map { it.gid }.toLongArray()
|
||||
val url = ehUrl { addPathSegments(EhUrl.FAV_PATH) }.toString()
|
||||
runCatching {
|
||||
EhEngine.modifyFavorites(url, gidArray, dstCat)
|
||||
}
|
||||
}
|
||||
mAdapter?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/*
|
||||
* 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.ui.scene
|
||||
|
||||
import android.graphics.Rect
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.IntDef
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.paging.PagingDataAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.recyclerview.widget.RecyclerView.ItemDecoration
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager
|
||||
import arrow.core.partially1
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.Settings.detailSize
|
||||
import com.hippo.ehviewer.Settings.thumbSizeDp
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.ui.legacy.AutoStaggeredGridLayoutManager
|
||||
import com.hippo.ehviewer.ui.legacy.MarginItemDecoration
|
||||
import com.hippo.ehviewer.ui.legacy.STRATEGY_MIN_SIZE
|
||||
import com.hippo.ehviewer.ui.legacy.STRATEGY_SUITABLE_SIZE
|
||||
import com.hippo.ehviewer.util.dp2px
|
||||
import splitties.init.appCtx
|
||||
|
||||
private val diffCallback = object : DiffUtil.ItemCallback<BaseGalleryInfo>() {
|
||||
override fun areItemsTheSame(oldItem: BaseGalleryInfo, newItem: BaseGalleryInfo) = oldItem.gid == newItem.gid
|
||||
override fun areContentsTheSame(oldItem: BaseGalleryInfo, newItem: BaseGalleryInfo) = oldItem.gid == newItem.gid
|
||||
}
|
||||
|
||||
class GalleryAdapter(
|
||||
private val recyclerView: RecyclerView,
|
||||
private val showFavorite: Boolean,
|
||||
private val onItemClick: (BaseGalleryInfo) -> Unit,
|
||||
private val onItemLongClick: (BaseGalleryInfo) -> Unit,
|
||||
) : PagingDataAdapter<BaseGalleryInfo, GalleryHolder>(diffCallback) {
|
||||
private val resources = recyclerView.context.resources
|
||||
private val layoutManager: AutoStaggeredGridLayoutManager = AutoStaggeredGridLayoutManager(0, StaggeredGridLayoutManager.VERTICAL)
|
||||
private var mListDecoration: ItemDecoration? = null
|
||||
private var mGirdDecoration: MarginItemDecoration? = null
|
||||
private var mType = TYPE_INVALID
|
||||
var tracker: GallerySelectionTracker<BaseGalleryInfo>? = null
|
||||
|
||||
var type: Int
|
||||
get() = mType
|
||||
set(type) {
|
||||
if (type == mType) return
|
||||
mType = type
|
||||
when (type) {
|
||||
TYPE_LIST -> {
|
||||
val columnWidth = resources.getDimensionPixelOffset(
|
||||
when (detailSize) {
|
||||
0 -> R.dimen.gallery_list_column_width_long
|
||||
1 -> R.dimen.gallery_list_column_width_short
|
||||
else -> error("Unexpected value: $detailSize")
|
||||
},
|
||||
)
|
||||
layoutManager.setColumnSize(columnWidth)
|
||||
layoutManager.setStrategy(STRATEGY_MIN_SIZE)
|
||||
if (null != mGirdDecoration) {
|
||||
recyclerView.removeItemDecoration(mGirdDecoration!!)
|
||||
val paddingH = resources.getDimensionPixelOffset(R.dimen.gallery_grid_margin_h)
|
||||
val paddingV = resources.getDimensionPixelOffset(R.dimen.gallery_grid_margin_v)
|
||||
recyclerView.setPadding(
|
||||
recyclerView.paddingLeft - paddingH,
|
||||
recyclerView.paddingTop - paddingV,
|
||||
recyclerView.paddingRight - paddingH,
|
||||
recyclerView.paddingBottom - paddingV,
|
||||
)
|
||||
mGirdDecoration = null
|
||||
}
|
||||
if (null == mListDecoration) {
|
||||
val interval = resources.getDimensionPixelOffset(R.dimen.gallery_list_interval)
|
||||
val paddingH = resources.getDimensionPixelOffset(R.dimen.gallery_list_margin_h)
|
||||
val paddingV = resources.getDimensionPixelOffset(R.dimen.gallery_list_margin_v)
|
||||
mListDecoration = object : ItemDecoration() {
|
||||
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||
outRect.set(0, interval / 2, 0, interval / 2)
|
||||
}
|
||||
}
|
||||
recyclerView.setPadding(
|
||||
recyclerView.paddingLeft + paddingH,
|
||||
recyclerView.paddingTop + paddingV,
|
||||
recyclerView.paddingRight + paddingH,
|
||||
recyclerView.paddingBottom + paddingV,
|
||||
)
|
||||
recyclerView.addItemDecoration(mListDecoration!!)
|
||||
}
|
||||
}
|
||||
|
||||
TYPE_GRID -> {
|
||||
val columnWidth = dp2px(appCtx, thumbSizeDp.toFloat())
|
||||
layoutManager.setColumnSize(columnWidth)
|
||||
layoutManager.setStrategy(STRATEGY_SUITABLE_SIZE)
|
||||
if (null != mListDecoration) {
|
||||
recyclerView.removeItemDecoration(mListDecoration!!)
|
||||
val paddingH = resources.getDimensionPixelOffset(R.dimen.gallery_list_margin_h)
|
||||
val paddingV = resources.getDimensionPixelOffset(R.dimen.gallery_list_margin_v)
|
||||
recyclerView.setPadding(
|
||||
recyclerView.paddingLeft - paddingH,
|
||||
recyclerView.paddingTop - paddingV,
|
||||
recyclerView.paddingRight - paddingH,
|
||||
recyclerView.paddingBottom - paddingV,
|
||||
)
|
||||
mListDecoration = null
|
||||
}
|
||||
if (null == mGirdDecoration) {
|
||||
val interval = resources.getDimensionPixelOffset(R.dimen.gallery_grid_interval)
|
||||
val paddingH = resources.getDimensionPixelOffset(R.dimen.gallery_grid_margin_h)
|
||||
val paddingV = resources.getDimensionPixelOffset(R.dimen.gallery_grid_margin_v)
|
||||
mGirdDecoration = MarginItemDecoration(interval, 0, 0, 0, 0)
|
||||
recyclerView.setPadding(
|
||||
recyclerView.paddingLeft + paddingH,
|
||||
recyclerView.paddingTop + paddingV,
|
||||
recyclerView.paddingRight + paddingH,
|
||||
recyclerView.paddingBottom + paddingV,
|
||||
)
|
||||
recyclerView.addItemDecoration(mGirdDecoration!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
init {
|
||||
recyclerView.adapter = this
|
||||
recyclerView.layoutManager = layoutManager
|
||||
type = Settings.listMode
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) {
|
||||
TYPE_LIST -> ListGalleryHolder(ComposeView(parent.context), showFavorite)
|
||||
TYPE_GRID -> GridGalleryHolder(ComposeView(parent.context))
|
||||
else -> error("Unexpected value: $viewType")
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = mType
|
||||
|
||||
override fun onBindViewHolder(holder: GalleryHolder, position: Int) {
|
||||
val gi = getItem(position) ?: return
|
||||
val selected = tracker?.isSelected(gi.gid) ?: false
|
||||
holder.galleryId = gi.gid
|
||||
holder.bind(gi, selected, onItemClick.partially1(gi), onItemLongClick.partially1(gi))
|
||||
}
|
||||
|
||||
@IntDef(TYPE_LIST, TYPE_GRID)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class Type
|
||||
companion object {
|
||||
const val TYPE_INVALID = -1
|
||||
const val TYPE_LIST = 0
|
||||
const val TYPE_GRID = 1
|
||||
}
|
||||
}
|
||||
@@ -1,885 +0,0 @@
|
||||
/*
|
||||
* 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.ui.scene
|
||||
|
||||
import android.animation.Animator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.os.Looper
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.style.CharacterStyle
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
import android.util.Log
|
||||
import android.view.ActionMode
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewAnimationUtils
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.text.parseAsHtml
|
||||
import androidx.core.text.set
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsAnimationCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.hippo.ehviewer.R
|
||||
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.GalleryCommentList
|
||||
import com.hippo.ehviewer.client.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.ListUrlBuilder
|
||||
import com.hippo.ehviewer.client.parser.VoteCommentResult
|
||||
import com.hippo.ehviewer.dao.Filter
|
||||
import com.hippo.ehviewer.dao.FilterMode
|
||||
import com.hippo.ehviewer.databinding.ItemDrawerFavoritesBinding
|
||||
import com.hippo.ehviewer.databinding.ItemGalleryCommentBinding
|
||||
import com.hippo.ehviewer.databinding.ItemGalleryCommentMoreBinding
|
||||
import com.hippo.ehviewer.databinding.ItemGalleryCommentProgressBinding
|
||||
import com.hippo.ehviewer.databinding.SceneGalleryCommentsBinding
|
||||
import com.hippo.ehviewer.ui.jumpToReaderByPage
|
||||
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
|
||||
import com.hippo.ehviewer.ui.legacy.CoilImageGetter
|
||||
import com.hippo.ehviewer.ui.legacy.EditTextDialogBuilder
|
||||
import com.hippo.ehviewer.ui.legacy.ViewTransition
|
||||
import com.hippo.ehviewer.ui.legacy.WindowInsetsAnimationHelper
|
||||
import com.hippo.ehviewer.ui.openBrowser
|
||||
import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs
|
||||
import com.hippo.ehviewer.util.AnimationUtils
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import com.hippo.ehviewer.util.IntList
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.SimpleAnimatorListener
|
||||
import com.hippo.ehviewer.util.TextUrl
|
||||
import com.hippo.ehviewer.util.addTextToClipboard
|
||||
import com.hippo.ehviewer.util.getParcelableCompat
|
||||
import com.hippo.ehviewer.util.toBBCode
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import rikka.core.res.resolveColor
|
||||
import kotlin.math.hypot
|
||||
|
||||
class GalleryCommentsScene : BaseToolbarScene(), View.OnClickListener, OnRefreshListener {
|
||||
private var _binding: SceneGalleryCommentsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val callback = object : OnBackPressedCallback(false) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (!mInAnimation && binding.editPanel.visibility == View.VISIBLE) {
|
||||
hideEditPanel(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var mGalleryDetail: GalleryDetail? = null
|
||||
private var mAdapter: CommentAdapter? = null
|
||||
private var mViewTransition: ViewTransition? = null
|
||||
private var mSendDrawable: Drawable? = null
|
||||
private var mPencilDrawable: Drawable? = null
|
||||
private var mCommentId: Long = 0
|
||||
private var mInAnimation = false
|
||||
private var mShowAllComments = false
|
||||
private var mRefreshingComments = false
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requireActivity().onBackPressedDispatcher.addCallback(callback)
|
||||
if (savedInstanceState == null) {
|
||||
onInit()
|
||||
} else {
|
||||
onRestore(savedInstanceState)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleArgs(args: Bundle?) {
|
||||
if (args == null) {
|
||||
return
|
||||
}
|
||||
mGalleryDetail = args.getParcelableCompat(KEY_GALLERY_DETAIL)
|
||||
mShowAllComments =
|
||||
mGalleryDetail != null && true && !mGalleryDetail!!.comments.hasMore
|
||||
}
|
||||
|
||||
private fun onInit() {
|
||||
handleArgs(arguments)
|
||||
}
|
||||
|
||||
private fun onRestore(savedInstanceState: Bundle) {
|
||||
mGalleryDetail = savedInstanceState.getParcelableCompat(KEY_GALLERY_DETAIL)
|
||||
mShowAllComments = mGalleryDetail != null && !mGalleryDetail!!.comments.hasMore
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putParcelable(KEY_GALLERY_DETAIL, mGalleryDetail)
|
||||
}
|
||||
|
||||
override fun onCreateViewWithToolbar(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = SceneGalleryCommentsBinding.inflate(inflater, container!!)
|
||||
setLiftOnScrollTargetView(binding.recyclerView)
|
||||
val tip = binding.tip
|
||||
ViewCompat.setWindowInsetsAnimationCallback(
|
||||
binding.root,
|
||||
WindowInsetsAnimationHelper(
|
||||
WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP,
|
||||
binding.editPanel,
|
||||
binding.fabLayout,
|
||||
),
|
||||
)
|
||||
binding.refreshLayout.setOnRefreshListener(this)
|
||||
val context = requireContext()
|
||||
val drawable = ContextCompat.getDrawable(context, R.drawable.big_sad_pandroid)
|
||||
drawable!!.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
|
||||
tip.setCompoundDrawables(null, drawable, null, null)
|
||||
mSendDrawable = ContextCompat.getDrawable(context, R.drawable.v_send_dark_x24)
|
||||
mPencilDrawable = ContextCompat.getDrawable(context, R.drawable.v_pencil_dark_x24)
|
||||
mAdapter = CommentAdapter()
|
||||
binding.recyclerView.adapter = mAdapter
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(
|
||||
context,
|
||||
RecyclerView.VERTICAL,
|
||||
false,
|
||||
)
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
// Cancel change animator
|
||||
val itemAnimator = binding.recyclerView.itemAnimator
|
||||
if (itemAnimator is DefaultItemAnimator) {
|
||||
itemAnimator.supportsChangeAnimations = false
|
||||
}
|
||||
binding.send.setOnClickListener(this)
|
||||
binding.editText.customSelectionActionModeCallback = object : ActionMode.Callback {
|
||||
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
requireActivity().menuInflater.inflate(R.menu.context_comment, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
|
||||
item?.let {
|
||||
val text = binding.editText.editableText
|
||||
val start = binding.editText.selectionStart
|
||||
val end = binding.editText.selectionEnd
|
||||
when (item.itemId) {
|
||||
R.id.action_bold -> text[start, end] = StyleSpan(Typeface.BOLD)
|
||||
|
||||
R.id.action_italic -> text[start, end] = StyleSpan(Typeface.ITALIC)
|
||||
|
||||
R.id.action_underline -> text[start, end] = UnderlineSpan()
|
||||
|
||||
R.id.action_strikethrough -> text[start, end] = StrikethroughSpan()
|
||||
|
||||
R.id.action_url -> {
|
||||
val oldSpans = text.getSpans<URLSpan>(start, end)
|
||||
var oldUrl = "https://"
|
||||
oldSpans.forEach {
|
||||
if (!it.url.isNullOrEmpty()) {
|
||||
oldUrl = it.url
|
||||
}
|
||||
}
|
||||
val builder = EditTextDialogBuilder(
|
||||
context,
|
||||
oldUrl,
|
||||
getString(R.string.format_url),
|
||||
)
|
||||
builder.setTitle(getString(R.string.format_url))
|
||||
builder.setPositiveButton(android.R.string.ok, null)
|
||||
val dialog = builder.show()
|
||||
val button: View? = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
button?.setOnClickListener(
|
||||
View.OnClickListener {
|
||||
val url = builder.text.trim()
|
||||
if (url.isEmpty()) {
|
||||
builder.setError(getString(R.string.text_is_empty))
|
||||
return@OnClickListener
|
||||
} else {
|
||||
builder.setError(null)
|
||||
}
|
||||
text.clearSpan(start, end, true)
|
||||
text[start, end] = URLSpan(url)
|
||||
dialog.dismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
R.id.action_clear -> {
|
||||
text.clearSpan(start, end, false)
|
||||
}
|
||||
|
||||
else -> return false
|
||||
}
|
||||
mode?.finish()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
}
|
||||
}
|
||||
binding.fab.setOnClickListener(this)
|
||||
addAboveSnackView(binding.editPanel)
|
||||
addAboveSnackView(binding.fabLayout)
|
||||
mViewTransition = ViewTransition(binding.refreshLayout, tip)
|
||||
updateView(false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
fun Spannable.clearSpan(start: Int, end: Int, url: Boolean) {
|
||||
val spans = if (url) getSpans<URLSpan>(start, end) else getSpans<CharacterStyle>(start, end)
|
||||
spans.forEach {
|
||||
val spanStart = getSpanStart(it)
|
||||
val spanEnd = getSpanEnd(it)
|
||||
removeSpan(it)
|
||||
if (spanStart < start) {
|
||||
this[spanStart, start] = it
|
||||
}
|
||||
if (spanEnd > end) {
|
||||
this[end, spanEnd] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
callback.remove()
|
||||
binding.recyclerView.stopScroll()
|
||||
removeAboveSnackView(binding.editPanel)
|
||||
removeAboveSnackView(binding.fabLayout)
|
||||
mAdapter = null
|
||||
mViewTransition = null
|
||||
_binding = null
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
setTitle(R.string.gallery_comments)
|
||||
setNavigationIcon(R.drawable.v_arrow_left_dark_x24)
|
||||
}
|
||||
|
||||
private fun showFilterCommenterDialog(commenter: String?, position: Int) {
|
||||
val context = context
|
||||
if (context == null || commenter == null) {
|
||||
return
|
||||
}
|
||||
BaseDialogBuilder(context)
|
||||
.setMessage(getString(R.string.filter_the_commenter, commenter))
|
||||
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
|
||||
Filter(FilterMode.COMMENTER, commenter).remember()
|
||||
hideComment(position)
|
||||
showTip(R.string.filter_added, LENGTH_SHORT)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun hideComment(position: Int) {
|
||||
if (mGalleryDetail == null) {
|
||||
return
|
||||
}
|
||||
val oldCommentsList = mGalleryDetail!!.comments.comments
|
||||
val newCommentsList = arrayOfNulls<GalleryComment>(
|
||||
oldCommentsList.size - 1,
|
||||
)
|
||||
var i = 0
|
||||
var j = 0
|
||||
while (i < oldCommentsList.size) {
|
||||
if (i != position) {
|
||||
newCommentsList[j] = oldCommentsList[i]
|
||||
j++
|
||||
}
|
||||
i++
|
||||
}
|
||||
mGalleryDetail!!.comments.comments = newCommentsList.requireNoNulls()
|
||||
mAdapter!!.notifyDataSetChanged()
|
||||
updateView(true)
|
||||
}
|
||||
|
||||
private fun voteComment(id: Long, vote: Int) {
|
||||
val gd = mGalleryDetail ?: return
|
||||
lifecycleScope.launchIO {
|
||||
runSuspendCatching {
|
||||
EhEngine.voteComment(gd.apiUid, gd.apiKey, gd.gid, gd.token, id, vote)
|
||||
}.onSuccess { result ->
|
||||
val voteUp = vote > 0
|
||||
showTip(
|
||||
if (voteUp) (if (0 != result.vote) R.string.vote_up_successfully else R.string.cancel_vote_up_successfully) else if (0 != result.vote) R.string.vote_down_successfully else R.string.cancel_vote_down_successfully,
|
||||
LENGTH_SHORT,
|
||||
)
|
||||
withUIContext { onVoteCommentSuccess(result, voteUp) }
|
||||
}.onFailure {
|
||||
showTip(R.string.vote_failed, LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
fun showVoteStatusDialog(context: Context, voteStatus: String) {
|
||||
val temp = voteStatus.split(',')
|
||||
val length = temp.size
|
||||
val userArray = arrayOfNulls<String>(length)
|
||||
val voteArray = arrayOfNulls<String>(length)
|
||||
for (i in 0 until length) {
|
||||
val str = temp[i].trim()
|
||||
val index = str.lastIndexOf(' ')
|
||||
if (index < 0) {
|
||||
Log.d(TAG, "Something wrong happened about vote state")
|
||||
userArray[i] = str
|
||||
voteArray[i] = ""
|
||||
} else {
|
||||
userArray[i] = str.substring(0, index).trim()
|
||||
voteArray[i] = str.substring(index + 1).trim()
|
||||
}
|
||||
}
|
||||
val builder = BaseDialogBuilder(context)
|
||||
val builderContext = builder.context
|
||||
val inflater = LayoutInflater.from(builderContext)
|
||||
val rv = inflater.inflate(R.layout.dialog_recycler_view, null) as RecyclerView
|
||||
rv.adapter = object : RecyclerView.Adapter<VoteHolder>() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VoteHolder {
|
||||
return VoteHolder(ItemDrawerFavoritesBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VoteHolder, position: Int) {
|
||||
holder.bind(userArray[position], voteArray[position])
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return length
|
||||
}
|
||||
}
|
||||
rv.layoutManager = LinearLayoutManager(builderContext)
|
||||
rv.clipToPadding = false
|
||||
builder.setView(rv).show()
|
||||
}
|
||||
|
||||
private fun showCommentDialog(position: Int, text: CharSequence) {
|
||||
val context = context
|
||||
if (context == null || mGalleryDetail == null || position >= mGalleryDetail!!.comments.comments.size || position < 0) {
|
||||
return
|
||||
}
|
||||
val comment = mGalleryDetail!!.comments.comments[position]
|
||||
val menu: MutableList<String> = ArrayList()
|
||||
val menuId = IntList()
|
||||
val resources = context.resources
|
||||
menu.add(resources.getString(R.string.copy_comment_text))
|
||||
menuId.add(R.id.copy)
|
||||
if (!comment.uploader && !comment.editable) {
|
||||
menu.add(resources.getString(R.string.block_commenter))
|
||||
menuId.add(R.id.block_commenter)
|
||||
}
|
||||
if (comment.editable) {
|
||||
menu.add(resources.getString(R.string.edit_comment))
|
||||
menuId.add(R.id.edit_comment)
|
||||
}
|
||||
if (comment.voteUpAble) {
|
||||
menu.add(resources.getString(if (comment.voteUpEd) R.string.cancel_vote_up else R.string.vote_up))
|
||||
menuId.add(R.id.vote_up)
|
||||
}
|
||||
if (comment.voteDownAble) {
|
||||
menu.add(resources.getString(if (comment.voteDownEd) R.string.cancel_vote_down else R.string.vote_down))
|
||||
menuId.add(R.id.vote_down)
|
||||
}
|
||||
if (!comment.voteState.isNullOrEmpty()) {
|
||||
menu.add(resources.getString(R.string.check_vote_status))
|
||||
menuId.add(R.id.check_vote_status)
|
||||
}
|
||||
BaseDialogBuilder(context)
|
||||
.setItems(menu.toTypedArray()) { _: DialogInterface?, which: Int ->
|
||||
if (which < 0 || which >= menuId.size) {
|
||||
return@setItems
|
||||
}
|
||||
when (menuId[which]) {
|
||||
R.id.copy -> requireActivity().addTextToClipboard(text, false)
|
||||
R.id.block_commenter -> showFilterCommenterDialog(comment.user, position)
|
||||
R.id.vote_up -> voteComment(comment.id, 1)
|
||||
R.id.vote_down -> voteComment(comment.id, -1)
|
||||
R.id.check_vote_status -> showVoteStatusDialog(context, comment.voteState!!)
|
||||
R.id.edit_comment -> {
|
||||
prepareEditComment(comment.id, text)
|
||||
if (!mInAnimation && binding.editPanel.visibility != View.VISIBLE) {
|
||||
showEditPanel(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
fun onItemClick(parent: RecyclerView?, view2: View?, position: Int): Boolean {
|
||||
val activity = mainActivity ?: return false
|
||||
val holder = parent!!.getChildViewHolder(view2!!)
|
||||
if (holder is ActualCommentHolder) {
|
||||
val span = holder.binding.comment.currentSpan
|
||||
holder.binding.comment.clearCurrentSpan()
|
||||
val detail = mGalleryDetail ?: return false
|
||||
if (span is URLSpan) {
|
||||
if (!activity.jumpToReaderByPage(span.url, detail)) {
|
||||
if (!findNavController().navWithUrl(span.url)) {
|
||||
activity.openBrowser(span.url)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showCommentDialog(position, holder.sp)
|
||||
}
|
||||
} else if (holder is MoreCommentHolder && !mRefreshingComments && mAdapter != null) {
|
||||
mRefreshingComments = true
|
||||
mShowAllComments = true
|
||||
mAdapter!!.notifyItemChanged(position)
|
||||
galleryDetailUrl?.let { refreshComment(it) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun refreshComment(url: String) {
|
||||
lifecycleScope.launchIO {
|
||||
runSuspendCatching {
|
||||
EhEngine.getGalleryDetail(url)
|
||||
}.onSuccess {
|
||||
withUIContext {
|
||||
onRefreshGallerySuccess(it.comments)
|
||||
}
|
||||
}.onFailure {
|
||||
withUIContext {
|
||||
onRefreshGalleryFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateView(animation: Boolean) {
|
||||
if (null == mViewTransition) {
|
||||
return
|
||||
}
|
||||
if (mGalleryDetail == null || mGalleryDetail!!.comments.comments.isEmpty()) {
|
||||
mViewTransition!!.showView(1, animation)
|
||||
} else {
|
||||
mViewTransition!!.showView(0, animation)
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareNewComment() {
|
||||
mCommentId = 0
|
||||
binding.send.setImageDrawable(mSendDrawable)
|
||||
}
|
||||
|
||||
private fun prepareEditComment(commentId: Long, text: CharSequence) {
|
||||
mCommentId = commentId
|
||||
binding.editText.setText(text)
|
||||
binding.send.setImageDrawable(mPencilDrawable)
|
||||
}
|
||||
|
||||
private fun showEditPanelWithAnimation() {
|
||||
mInAnimation = true
|
||||
binding.fab.translationX = 0.0f
|
||||
binding.fab.translationY = 0.0f
|
||||
binding.fab.scaleX = 1.0f
|
||||
binding.fab.scaleY = 1.0f
|
||||
val fabEndX = binding.editPanel.left + binding.editPanel.width / 2 - binding.fab.width / 2
|
||||
val fabEndY = binding.editPanel.top + binding.editPanel.height / 2 - binding.fab.height / 2
|
||||
binding.fab.animate().x(fabEndX.toFloat()).y(fabEndY.toFloat()).scaleX(0.0f).scaleY(0.0f)
|
||||
.setInterpolator(AnimationUtils.SLOW_FAST_SLOW_INTERPOLATOR)
|
||||
.setDuration(300L).setListener(object : SimpleAnimatorListener() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
(binding.fab as View).visibility = View.INVISIBLE
|
||||
binding.editPanel.visibility = View.VISIBLE
|
||||
val halfW = binding.editPanel.width / 2
|
||||
val halfH = binding.editPanel.height / 2
|
||||
val animator = ViewAnimationUtils.createCircularReveal(
|
||||
binding.editPanel,
|
||||
halfW,
|
||||
halfH,
|
||||
0f,
|
||||
hypot(halfW.toDouble(), halfH.toDouble()).toFloat(),
|
||||
).setDuration(300L)
|
||||
animator.addListener(object : SimpleAnimatorListener() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
mInAnimation = false
|
||||
}
|
||||
})
|
||||
animator.start()
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
|
||||
private fun showEditPanel(animation: Boolean) {
|
||||
callback.isEnabled = true
|
||||
if (animation) {
|
||||
showEditPanelWithAnimation()
|
||||
} else {
|
||||
(binding.fab as View).visibility = View.INVISIBLE
|
||||
binding.editPanel.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideEditPanelWithAnimation() {
|
||||
mInAnimation = true
|
||||
val halfW = binding.editPanel.width / 2
|
||||
val halfH = binding.editPanel.height / 2
|
||||
val animator = ViewAnimationUtils.createCircularReveal(
|
||||
binding.editPanel,
|
||||
halfW,
|
||||
halfH,
|
||||
hypot(halfW.toDouble(), halfH.toDouble()).toFloat(),
|
||||
0.0f,
|
||||
).setDuration(300L)
|
||||
animator.addListener(object : SimpleAnimatorListener() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||
// Some devices may run this block in non-UI thread.
|
||||
// It might be a bug of Android OS.
|
||||
// Check it here to avoid crash.
|
||||
return
|
||||
}
|
||||
binding.editPanel.visibility = View.GONE
|
||||
(binding.fab as View).visibility = View.VISIBLE
|
||||
val fabStartX =
|
||||
binding.editPanel.left + binding.editPanel.width / 2 - binding.fab.width / 2
|
||||
val fabStartY =
|
||||
binding.editPanel.top + binding.editPanel.height / 2 - binding.fab.height / 2
|
||||
binding.fab.x = fabStartX.toFloat()
|
||||
binding.fab.y = fabStartY.toFloat()
|
||||
binding.fab.scaleX = 0.0f
|
||||
binding.fab.scaleY = 0.0f
|
||||
binding.fab.rotation = -45.0f
|
||||
binding.fab.animate().translationX(0.0f).translationY(0.0f).scaleX(1.0f)
|
||||
.scaleY(1.0f)
|
||||
.rotation(0.0f)
|
||||
.setInterpolator(AnimationUtils.SLOW_FAST_SLOW_INTERPOLATOR)
|
||||
.setDuration(300L).setListener(object : SimpleAnimatorListener() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
mInAnimation = false
|
||||
}
|
||||
}).start()
|
||||
}
|
||||
})
|
||||
animator.start()
|
||||
}
|
||||
|
||||
private fun hideEditPanel(animation: Boolean) {
|
||||
callback.isEnabled = false
|
||||
hideSoftInput()
|
||||
if (animation) {
|
||||
hideEditPanelWithAnimation()
|
||||
} else {
|
||||
(binding.fab as View).visibility = View.VISIBLE
|
||||
binding.editPanel.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
private val galleryDetailUrl: String?
|
||||
get() = if (mGalleryDetail != null && mGalleryDetail!!.gid != -1L && mGalleryDetail!!.token != null) {
|
||||
EhUrl.getGalleryDetailUrl(
|
||||
mGalleryDetail!!.gid,
|
||||
mGalleryDetail!!.token,
|
||||
0,
|
||||
mShowAllComments,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val context = context
|
||||
val activity = mainActivity
|
||||
if (null == context || null == activity) {
|
||||
return
|
||||
}
|
||||
if (binding.fab === v) {
|
||||
if (!mInAnimation) {
|
||||
prepareNewComment()
|
||||
showEditPanel(true)
|
||||
}
|
||||
} else if (binding.send === v) {
|
||||
if (!mInAnimation) {
|
||||
val comment = binding.editText.text?.toBBCode()?.takeIf { it.isNotBlank() } ?: return
|
||||
val url = galleryDetailUrl ?: return
|
||||
lifecycleScope.launchIO {
|
||||
runSuspendCatching {
|
||||
EhEngine.commentGallery(
|
||||
url,
|
||||
comment,
|
||||
if (mCommentId != 0L) mCommentId.toString() else null,
|
||||
)
|
||||
}.onSuccess {
|
||||
showTip(
|
||||
if (mCommentId != 0L) R.string.edit_comment_successfully else R.string.comment_successfully,
|
||||
LENGTH_SHORT,
|
||||
)
|
||||
withUIContext {
|
||||
onCommentGallerySuccess(it)
|
||||
}
|
||||
}.onFailure {
|
||||
showTip(
|
||||
"""
|
||||
${getString(if (mCommentId != 0L) R.string.edit_comment_failed else R.string.comment_failed)}
|
||||
${ExceptionUtils.getReadableString(it)}
|
||||
""".trimIndent(),
|
||||
LENGTH_LONG,
|
||||
)
|
||||
}
|
||||
}
|
||||
hideSoftInput()
|
||||
hideEditPanel(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRefreshGallerySuccess(result: GalleryCommentList) {
|
||||
if (mGalleryDetail == null || mAdapter == null) {
|
||||
return
|
||||
}
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
mRefreshingComments = false
|
||||
mGalleryDetail!!.comments = result
|
||||
mAdapter!!.notifyDataSetChanged()
|
||||
updateView(true)
|
||||
}
|
||||
|
||||
private fun onRefreshGalleryFailure() {
|
||||
if (mAdapter == null) {
|
||||
return
|
||||
}
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
mRefreshingComments = false
|
||||
val position = mAdapter!!.itemCount - 1
|
||||
if (position >= 0) {
|
||||
mAdapter!!.notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onCommentGallerySuccess(result: GalleryCommentList) {
|
||||
if (mGalleryDetail == null || mAdapter == null) {
|
||||
return
|
||||
}
|
||||
mGalleryDetail!!.comments = result
|
||||
mAdapter!!.notifyDataSetChanged()
|
||||
|
||||
// Remove text
|
||||
binding.editText.setText("")
|
||||
updateView(true)
|
||||
}
|
||||
|
||||
private fun onVoteCommentSuccess(result: VoteCommentResult, voteUp: Boolean) {
|
||||
if (mAdapter == null) {
|
||||
return
|
||||
}
|
||||
var position = -1
|
||||
var i = 0
|
||||
val n = mGalleryDetail!!.comments.comments.size
|
||||
while (i < n) {
|
||||
val comment = mGalleryDetail!!.comments.comments[i]
|
||||
if (comment.id == result.id) {
|
||||
position = i
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
if (-1 == position) {
|
||||
Log.d(TAG, "Can't find comment with id " + result.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Update comment
|
||||
val comment = mGalleryDetail!!.comments.comments[position]
|
||||
comment.score = result.score
|
||||
if (voteUp) {
|
||||
comment.voteUpEd = 0 != result.vote
|
||||
comment.voteDownEd = false
|
||||
} else {
|
||||
comment.voteDownEd = 0 != result.vote
|
||||
comment.voteUpEd = false
|
||||
}
|
||||
mAdapter!!.notifyItemChanged(position)
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
if (!mRefreshingComments && mAdapter != null) {
|
||||
mRefreshingComments = true
|
||||
galleryDetailUrl?.let { refreshComment(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private class VoteHolder(private val binding: ItemDrawerFavoritesBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(user: String?, vote: String?) {
|
||||
binding.key.text = user
|
||||
binding.value.text = vote
|
||||
}
|
||||
}
|
||||
|
||||
private abstract class CommentHolder(binding: ViewBinding) :
|
||||
RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
private class MoreCommentHolder(binding: ItemGalleryCommentMoreBinding) : CommentHolder(binding)
|
||||
|
||||
private class ProgressCommentHolder(binding: ItemGalleryCommentProgressBinding) :
|
||||
CommentHolder(binding)
|
||||
|
||||
private inner class ActualCommentHolder(val binding: ItemGalleryCommentBinding) :
|
||||
CommentHolder(binding) {
|
||||
lateinit var sp: CharSequence
|
||||
|
||||
private fun generateComment(
|
||||
context: Context,
|
||||
textView: TextView,
|
||||
comment: GalleryComment,
|
||||
): CharSequence {
|
||||
sp = comment.comment.orEmpty().parseAsHtml(imageGetter = CoilImageGetter(textView))
|
||||
val ssb = SpannableStringBuilder(sp)
|
||||
if (0L != comment.id && 0 != comment.score) {
|
||||
val score = comment.score
|
||||
val scoreString = if (score > 0) "+$score" else score.toString()
|
||||
ssb.append(" ").inSpans(
|
||||
RelativeSizeSpan(0.8f),
|
||||
StyleSpan(Typeface.BOLD),
|
||||
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
|
||||
) {
|
||||
append(scoreString)
|
||||
}
|
||||
}
|
||||
if (comment.lastEdited != 0L) {
|
||||
val str = context.getString(
|
||||
R.string.last_edited,
|
||||
ReadableTime.getTimeAgo(comment.lastEdited),
|
||||
)
|
||||
ssb.append("\n\n").inSpans(
|
||||
RelativeSizeSpan(0.8f),
|
||||
StyleSpan(Typeface.BOLD),
|
||||
ForegroundColorSpan(theme.resolveColor(android.R.attr.textColorSecondary)),
|
||||
) {
|
||||
append(str)
|
||||
}
|
||||
}
|
||||
return TextUrl.handleTextUrl(ssb)
|
||||
}
|
||||
|
||||
fun bind(value: GalleryComment) {
|
||||
binding.run {
|
||||
user.text = value.user?.let {
|
||||
if (value.uploader) getString(R.string.comment_user_uploader, it) else it
|
||||
}
|
||||
user.setOnClickListener {
|
||||
value.user?.let {
|
||||
val lub = ListUrlBuilder().apply {
|
||||
mode = ListUrlBuilder.MODE_UPLOADER
|
||||
keyword = it
|
||||
}
|
||||
navAnimated(R.id.galleryListScene, lub.toStartArgs(), true)
|
||||
}
|
||||
}
|
||||
time.text = ReadableTime.getTimeAgo(value.time)
|
||||
comment.text = generateComment(binding.comment.context, binding.comment, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CommentAdapter : RecyclerView.Adapter<CommentHolder>() {
|
||||
private val mInflater: LayoutInflater = layoutInflater
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentHolder {
|
||||
return when (viewType) {
|
||||
TYPE_COMMENT -> ActualCommentHolder(
|
||||
ItemGalleryCommentBinding.inflate(mInflater, parent, false),
|
||||
)
|
||||
|
||||
TYPE_MORE -> MoreCommentHolder(
|
||||
ItemGalleryCommentMoreBinding.inflate(mInflater, parent, false),
|
||||
)
|
||||
|
||||
TYPE_PROGRESS -> ProgressCommentHolder(
|
||||
ItemGalleryCommentProgressBinding.inflate(mInflater, parent, false),
|
||||
)
|
||||
|
||||
else -> throw IllegalStateException("Invalid view type: $viewType")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: CommentHolder, position: Int) {
|
||||
val context = context
|
||||
if (context == null || mGalleryDetail == null) {
|
||||
return
|
||||
}
|
||||
holder.itemView.setOnClickListener {
|
||||
onItemClick(
|
||||
binding.recyclerView,
|
||||
holder.itemView,
|
||||
position,
|
||||
)
|
||||
}
|
||||
holder.itemView.isClickable = true
|
||||
holder.itemView.isFocusable = true
|
||||
if (holder is ActualCommentHolder) {
|
||||
holder.bind(mGalleryDetail!!.comments.comments[position])
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return if (mGalleryDetail == null) {
|
||||
0
|
||||
} else if (mGalleryDetail!!.comments.hasMore) {
|
||||
mGalleryDetail!!.comments.comments.size + 1
|
||||
} else {
|
||||
mGalleryDetail!!.comments.comments.size
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (position >= mGalleryDetail!!.comments.comments.size) {
|
||||
if (mRefreshingComments) TYPE_PROGRESS else TYPE_MORE
|
||||
} else {
|
||||
TYPE_COMMENT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG: String = GalleryCommentsScene::class.java.simpleName
|
||||
const val KEY_API_UID = "api_uid"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val KEY_GID = "gid"
|
||||
const val KEY_TOKEN = "token"
|
||||
const val KEY_COMMENT_LIST = "comment_list"
|
||||
const val KEY_GALLERY_DETAIL = "gallery_detail"
|
||||
private const val TYPE_COMMENT = 0
|
||||
private const val TYPE_MORE = 1
|
||||
private const val TYPE_PROGRESS = 2
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,99 +0,0 @@
|
||||
/*
|
||||
* 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.ui.scene
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.Settings.listThumbSize
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.setMD3Content
|
||||
import eu.kanade.tachiyomi.util.system.pxToDp
|
||||
|
||||
abstract class GalleryHolder(composeView: ComposeView) : RecyclerView.ViewHolder(composeView) {
|
||||
var galleryId = 0L
|
||||
|
||||
abstract fun bind(
|
||||
galleryInfo: GalleryInfo,
|
||||
isChecked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
class ListGalleryHolder(private val composeView: ComposeView, private val showFavourite: Boolean) : GalleryHolder(composeView) {
|
||||
|
||||
private val height = (3 * listThumbSize * 3).pxToDp.dp
|
||||
private val showPages = Settings.showGalleryPages
|
||||
|
||||
override fun bind(galleryInfo: GalleryInfo, isChecked: Boolean, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||
composeView.setMD3Content {
|
||||
CheckableItem(checked = isChecked) {
|
||||
GalleryInfoListItem(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
info = galleryInfo,
|
||||
modifier = Modifier.height(height),
|
||||
isInFavScene = !showFavourite,
|
||||
showPages = showPages,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GridGalleryHolder(private val composeView: ComposeView) : GalleryHolder(composeView) {
|
||||
override fun bind(galleryInfo: GalleryInfo, isChecked: Boolean, onClick: () -> Unit, onLongClick: () -> Unit) {
|
||||
composeView.setMD3Content {
|
||||
CheckableItem(checked = isChecked) {
|
||||
GalleryInfoGridItem(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
info = galleryInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround https://github.com/Ehviewer-Overhauled/Ehviewer/issues/1023
|
||||
// See https://issuetracker.google.com/issues/240449681
|
||||
composeView.getChildAt(0)?.requestLayout()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckableItem(checked: Boolean, content: @Composable () -> Unit) {
|
||||
Box {
|
||||
content()
|
||||
if (checked) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,164 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.scene
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
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.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import androidx.room.paging.util.getClippedRefreshKey
|
||||
import androidx.room.paging.util.getLimit
|
||||
import androidx.room.paging.util.getOffset
|
||||
import arrow.fx.coroutines.parMap
|
||||
import coil.imageLoader
|
||||
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.data.GalleryDetail
|
||||
import com.hippo.ehviewer.client.data.GalleryPreview
|
||||
import com.hippo.ehviewer.coil.justDownload
|
||||
import com.hippo.ehviewer.ktbuilder.imageRequest
|
||||
import com.hippo.ehviewer.ui.legacy.calculateSuitableSpanCount
|
||||
import com.hippo.ehviewer.ui.main.EhPreviewItem
|
||||
import com.hippo.ehviewer.ui.navToReader
|
||||
import com.hippo.ehviewer.ui.setMD3Content
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalGrid
|
||||
import com.hippo.ehviewer.ui.tools.rememberDialogState
|
||||
import com.hippo.ehviewer.ui.tools.rememberInVM
|
||||
import com.hippo.ehviewer.util.getParcelableCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
class GalleryPreviewScreen : Fragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = ComposeView(inflater.context).apply {
|
||||
setMD3Content {
|
||||
val galleryDetail = remember { requireArguments().getParcelableCompat<GalleryDetail>(KEY_GALLERY_DETAIL)!! }
|
||||
val context = LocalContext.current
|
||||
fun onPreviewCLick(index: Int) = context.navToReader(galleryDetail.galleryInfo, index)
|
||||
var toNextPage by rememberSaveable { mutableStateOf(requireArguments().getBoolean(KEY_NEXT_PAGE)) }
|
||||
val scrollBehaviour = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val columnCount = calculateSuitableSpanCount()
|
||||
val state = rememberLazyGridState()
|
||||
val dialogState = rememberDialogState()
|
||||
dialogState.Intercept()
|
||||
val coroutineScope = rememberCoroutineScope { Dispatchers.IO }
|
||||
val pages = galleryDetail.pages
|
||||
val pgSize = galleryDetail.previewList.size
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (toNextPage) state.scrollToItem(pgSize)
|
||||
toNextPage = false
|
||||
}
|
||||
|
||||
suspend fun getPreviewListByPage(page: Int) = galleryDetail.run {
|
||||
val url = EhUrl.getGalleryDetailUrl(gid, token, page, false)
|
||||
val result = EhEngine.getPreviewList(url)
|
||||
if (Settings.preloadThumbAggressively) {
|
||||
coroutineScope.launch {
|
||||
context.run { result.first.first.forEach { imageLoader.enqueue(imageRequest(it) { justDownload() }) } }
|
||||
}
|
||||
}
|
||||
result.first.first
|
||||
}
|
||||
|
||||
val data = rememberInVM {
|
||||
val previewPagesMap = galleryDetail.previewList.associateBy { it.position } as MutableMap
|
||||
Pager(PagingConfig(pageSize = pgSize, initialLoadSize = pgSize, jumpThreshold = 2 * pgSize)) {
|
||||
object : PagingSource<Int, GalleryPreview>() {
|
||||
override fun getRefreshKey(state: PagingState<Int, GalleryPreview>) = state.getClippedRefreshKey()
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GalleryPreview> {
|
||||
val key = params.key ?: 0
|
||||
val up = getOffset(params, key, pages)
|
||||
val end = (up + getLimit(params, key) - 1).coerceAtMost(pages - 1)
|
||||
runSuspendCatching {
|
||||
(up..end).filterNot { it in previewPagesMap }.map { it / pgSize }.toSet()
|
||||
.parMap(Dispatchers.IO) { getPreviewListByPage(it) }
|
||||
.forEach { previews -> previews.forEach { previewPagesMap[it.position] = it } }
|
||||
}.onFailure {
|
||||
return LoadResult.Error(it)
|
||||
}
|
||||
val r = (up..end).map { requireNotNull(previewPagesMap[it]) }
|
||||
val prevK = if (up <= 0 || r.isEmpty()) null else up
|
||||
val nextK = if (end == pages - 1) null else end + 1
|
||||
return LoadResult.Page(r, prevK, nextK, up, pages - end - 1)
|
||||
}
|
||||
override val jumpingSupported = true
|
||||
}
|
||||
}.flow.cachedIn(viewModelScope)
|
||||
}.collectAsLazyPagingItems()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.gallery_previews)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { findNavController().popBackStack() }) {
|
||||
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehaviour,
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
FastScrollLazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columnCount),
|
||||
modifier = Modifier.nestedScroll(scrollBehaviour.nestedScrollConnection).padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)),
|
||||
state = state,
|
||||
contentPadding = paddingValues,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(
|
||||
count = data.itemCount,
|
||||
key = data.itemKey(key = { item -> item.position }),
|
||||
contentType = data.itemContentType(),
|
||||
) { index ->
|
||||
val item = data[index]
|
||||
EhPreviewItem(item, index) {
|
||||
onPreviewCLick(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val KEY_GALLERY_DETAIL = "gallery_detail"
|
||||
const val KEY_NEXT_PAGE = "next_page"
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.scene
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MotionEvent
|
||||
import androidx.recyclerview.selection.ItemDetailsLookup
|
||||
import androidx.recyclerview.selection.ItemKeyProvider
|
||||
import androidx.recyclerview.selection.SelectionTracker
|
||||
import androidx.recyclerview.selection.StorageStrategy
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
|
||||
class GallerySelectionTracker<T : GalleryInfo>(
|
||||
selectionId: String,
|
||||
recyclerView: RecyclerView,
|
||||
private val getItems: () -> List<T>,
|
||||
getItemKey: RecyclerView.ViewHolder.() -> Long,
|
||||
builder: SelectionTracker.Builder<Long>.() -> Unit = {},
|
||||
) {
|
||||
private val tracker: SelectionTracker<Long>
|
||||
val selectionSize get() = tracker.selection.size()
|
||||
var isInCustomChoice = false
|
||||
private set
|
||||
|
||||
init {
|
||||
tracker = SelectionTracker.Builder(
|
||||
selectionId,
|
||||
recyclerView,
|
||||
object : ItemKeyProvider<Long>(SCOPE_MAPPED) {
|
||||
override fun getKey(position: Int) = getItems()[position].gid
|
||||
override fun getPosition(key: Long) = getItems().indexOfFirst { it.gid == key }
|
||||
},
|
||||
object : ItemDetailsLookup<Long>() {
|
||||
override fun getItemDetails(e: MotionEvent) =
|
||||
recyclerView.findChildViewUnder(e.x, e.y)?.let {
|
||||
recyclerView.getChildViewHolder(it).run {
|
||||
object : ItemDetails<Long>() {
|
||||
override fun getPosition() = bindingAdapterPosition
|
||||
override fun getSelectionKey() = getItemKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
StorageStrategy.createLongStorage(),
|
||||
).apply(builder).build()
|
||||
}
|
||||
|
||||
fun addCustomChoiceListener(onIntoCustomChoiceListener: () -> Unit, onOutOfCustomChoiceListener: () -> Unit) {
|
||||
tracker.addObserver(object : SelectionTracker.SelectionObserver<Long>() {
|
||||
override fun onSelectionChanged() {
|
||||
if (tracker.selection.isEmpty) {
|
||||
if (isInCustomChoice) {
|
||||
isInCustomChoice = false
|
||||
onOutOfCustomChoiceListener()
|
||||
}
|
||||
} else if (!isInCustomChoice) {
|
||||
isInCustomChoice = true
|
||||
onIntoCustomChoiceListener()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSelectionRestored() {
|
||||
if (!tracker.selection.isEmpty) {
|
||||
isInCustomChoice = true
|
||||
onIntoCustomChoiceListener()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun isSelected(key: Long) = tracker.isSelected(key)
|
||||
fun selectAll() = tracker.setItemsSelected(getItems().map(GalleryInfo::gid), true)
|
||||
fun clearSelection() = tracker.clearSelection()
|
||||
fun saveSelection(state: Bundle) = tracker.onSaveInstanceState(state)
|
||||
fun restoreSelection(state: Bundle?) = tracker.onRestoreInstanceState(state)
|
||||
fun getAndClearSelection() = getItems().run { tracker.selection.map { gid -> first { it.gid == gid } } }
|
||||
.also { clearSelection() }
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
/*
|
||||
* 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.ui.scene
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ClearAll
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.DismissDirection
|
||||
import androidx.compose.material3.DismissValue
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SwipeToDismiss
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDismissState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.hippo.ehviewer.EhDB
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings.listThumbSize
|
||||
import com.hippo.ehviewer.ui.doGalleryInfoAction
|
||||
import com.hippo.ehviewer.ui.main.GalleryInfoListItem
|
||||
import com.hippo.ehviewer.ui.setMD3Content
|
||||
import com.hippo.ehviewer.ui.tools.CrystalCard
|
||||
import com.hippo.ehviewer.ui.tools.Deferred
|
||||
import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
|
||||
import com.hippo.ehviewer.ui.tools.rememberDialogState
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.system.pxToDp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class HistoryScene : BaseScene() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = ComposeView(inflater.context).apply {
|
||||
setMD3Content {
|
||||
val dialogState = rememberDialogState()
|
||||
dialogState.Intercept()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val historyData = remember { Pager(PagingConfig(20, jumpThreshold = 40)) { EhDB.historyLazyList }.flow.cachedIn(lifecycleScope) }.collectAsLazyPagingItems()
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(id = R.string.history)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { toggleDrawer(GravityCompat.START) }) {
|
||||
Icon(imageVector = Icons.Default.Menu, contentDescription = null)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
coroutineScope.launchIO {
|
||||
dialogState.awaitPermissionOrCancel(
|
||||
confirmText = R.string.clear_all,
|
||||
dismissText = android.R.string.cancel,
|
||||
text = { Text(text = stringResource(id = R.string.clear_all_history)) },
|
||||
)
|
||||
EhDB.clearHistoryInfo()
|
||||
}
|
||||
}) {
|
||||
Icon(imageVector = Icons.Default.ClearAll, contentDescription = null)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
val cardHeight = remember { (3 * listThumbSize * 3).pxToDp.dp }
|
||||
FastScrollLazyColumn(
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.gallery_list_margin_h)),
|
||||
contentPadding = paddingValues,
|
||||
verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)),
|
||||
) {
|
||||
items(
|
||||
count = historyData.itemCount,
|
||||
key = historyData.itemKey(key = { item -> item.gid }),
|
||||
contentType = historyData.itemContentType(),
|
||||
) { index ->
|
||||
val info = historyData[index]
|
||||
if (info != null) {
|
||||
val dismissState = rememberDismissState(
|
||||
confirmValueChange = {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
coroutineScope.launchIO {
|
||||
EhDB.deleteHistoryInfo(info)
|
||||
}
|
||||
}
|
||||
true
|
||||
},
|
||||
)
|
||||
SwipeToDismiss(
|
||||
state = dismissState,
|
||||
background = {},
|
||||
dismissContent = {
|
||||
// TODO: item delete & add animation
|
||||
// Bug tracker: https://issuetracker.google.com/issues/150812265
|
||||
GalleryInfoListItem(
|
||||
onClick = {
|
||||
navAnimated(
|
||||
R.id.galleryDetailScene,
|
||||
bundleOf(
|
||||
GalleryDetailScene.KEY_ACTION to GalleryDetailScene.ACTION_GALLERY_INFO,
|
||||
GalleryDetailScene.KEY_GALLERY_INFO to info,
|
||||
),
|
||||
)
|
||||
},
|
||||
onLongClick = {
|
||||
coroutineScope.launchIO {
|
||||
dialogState.doGalleryInfoAction(info, context)
|
||||
}
|
||||
},
|
||||
info = info,
|
||||
modifier = Modifier.height(cardHeight),
|
||||
)
|
||||
},
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
)
|
||||
} else {
|
||||
CrystalCard(modifier = Modifier.height(cardHeight).fillMaxWidth()) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Deferred({ delay(200) }) {
|
||||
if (historyData.itemCount == 0) {
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues).fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.big_history),
|
||||
contentDescription = null,
|
||||
Modifier.padding(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.no_history),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.scene
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryListUrlParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageUrlParser
|
||||
|
||||
@MainThread
|
||||
@SuppressLint("PrivateResource")
|
||||
fun NavController.navAnimated(id: Int, args: Bundle?, singleTop: Boolean = false) {
|
||||
val options: NavOptions = NavOptions.Builder().setLaunchSingleTop(singleTop)
|
||||
.setEnterAnim(androidx.fragment.R.animator.fragment_open_enter)
|
||||
.setExitAnim(androidx.fragment.R.animator.fragment_open_exit)
|
||||
.setPopEnterAnim(androidx.fragment.R.animator.fragment_close_enter)
|
||||
.setPopExitAnim(androidx.fragment.R.animator.fragment_close_exit)
|
||||
.build()
|
||||
navigate(id, args, options)
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun NavController.navWithUrl(url: String): Boolean {
|
||||
if (url.isEmpty()) return false
|
||||
GalleryListUrlParser.parse(url)?.let {
|
||||
navAnimated(
|
||||
R.id.galleryListScene,
|
||||
bundleOf(
|
||||
GalleryListScene.KEY_ACTION to GalleryListScene.ACTION_LIST_URL_BUILDER,
|
||||
GalleryListScene.KEY_LIST_URL_BUILDER to it,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
GalleryDetailUrlParser.parse(url)?.apply {
|
||||
navAnimated(
|
||||
R.id.galleryDetailScene,
|
||||
bundleOf(
|
||||
GalleryDetailScene.KEY_ACTION to GalleryDetailScene.ACTION_GID_TOKEN,
|
||||
GalleryDetailScene.KEY_GID to gid,
|
||||
GalleryDetailScene.KEY_TOKEN to token,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
GalleryPageUrlParser.parse(url)?.apply {
|
||||
navAnimated(
|
||||
R.id.progressScene,
|
||||
bundleOf(
|
||||
ProgressScene.KEY_ACTION to ProgressScene.ACTION_GALLERY_TOKEN,
|
||||
ProgressScene.KEY_GID to gid,
|
||||
ProgressScene.KEY_PTOKEN to pToken,
|
||||
ProgressScene.KEY_PAGE to page,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun Fragment.navAnimated(id: Int, args: Bundle?, singleTop: Boolean = false) = findNavController().navAnimated(id, args, singleTop)
|
||||
@@ -1,107 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.scene
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.platform.ComposeView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.ui.setMD3Content
|
||||
import com.hippo.ehviewer.util.ExceptionUtils
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
|
||||
class ProgressScene : BaseScene() {
|
||||
override val showLeftDrawer = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return ComposeView(inflater.context).apply {
|
||||
setMD3Content {
|
||||
val action = rememberSaveable { requireArguments().getString(KEY_ACTION, INVALID) }
|
||||
val gid = rememberSaveable { requireArguments().getLong(KEY_GID, -1) }
|
||||
val token = rememberSaveable { requireArguments().getString(KEY_PTOKEN, INVALID) }
|
||||
val page = rememberSaveable { requireArguments().getInt(KEY_PAGE, -1) }
|
||||
val wrong = stringResource(id = R.string.error_something_wrong_happened)
|
||||
var error by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(error) {
|
||||
if (error.isEmpty()) {
|
||||
if (action != ACTION_GALLERY_TOKEN || gid == -1L || token == INVALID || page == -1) {
|
||||
error = wrong
|
||||
} else {
|
||||
runSuspendCatching {
|
||||
EhEngine.getGalleryToken(gid, token, page)
|
||||
}.onSuccess {
|
||||
withUIContext {
|
||||
findNavController().popBackStack()
|
||||
navAnimated(
|
||||
R.id.galleryDetailScene,
|
||||
bundleOf(
|
||||
GalleryDetailScene.KEY_ACTION to GalleryDetailScene.ACTION_GID_TOKEN,
|
||||
GalleryDetailScene.KEY_GID to gid,
|
||||
GalleryDetailScene.KEY_TOKEN to it,
|
||||
GalleryDetailScene.KEY_PAGE to page,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.onFailure {
|
||||
error = ExceptionUtils.getReadableString(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (error.isNotBlank()) {
|
||||
Column(
|
||||
modifier = Modifier.clickable { error = "" },
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.big_sad_pandroid),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = wrong,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_ACTION = "action"
|
||||
const val ACTION_GALLERY_TOKEN = "gallery_token"
|
||||
const val KEY_GID = "gid"
|
||||
const val KEY_PTOKEN = "ptoken"
|
||||
const val KEY_PAGE = "page"
|
||||
const val INVALID = "invalid"
|
||||
}
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.scene
|
||||
|
||||
import android.animation.Animator
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.MenuBook
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.drawerlayout.widget.DrawerLayout
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.search.SearchView
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.hippo.ehviewer.EhApplication.Companion.searchDatabase
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.dao.Search
|
||||
import com.hippo.ehviewer.dao.SearchDao
|
||||
import com.hippo.ehviewer.databinding.SceneSearchbarBinding
|
||||
import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder
|
||||
import com.hippo.ehviewer.ui.legacy.FabLayout
|
||||
import com.hippo.ehviewer.ui.setMD3Content
|
||||
import com.hippo.ehviewer.util.AnimationUtils
|
||||
import com.hippo.ehviewer.util.SimpleAnimatorListener
|
||||
import com.jamal.composeprefs3.ui.ifNotNullThen
|
||||
import com.jamal.composeprefs3.ui.ifTrueThen
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
|
||||
abstract class SearchBarScene : BaseScene(), ToolBarScene {
|
||||
private var _binding: SceneSearchbarBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var mSuggestionList by mutableStateOf(emptyList<Suggestion>())
|
||||
private var mSuggestionProvider: SuggestionProvider? = null
|
||||
var allowEmptySearch = true
|
||||
private val mSearchDatabase = searchDatabase.searchDao()
|
||||
private var onApplySearch: (String) -> Unit = {}
|
||||
protected val mSearchFab get() = binding.searchFab
|
||||
protected abstract val fabLayout: FabLayout
|
||||
protected var mShowActionFab = true
|
||||
protected val mActionFabAnimatorListener = object : SimpleAnimatorListener() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
fabLayout.primaryFab?.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
private val mSearchFabAnimatorListener = object : SimpleAnimatorListener() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
mSearchFab.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = SceneSearchbarBinding.inflate(inflater, container, false)
|
||||
binding.appbar.statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context)
|
||||
binding.searchview.editText.addTextChangedListener { updateSuggestions() }
|
||||
binding.searchview.editText.setOnEditorActionListener { _, _, _ ->
|
||||
onApplySearch()
|
||||
true
|
||||
}
|
||||
binding.searchBarList.consumeWindowInsets = false
|
||||
binding.searchBarList.setMD3Content {
|
||||
LazyColumn(contentPadding = WindowInsets.navigationBars.asPaddingValues()) {
|
||||
items(mSuggestionList) {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = it.keyword) },
|
||||
supportingContent = it.hint.ifNotNullThen { Text(text = it.hint!!) },
|
||||
leadingContent = it.canOpenDirectly.ifTrueThen {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MenuBook,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailingContent = it.canDelete.ifTrueThen {
|
||||
IconButton(onClick = { deleteKeyword(it.keyword) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.clickable { it.onClick() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.searchview.addTransitionListener { _, _, newState ->
|
||||
if (newState == SearchView.TransitionState.SHOWING) {
|
||||
onSearchViewExpanded()
|
||||
} else if (newState == SearchView.TransitionState.HIDING) {
|
||||
onSearchViewHidden()
|
||||
}
|
||||
}
|
||||
mSearchFab.setOnClickListener { onApplySearch() }
|
||||
onCreateViewWithToolbar(inflater, binding.root, savedInstanceState)
|
||||
// This has to be placed after onCreateViewWithToolbar() since
|
||||
// callbacks are invoked in the reverse order in which they are added
|
||||
binding.searchview.addTransitionListener(mSearchViewOnBackPressedCallback)
|
||||
requireActivity().onBackPressedDispatcher.addCallback(mSearchViewOnBackPressedCallback)
|
||||
binding.appbar.bringToFront()
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.toolbar.apply {
|
||||
val menuResId = getMenuResId()
|
||||
if (menuResId != 0) {
|
||||
inflateMenu(menuResId)
|
||||
setOnMenuItemClickListener { item: MenuItem -> onMenuItemClick(item) }
|
||||
}
|
||||
setNavigationOnClickListener { onNavigationClick() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
mSearchViewOnBackPressedCallback.remove()
|
||||
binding.root.removeAllViews()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private var privLockModeStart: Int? = null
|
||||
|
||||
@CallSuper
|
||||
open fun onSearchViewExpanded() {
|
||||
privLockModeStart = getDrawerLockMode(GravityCompat.START)
|
||||
privLockModeStart?.let {
|
||||
setDrawerLockMode(
|
||||
DrawerLayout.LOCK_MODE_LOCKED_CLOSED,
|
||||
GravityCompat.START,
|
||||
)
|
||||
}
|
||||
updateSuggestions()
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun onSearchViewHidden() {
|
||||
binding.toolbar.setText(binding.searchview.text)
|
||||
privLockModeStart?.let { setDrawerLockMode(it, GravityCompat.START) }
|
||||
privLockModeStart = null
|
||||
}
|
||||
|
||||
fun showSearchFab(animation: Boolean, delay: Long = 0) {
|
||||
if (animation) {
|
||||
mSearchFab.visibility = View.VISIBLE
|
||||
mSearchFab.animate().scaleX(1.0f).scaleY(1.0f).setListener(null)
|
||||
.setDuration(ANIMATE_TIME).setStartDelay(delay)
|
||||
.setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR).start()
|
||||
} else {
|
||||
mSearchFab.visibility = View.VISIBLE
|
||||
mSearchFab.scaleX = 1.0f
|
||||
mSearchFab.scaleY = 1.0f
|
||||
}
|
||||
}
|
||||
|
||||
fun hideSearchFab(animation: Boolean): Long {
|
||||
return if (View.INVISIBLE == mSearchFab.visibility) {
|
||||
0L
|
||||
} else {
|
||||
if (animation) {
|
||||
mSearchFab.animate().scaleX(0.0f).scaleY(0.0f)
|
||||
.setListener(mSearchFabAnimatorListener)
|
||||
.setDuration(ANIMATE_TIME).setStartDelay(0L)
|
||||
.setInterpolator(AnimationUtils.SLOW_FAST_INTERPOLATOR).start()
|
||||
} else {
|
||||
mSearchFab.visibility = View.INVISIBLE
|
||||
mSearchFab.scaleX = 0.0f
|
||||
mSearchFab.scaleY = 0.0f
|
||||
}
|
||||
ANIMATE_TIME
|
||||
}
|
||||
}
|
||||
|
||||
fun showActionFab(animation: Boolean, delay: Long = ANIMATE_TIME) {
|
||||
mShowActionFab = true
|
||||
if (animation) {
|
||||
val fab = fabLayout.primaryFab!!
|
||||
fab.visibility = View.VISIBLE
|
||||
fab.animate().scaleX(1.0f).scaleY(1.0f).setListener(null)
|
||||
.setDuration(ANIMATE_TIME).setStartDelay(delay)
|
||||
.setInterpolator(AnimationUtils.FAST_SLOW_INTERPOLATOR).start()
|
||||
} else {
|
||||
fabLayout.setExpanded(expanded = false, animation = false)
|
||||
val fab = fabLayout.primaryFab!!
|
||||
fab.visibility = View.VISIBLE
|
||||
fab.scaleX = 1.0f
|
||||
fab.scaleY = 1.0f
|
||||
}
|
||||
}
|
||||
|
||||
fun hideActionFab(animation: Boolean): Long {
|
||||
mShowActionFab = false
|
||||
return if (animation) {
|
||||
val fab = fabLayout.primaryFab!!
|
||||
if (View.INVISIBLE == fab.visibility) {
|
||||
0L
|
||||
} else {
|
||||
fabLayout.setExpanded(expanded = false, animation = true)
|
||||
fab.animate().scaleX(0.0f).scaleY(0.0f).setListener(mActionFabAnimatorListener)
|
||||
.setDuration(ANIMATE_TIME).setStartDelay(0L)
|
||||
.setInterpolator(AnimationUtils.SLOW_FAST_INTERPOLATOR).start()
|
||||
ANIMATE_TIME
|
||||
}
|
||||
} else {
|
||||
fabLayout.setExpanded(expanded = false, animation = false)
|
||||
val fab = fabLayout.primaryFab!!
|
||||
fab.visibility = View.INVISIBLE
|
||||
fab.scaleX = 0.0f
|
||||
fab.scaleY = 0.0f
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSearchFab(animation: Boolean) {
|
||||
val delay = hideActionFab(animation)
|
||||
showSearchFab(animation, delay)
|
||||
}
|
||||
|
||||
fun selectActionFab(animation: Boolean) {
|
||||
val delay = hideSearchFab(animation)
|
||||
showActionFab(animation, delay)
|
||||
}
|
||||
|
||||
fun setSearchBarHint(hint: String?) {
|
||||
binding.toolbar.hint = hint
|
||||
}
|
||||
|
||||
fun setSearchBarText(text: String?) {
|
||||
binding.toolbar.setText(text)
|
||||
binding.searchview.setText(text)
|
||||
}
|
||||
|
||||
fun setEditTextHint(hint: String?) {
|
||||
binding.searchview.editText.hint = hint
|
||||
}
|
||||
|
||||
override fun onNavigationClick() {
|
||||
toggleDrawer(GravityCompat.START)
|
||||
}
|
||||
|
||||
override fun getMenuResId(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
return if (item.itemId == R.id.action_open_side_sheet) {
|
||||
openSideSheet()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun setLiftOnScrollTargetView(view: View?) {
|
||||
binding.appbar.setLiftOnScrollTargetView(view)
|
||||
}
|
||||
|
||||
fun setOnApplySearch(lambda: (String) -> Unit) {
|
||||
onApplySearch = lambda
|
||||
}
|
||||
|
||||
private suspend fun addQuery(query: String) {
|
||||
mSearchDatabase.deleteQuery(query)
|
||||
if (query.isBlank()) return
|
||||
val search = Search(System.currentTimeMillis(), query)
|
||||
mSearchDatabase.insert(search)
|
||||
}
|
||||
|
||||
fun onApplySearch() {
|
||||
binding.toolbar.setText(binding.searchview.text)
|
||||
binding.searchview.hide()
|
||||
val query = binding.toolbar.text.toString().trim()
|
||||
if (!allowEmptySearch && query.isEmpty()) return
|
||||
lifecycleScope.launchIO { addQuery(query) }
|
||||
onApplySearch(query)
|
||||
}
|
||||
|
||||
fun interface SuggestionProvider {
|
||||
fun providerSuggestions(text: String): Suggestion?
|
||||
}
|
||||
|
||||
private fun deleteKeyword(keyword: String) {
|
||||
BaseDialogBuilder(requireContext())
|
||||
.setMessage(requireContext().getString(R.string.delete_search_history, keyword))
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.delete) { _, _ ->
|
||||
lifecycleScope.launchIO {
|
||||
mSearchDatabase.deleteQuery(keyword)
|
||||
updateSuggestions()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
abstract class Suggestion {
|
||||
abstract val keyword: String
|
||||
open val hint: String? = null
|
||||
abstract fun onClick()
|
||||
open val canDelete: Boolean = false
|
||||
open val canOpenDirectly: Boolean = false
|
||||
}
|
||||
|
||||
inner class TagSuggestion(
|
||||
override val hint: String?,
|
||||
override val keyword: String,
|
||||
) : Suggestion() {
|
||||
override fun onClick() {
|
||||
binding.searchview.editText.let {
|
||||
var keywords = it.text.toString().substringBeforeLast(' ', "")
|
||||
if (keywords.isNotEmpty()) keywords += ' '
|
||||
keywords += wrapTagKeyword(keyword)
|
||||
if (!keywords.endsWith(':')) keywords += ' '
|
||||
it.setText(keywords)
|
||||
it.setSelection(keywords.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class KeywordSuggestion(
|
||||
override val keyword: String,
|
||||
) : Suggestion() {
|
||||
override val canDelete = true
|
||||
override fun onClick() {
|
||||
binding.searchview.editText.run {
|
||||
setText(keyword)
|
||||
setSelection(length())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSuggestions() {
|
||||
_binding ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launchIO {
|
||||
mSuggestionList = mergedSuggestionFlow().toList()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun SearchDao.suggestions(prefix: String, limit: Int) = (if (prefix.isBlank()) list(limit) else rawSuggestions(prefix, limit)).map { it.query }
|
||||
|
||||
private fun mergedSuggestionFlow(): Flow<Suggestion> = flow {
|
||||
binding.searchview.editText.text?.toString()?.let { text ->
|
||||
mSuggestionProvider?.run { providerSuggestions(text)?.let { emit(it) } }
|
||||
mSearchDatabase.suggestions(text, 128).forEach { emit(KeywordSuggestion(it)) }
|
||||
EhTagDatabase.takeIf { it.initialized }?.run {
|
||||
if (text.isNotEmpty() && !text.endsWith(' ')) {
|
||||
val keyword = text.substringAfterLast(' ')
|
||||
val translate = Settings.showTagTranslations && isTranslatable(requireContext())
|
||||
suggestFlow(keyword, translate, true).collect {
|
||||
emit(TagSuggestion(it.first, it.second))
|
||||
}
|
||||
suggestFlow(keyword, translate).collect {
|
||||
emit(TagSuggestion(it.first, it.second))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setSuggestionProvider(suggestionProvider: SuggestionProvider) {
|
||||
mSuggestionProvider = suggestionProvider
|
||||
}
|
||||
|
||||
fun showSearchBar() {
|
||||
_binding ?: return
|
||||
binding.appbar.setExpanded(true)
|
||||
}
|
||||
|
||||
private val mSearchViewOnBackPressedCallback =
|
||||
object : OnBackPressedCallback(false), SearchView.TransitionListener {
|
||||
override fun handleOnBackPressed() {
|
||||
binding.searchview.hide()
|
||||
}
|
||||
|
||||
override fun onStateChanged(
|
||||
searchView: SearchView,
|
||||
previousState: SearchView.TransitionState,
|
||||
newState: SearchView.TransitionState,
|
||||
) {
|
||||
if (newState == SearchView.TransitionState.SHOWING) {
|
||||
isEnabled = true
|
||||
} else if (newState == SearchView.TransitionState.HIDING) {
|
||||
isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun wrapTagKeyword(keyword: String, translate: Boolean = false): String {
|
||||
return if (keyword.endsWith(':')) {
|
||||
keyword
|
||||
} else {
|
||||
val tag = keyword.substringAfter(':')
|
||||
val prefix = keyword.dropLast(tag.length + 1)
|
||||
if (translate) {
|
||||
val namespacePrefix = EhTagDatabase.namespaceToPrefix(prefix)
|
||||
val newPrefix = EhTagDatabase.getTranslation(tag = prefix) ?: prefix
|
||||
val newTag = EhTagDatabase.getTranslation(namespacePrefix, tag) ?: tag
|
||||
"$newPrefix:$newTag"
|
||||
} else if (keyword.contains(' ')) {
|
||||
"$prefix:\"$tag$\""
|
||||
} else {
|
||||
"$keyword$"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val ANIMATE_TIME = 300L
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.scene
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
interface ToolBarScene {
|
||||
fun onCreateViewWithToolbar(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View
|
||||
|
||||
fun onNavigationClick()
|
||||
|
||||
fun getMenuResId(): Int
|
||||
|
||||
fun onMenuItemClick(item: MenuItem): Boolean
|
||||
|
||||
fun setLiftOnScrollTargetView(view: View?)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sin
|
||||
|
||||
private const val FullCircle: Float = (PI * 2).toFloat()
|
||||
private const val QuarterCircle = PI / 2
|
||||
|
||||
@Composable
|
||||
fun CircularLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
placeFirstItemInCenter: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Layout(
|
||||
modifier = modifier,
|
||||
content = content,
|
||||
) { measurables, constraints ->
|
||||
val radiusPx = constraints.maxWidth * 0.4
|
||||
val itemConstraints = constraints.copy(minWidth = 0, minHeight = 0)
|
||||
val placeables = measurables.map { measurable -> measurable.measure(itemConstraints) }
|
||||
val outPlaceables = if (placeFirstItemInCenter) placeables.drop(1) else placeables
|
||||
val theta = FullCircle / (outPlaceables.count())
|
||||
|
||||
layout(
|
||||
width = constraints.minWidth,
|
||||
height = constraints.minHeight,
|
||||
) {
|
||||
if (placeFirstItemInCenter) {
|
||||
placeables[0].let {
|
||||
val centerOffsetX = constraints.maxWidth / 2 - it.width / 2
|
||||
val centerOffsetY = constraints.maxHeight / 2 - it.height / 2
|
||||
it.place(
|
||||
x = centerOffsetX,
|
||||
y = centerOffsetY,
|
||||
)
|
||||
}
|
||||
}
|
||||
outPlaceables.forEachIndexed { i, it ->
|
||||
val centerOffsetX = constraints.maxWidth / 2 - it.width / 2
|
||||
val centerOffsetY = constraints.maxHeight / 2 - it.height / 2
|
||||
val offsetX = radiusPx * cos(theta * i - QuarterCircle) + centerOffsetX
|
||||
val offsetY = radiusPx * sin(theta * i - QuarterCircle) + centerOffsetY
|
||||
it.place(
|
||||
x = offsetX.roundToInt(),
|
||||
y = offsetY.roundToInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AlertDialogDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.ShapeDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.jamal.composeprefs3.ui.ifNotNullThen
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
interface DialogScope<R> {
|
||||
var expectedValue: R
|
||||
}
|
||||
|
||||
fun interface DismissDialogScope<R> {
|
||||
fun dismissWith(value: R)
|
||||
}
|
||||
|
||||
class DialogState {
|
||||
var content: (@Composable () -> Unit)? by mutableStateOf(null)
|
||||
|
||||
@Composable
|
||||
fun Intercept() = content?.invoke()
|
||||
|
||||
fun dismiss() {
|
||||
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 fun <R> awaitResult(initial: R, @StringRes title: Int? = null, block: @Composable DialogScope<R>.() -> Unit): R {
|
||||
return dialog { cont ->
|
||||
val state = remember(cont) { mutableStateOf(initial) }
|
||||
val impl = remember(cont) { object : DialogScope<R> { override var expectedValue by state } }
|
||||
AlertDialog(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { cont.resume(state.value) }) {
|
||||
Text(text = stringResource(id = android.R.string.ok))
|
||||
}
|
||||
},
|
||||
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
|
||||
text = { block(impl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitInputText(
|
||||
initial: String = "",
|
||||
title: String? = null,
|
||||
hint: String? = null,
|
||||
isNumber: Boolean = false,
|
||||
invalidator: ((String) -> String?)? = null,
|
||||
): String {
|
||||
return dialog { cont ->
|
||||
var state by remember(cont) { mutableStateOf(initial) }
|
||||
var error by remember(cont) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
error = invalidator?.invoke(state)
|
||||
error ?: cont.resume(state)
|
||||
}) {
|
||||
Text(text = stringResource(id = android.R.string.ok))
|
||||
}
|
||||
},
|
||||
title = title.ifNotNullThen { Text(text = title!!) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = state,
|
||||
onValueChange = { state = it },
|
||||
label = hint.ifNotNullThen {
|
||||
Text(text = hint!!)
|
||||
},
|
||||
trailingIcon = error.ifNotNullThen {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
supportingText = error.ifNotNullThen {
|
||||
Text(text = error!!)
|
||||
},
|
||||
isError = error != null,
|
||||
keyboardOptions = if (isNumber) KeyboardOptions(keyboardType = KeyboardType.Number) else KeyboardOptions.Default,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun awaitPermissionOrCancel(
|
||||
@StringRes confirmText: Int? = null,
|
||||
@StringRes dismissText: Int? = null,
|
||||
@StringRes title: Int? = null,
|
||||
onDismiss: () -> Unit = {},
|
||||
text: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
return dialog { cont ->
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
onDismiss()
|
||||
cont.cancel()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { cont.resume(Unit) }) {
|
||||
Text(text = stringResource(id = confirmText ?: android.R.string.ok))
|
||||
}
|
||||
},
|
||||
dismissButton = dismissText.ifNotNullThen {
|
||||
TextButton(onClick = {
|
||||
onDismiss()
|
||||
cont.cancel()
|
||||
}) {
|
||||
Text(text = stringResource(id = dismissText!!))
|
||||
}
|
||||
},
|
||||
title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) },
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <R> showNoButton(respectDefaultWidth: Boolean = true, block: @Composable DismissDialogScope<R>.() -> Unit): R {
|
||||
return dialog { cont ->
|
||||
val impl = remember(cont) {
|
||||
DismissDialogScope<R> {
|
||||
cont.resume(it)
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { cont.cancel() },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = respectDefaultWidth),
|
||||
content = {
|
||||
Surface(
|
||||
modifier = with(Modifier) { if (!respectDefaultWidth) defaultMinSize(280.dp) else width(280.dp) },
|
||||
shape = AlertDialogDefaults.shape,
|
||||
color = AlertDialogDefaults.containerColor,
|
||||
tonalElevation = AlertDialogDefaults.TonalElevation,
|
||||
content = { block(impl) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showSelectItem(
|
||||
vararg items: String,
|
||||
@StringRes title: Int,
|
||||
): Int = showNoButton {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showSelectItemWithIcon(
|
||||
vararg items: Pair<ImageVector, Int>,
|
||||
title: String,
|
||||
): Int = showNoButton {
|
||||
LazyColumn {
|
||||
stickyHeader {
|
||||
Text(text = title, modifier = Modifier.padding(horizontal = 16.dp).padding(top = 16.dp), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
itemsIndexed(items) { index, (icon, text) ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = text), style = MaterialTheme.typography.titleMedium)
|
||||
},
|
||||
modifier = Modifier.clickable { dismissWith(index) },
|
||||
leadingContent = {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = AlertDialogDefaults.iconContentColor)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showSelectItemWithIconAndTextField(
|
||||
vararg items: Pair<ImageVector, String>,
|
||||
@StringRes title: Int,
|
||||
@StringRes hint: Int,
|
||||
maxChar: Int,
|
||||
): Pair<Int, String> = showNoButton(false) {
|
||||
Column {
|
||||
Text(text = stringResource(id = title), modifier = Modifier.padding(horizontal = 16.dp).padding(top = 16.dp), style = MaterialTheme.typography.titleMedium)
|
||||
CircularLayout(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1F),
|
||||
placeFirstItemInCenter = true,
|
||||
) {
|
||||
var note by remember { mutableStateOf("") }
|
||||
TextField(
|
||||
value = note,
|
||||
onValueChange = { note = it },
|
||||
modifier = Modifier.fillMaxWidth(0.45F).aspectRatio(1F),
|
||||
label = { Text(text = stringResource(id = hint)) },
|
||||
trailingIcon = {
|
||||
if (note.isNotEmpty()) {
|
||||
IconButton(onClick = { note = "" }) {
|
||||
Icon(imageVector = Icons.Default.Close, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
supportingText = {
|
||||
Text(
|
||||
text = "${note.toByteArray().size} / $maxChar",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
},
|
||||
shape = ShapeDefaults.ExtraSmall,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
)
|
||||
items.forEachIndexed { index, (icon, text) ->
|
||||
Column(
|
||||
modifier = Modifier.clip(IconWithTextCorner).clickable { dismissWith(index to note) }
|
||||
.fillMaxWidth(0.2F),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = null, tint = AlertDialogDefaults.iconContentColor)
|
||||
Text(
|
||||
text = text,
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val IconWithTextCorner = RoundedCornerShape(8.dp)
|
||||
|
||||
@Composable
|
||||
fun rememberDialogState(): DialogState {
|
||||
return remember { DialogState() }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import android.text.Spanned
|
||||
import android.text.style.StrikethroughSpan
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
|
||||
fun Spanned.toAnnotatedString(): AnnotatedString = buildAnnotatedString {
|
||||
val spanned = this@toAnnotatedString
|
||||
append(spanned.toString())
|
||||
getSpans(0, spanned.length, Any::class.java).forEach { span ->
|
||||
val start = getSpanStart(span)
|
||||
val end = getSpanEnd(span)
|
||||
when (span) {
|
||||
is StrikethroughSpan -> addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package com.hippo.ehviewer.ui.tools
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
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.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
|
||||
@Composable
|
||||
fun TimePickerDialog(
|
||||
title: String,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier.width(IntrinsicSize.Min).height(IntrinsicSize.Min).background(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
content()
|
||||
Row(modifier = Modifier.height(40.dp).fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(stringResource(id = android.R.string.cancel))
|
||||
}
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(id = android.R.string.ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import android.view.animation.Interpolator
|
||||
import androidx.interpolator.view.animation.FastOutLinearInInterpolator
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
|
||||
|
||||
object AnimationUtils {
|
||||
val FAST_SLOW_INTERPOLATOR: Interpolator = LinearOutSlowInInterpolator()
|
||||
val SLOW_FAST_INTERPOLATOR: Interpolator = FastOutLinearInInterpolator()
|
||||
val SLOW_FAST_SLOW_INTERPOLATOR: Interpolator = FastOutSlowInInterpolator()
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.Spanned
|
||||
import android.text.style.CharacterStyle
|
||||
import android.text.style.ImageSpan
|
||||
import android.text.style.StrikethroughSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.text.style.URLSpan
|
||||
import android.text.style.UnderlineSpan
|
||||
|
||||
fun Spanned.toBBCode(): String {
|
||||
val text = this
|
||||
tailrec fun StringBuilder.processOneSpanTransition(cur: Int = 0) {
|
||||
val next = nextSpanTransition(cur, text.length, CharacterStyle::class.java)
|
||||
getSpans(cur, next, CharacterStyle::class.java).forEach {
|
||||
when (it) {
|
||||
is StyleSpan -> {
|
||||
val s = it.style
|
||||
if (s and Typeface.BOLD != 0) {
|
||||
append("[b]")
|
||||
}
|
||||
if (s and Typeface.ITALIC != 0) {
|
||||
append("[i]")
|
||||
}
|
||||
}
|
||||
|
||||
is UnderlineSpan -> append("[u]")
|
||||
is StrikethroughSpan -> append("[s]")
|
||||
is URLSpan -> {
|
||||
append("[url=")
|
||||
append(it.url)
|
||||
append("]")
|
||||
}
|
||||
|
||||
is ImageSpan -> {
|
||||
append("[img]")
|
||||
append(it.source)
|
||||
append("[/img]")
|
||||
}
|
||||
}
|
||||
}
|
||||
append(text.subSequence(cur, next))
|
||||
getSpans(cur, next, CharacterStyle::class.java).reversed().forEach {
|
||||
when (it) {
|
||||
is StyleSpan -> {
|
||||
val s = it.style
|
||||
if (s and Typeface.BOLD != 0) {
|
||||
append("[/b]")
|
||||
}
|
||||
if (s and Typeface.ITALIC != 0) {
|
||||
append("[/i]")
|
||||
}
|
||||
}
|
||||
|
||||
is UnderlineSpan -> append("[/u]")
|
||||
is StrikethroughSpan -> append("[/s]")
|
||||
is URLSpan -> append("[/url]")
|
||||
}
|
||||
}
|
||||
if (next < text.length) processOneSpanTransition(next)
|
||||
}
|
||||
return StringBuilder().apply {
|
||||
processOneSpanTransition()
|
||||
}.toString()
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import java.io.File
|
||||
|
||||
fun ResponseBody.copyToFile(file: File) {
|
||||
file.outputStream().use { os ->
|
||||
source().use {
|
||||
os.channel.transferFrom(it, 0, Long.MAX_VALUE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class IntList(private val delegate: MutableList<Int> = mutableListOf()) :
|
||||
Parcelable, MutableList<Int> by delegate
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright 2015 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.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.TypedValue.COMPLEX_UNIT_DIP
|
||||
import android.util.TypedValue.applyDimension
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun dp2px(context: Context, dp: Float): Int {
|
||||
return applyDimension(COMPLEX_UNIT_DIP, dp, context.resources.displayMetrics).roundToInt()
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class LongList(private val delegate: MutableList<Long> = mutableListOf()) :
|
||||
Parcelable, MutableList<Long> by delegate
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.hippo.ehviewer.util
|
||||
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
|
||||
val isAtLeastQ = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
val isAtLeastR = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
val isAtLeastSExtension7 = isAtLeastR && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* 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.util
|
||||
|
||||
import android.animation.Animator
|
||||
|
||||
abstract class SimpleAnimatorListener : Animator.AnimatorListener {
|
||||
override fun onAnimationStart(animation: Animator) {}
|
||||
override fun onAnimationEnd(animation: Animator) {}
|
||||
override fun onAnimationCancel(animation: Animator) {}
|
||||
override fun onAnimationRepeat(animation: Animator) {}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.reader
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun PageIndicatorText(
|
||||
currentPage: Int,
|
||||
totalPages: Int,
|
||||
) {
|
||||
if (currentPage <= 0 || totalPages <= 0) return
|
||||
|
||||
val text = "$currentPage / $totalPages"
|
||||
|
||||
Box {
|
||||
Text(
|
||||
text = text,
|
||||
color = Color(45, 45, 45),
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
style = TextStyle.Default.copy(
|
||||
drawStyle = Stroke(width = 4f),
|
||||
),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
color = Color(235, 235, 235),
|
||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package eu.kanade.tachiyomi.util.system
|
||||
|
||||
import android.app.Activity
|
||||
|
||||
/**
|
||||
* Checks whether if the device has a display cutout (i.e. notch, camera cutout, etc.).
|
||||
*
|
||||
* Only works in Android 9+.
|
||||
*/
|
||||
fun Activity.hasDisplayCutout(): Boolean {
|
||||
return window.decorView.rootWindowInsets?.displayCutout != null
|
||||
}
|
||||
@@ -0,0 +1,989 @@
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.OnBackPressedDispatcherOwner
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.AnchoredDraggableState
|
||||
import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredDraggable
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.snapTo
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionContext
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCompositionContext
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
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.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.AbstractComposeView
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.platform.ViewRootForInspector
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.popup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.window.SecureFlagPolicy
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.findViewTreeViewModelStoreOwner
|
||||
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
|
||||
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
|
||||
import com.hippo.ehviewer.util.isAtLeastT
|
||||
import java.util.UUID
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
internal object AnchoredDraggableDefaults2 {
|
||||
/**
|
||||
* The default animation used by [AnchoredDraggableState].
|
||||
*/
|
||||
@get:ExperimentalMaterial3Api
|
||||
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
|
||||
@ExperimentalMaterial3Api
|
||||
val AnimationSpec = SpringSpec<Float>()
|
||||
}
|
||||
|
||||
/**
|
||||
* State of a sheet composable, such as [ModalBottomSheetFix]
|
||||
*
|
||||
* Contains states relating to its swipe position as well as animations between state values.
|
||||
*
|
||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
|
||||
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
|
||||
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
|
||||
* interaction.
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
|
||||
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
|
||||
* programmatically or by user interaction.
|
||||
*/
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
class SheetState2
|
||||
@Deprecated(
|
||||
message = "This constructor is deprecated. " +
|
||||
"Please use the constructor that provides a [Density]",
|
||||
replaceWith = ReplaceWith(
|
||||
"SheetState(" +
|
||||
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
|
||||
"confirmValueChange, skipHiddenState)",
|
||||
),
|
||||
)
|
||||
constructor(
|
||||
internal val skipPartiallyExpanded: Boolean,
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
val confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
internal val skipHiddenState: Boolean = false,
|
||||
) {
|
||||
|
||||
/**
|
||||
* State of a sheet composable, such as [ModalBottomSheetFix]
|
||||
*
|
||||
* Contains states relating to its swipe position as well as animations between state values.
|
||||
*
|
||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
|
||||
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
|
||||
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
|
||||
* interaction.
|
||||
* @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 skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
|
||||
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
|
||||
* programmatically or by user interaction.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Suppress("Deprecation")
|
||||
constructor(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
density: Density,
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
skipHiddenState: Boolean = false,
|
||||
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
|
||||
this.density = density
|
||||
}
|
||||
init {
|
||||
if (skipPartiallyExpanded) {
|
||||
require(initialValue != SheetValue.PartiallyExpanded) {
|
||||
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
|
||||
"is set to true."
|
||||
}
|
||||
}
|
||||
if (skipHiddenState) {
|
||||
require(initialValue != SheetValue.Hidden) {
|
||||
"The initial value must not be set to Hidden if skipHiddenState is set to true."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current value of the state.
|
||||
*
|
||||
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
|
||||
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
|
||||
* was in before the swipe or animation started.
|
||||
*/
|
||||
|
||||
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
|
||||
|
||||
/**
|
||||
* The target value of the bottom sheet state.
|
||||
*
|
||||
* If a swipe is in progress, this is the value that the sheet would animate to if the
|
||||
* swipe finishes. If an animation is running, this is the target value of that animation.
|
||||
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
|
||||
*/
|
||||
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* Whether the modal bottom sheet is visible.
|
||||
*/
|
||||
val isVisible: Boolean
|
||||
get() = anchoredDraggableState.currentValue != SheetValue.Hidden
|
||||
|
||||
/**
|
||||
* Require the current offset (in pixels) of the bottom sheet.
|
||||
*
|
||||
* The offset will be initialized during the first measurement phase of the provided sheet
|
||||
* content.
|
||||
*
|
||||
* These are the phases:
|
||||
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
|
||||
*
|
||||
* During the first composition, an [IllegalStateException] is thrown. In subsequent
|
||||
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
|
||||
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
|
||||
* frame, after layout.
|
||||
*
|
||||
* @throws IllegalStateException If the offset has not been initialized yet
|
||||
*/
|
||||
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
/**
|
||||
* Whether the sheet has an expanded state defined.
|
||||
*/
|
||||
|
||||
val hasExpandedState: Boolean
|
||||
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.Expanded)
|
||||
|
||||
/**
|
||||
* Whether the modal bottom sheet has a partially expanded state defined.
|
||||
*/
|
||||
val hasPartiallyExpandedState: Boolean
|
||||
get() = anchoredDraggableState.anchors.hasAnchorFor(SheetValue.PartiallyExpanded)
|
||||
|
||||
/**
|
||||
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
|
||||
* animation has been cancelled.
|
||||
* *
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun expand() {
|
||||
anchoredDraggableState.animateTo(SheetValue.Expanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
|
||||
* cancelled.
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
|
||||
*/
|
||||
suspend fun partialExpand() {
|
||||
check(!skipPartiallyExpanded) {
|
||||
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
|
||||
" skipPartiallyExpanded to false to use this function."
|
||||
}
|
||||
animateTo(SheetValue.PartiallyExpanded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
|
||||
* else [Expanded].
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun show() {
|
||||
val targetValue = when {
|
||||
hasPartiallyExpandedState -> SheetValue.PartiallyExpanded
|
||||
else -> SheetValue.Expanded
|
||||
}
|
||||
animateTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
|
||||
* been cancelled.
|
||||
* @throws [CancellationException] if the animation is interrupted
|
||||
*/
|
||||
suspend fun hide() {
|
||||
check(!skipHiddenState) {
|
||||
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
|
||||
" to false to use this function."
|
||||
}
|
||||
animateTo(SheetValue.Hidden)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate to a [targetValue].
|
||||
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
|
||||
* [targetValue] without updating the offset.
|
||||
*
|
||||
* @throws CancellationException if the interaction interrupted by another interaction like a
|
||||
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
||||
*
|
||||
* @param targetValue The target value of the animation
|
||||
*/
|
||||
internal suspend fun animateTo(
|
||||
targetValue: SheetValue,
|
||||
velocity: Float = anchoredDraggableState.lastVelocity,
|
||||
) {
|
||||
anchoredDraggableState.animateTo(targetValue, velocity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap to a [targetValue] without any animation.
|
||||
*
|
||||
* @throws CancellationException if the interaction interrupted by another interaction like a
|
||||
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
|
||||
*
|
||||
* @param targetValue The target value of the animation
|
||||
*/
|
||||
internal suspend fun snapTo(targetValue: SheetValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest anchor taking into account the velocity and settle at it with an animation.
|
||||
*/
|
||||
internal suspend fun settle(velocity: Float) {
|
||||
anchoredDraggableState.settle(velocity)
|
||||
}
|
||||
|
||||
internal var anchoredDraggableState = AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = AnchoredDraggableDefaults2.AnimationSpec,
|
||||
confirmValueChange = confirmValueChange,
|
||||
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
|
||||
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } },
|
||||
)
|
||||
|
||||
internal val offset: Float get() = anchoredDraggableState.offset
|
||||
|
||||
internal var density: Density? = null
|
||||
private fun requireDensity() = requireNotNull(density) {
|
||||
"SheetState did not have a density attached. Are you using SheetState with " +
|
||||
"BottomSheetScaffold or ModalBottomSheet component?"
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default [Saver] implementation for [SheetState2].
|
||||
*/
|
||||
fun Saver(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
confirmValueChange: (SheetValue) -> Boolean,
|
||||
density: Density,
|
||||
) = Saver<SheetState2, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
SheetState2(skipPartiallyExpanded, density, savedValue, confirmValueChange)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* The default [Saver] implementation for [SheetState2].
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "This function is deprecated. Please use the overload where Density is" +
|
||||
" provided.",
|
||||
replaceWith = ReplaceWith(
|
||||
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)",
|
||||
),
|
||||
)
|
||||
@Suppress("Deprecation")
|
||||
fun Saver(
|
||||
skipPartiallyExpanded: Boolean,
|
||||
confirmValueChange: (SheetValue) -> Boolean,
|
||||
) = Saver<SheetState2, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
SheetState2(skipPartiallyExpanded, savedValue, confirmValueChange)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <a href="https://m3.material.io/components/bottom-sheets/overview" class="external" target="_blank">Material Design modal bottom sheet</a>.
|
||||
*
|
||||
* Modal bottom sheets are used as an alternative to inline menus or simple dialogs on mobile,
|
||||
* especially when offering a long list of action items, or when items require longer descriptions
|
||||
* and icons. Like dialogs, modal bottom sheets appear in front of app content, disabling all other
|
||||
* app functionality when they appear, and remaining on screen until confirmed, dismissed, or a
|
||||
* required action has been taken.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* A simple example of a modal bottom sheet looks like this:
|
||||
*
|
||||
* @sample androidx.compose.material3.samples.ModalBottomSheetSample
|
||||
*
|
||||
* @param onDismissRequest Executes when the user clicks outside of the bottom sheet, after sheet
|
||||
* animates to [Hidden].
|
||||
* @param modifier Optional [Modifier] for the bottom sheet.
|
||||
* @param sheetState The state of the bottom sheet.
|
||||
* @param sheetMaxWidth [Dp] that defines what the maximum width the sheet will take.
|
||||
* Pass in [Dp.Unspecified] for a sheet that spans the entire screen width.
|
||||
* @param shape The shape of the bottom sheet.
|
||||
* @param containerColor The color used for the background of this bottom sheet
|
||||
* @param contentColor The preferred color for content inside this bottom sheet. Defaults to either
|
||||
* the matching content color for [containerColor], or to the current [LocalContentColor] if
|
||||
* [containerColor] is not a color from the theme.
|
||||
* @param tonalElevation The tonal elevation of this bottom sheet.
|
||||
* @param scrimColor Color of the scrim that obscures content when the bottom sheet is open.
|
||||
* @param dragHandle Optional visual marker to swipe the bottom sheet.
|
||||
* @param windowInsets window insets to be passed to the bottom sheet window via [PaddingValues]
|
||||
* params.
|
||||
* @param properties [ModalBottomSheetProperties2] for further customization of this
|
||||
* modal bottom sheet's behavior.
|
||||
* @param content The content to be displayed inside the bottom sheet.
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun ModalBottomSheetFix(
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sheetState: SheetState2 = rememberModalBottomSheetState(),
|
||||
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
|
||||
shape: Shape = BottomSheetDefaults.ExpandedShape,
|
||||
containerColor: Color = BottomSheetDefaults.ContainerColor,
|
||||
contentColor: Color = contentColorFor(containerColor),
|
||||
tonalElevation: Dp = BottomSheetDefaults.Elevation,
|
||||
scrimColor: Color = BottomSheetDefaults.ScrimColor,
|
||||
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||
windowInsets: WindowInsets = BottomSheetDefaults.windowInsets,
|
||||
properties: ModalBottomSheetProperties2 = ModalBottomSheetDefaults2.properties(),
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
// b/291735717 Remove this once deprecated methods without density are removed
|
||||
val density = LocalDensity.current
|
||||
SideEffect {
|
||||
sheetState.density = density
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
val animateToDismiss: () -> Unit = {
|
||||
if (sheetState.confirmValueChange(SheetValue.Hidden)) {
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||
if (!sheetState.isVisible) {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(sheetState) {
|
||||
snapshotFlow {
|
||||
sheetState.targetValue == SheetValue.Hidden && !sheetState.isVisible
|
||||
}.drop(1).collectLatest {
|
||||
if (it) onDismissRequest()
|
||||
}
|
||||
}
|
||||
val settleToDismiss: (velocity: Float) -> Unit = {
|
||||
scope.launch { sheetState.settle(it) }.invokeOnCompletion {
|
||||
if (!sheetState.isVisible) onDismissRequest()
|
||||
}
|
||||
}
|
||||
|
||||
val popupOnDismissRequest: () -> Unit = {
|
||||
if (sheetState.currentValue == SheetValue.Expanded && sheetState.hasPartiallyExpandedState) {
|
||||
scope.launch { sheetState.partialExpand() }
|
||||
} else { // Is expanded without collapsed state or is collapsed.
|
||||
scope.launch { sheetState.hide() }.invokeOnCompletion { onDismissRequest() }
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheetPopup(
|
||||
properties = properties,
|
||||
onDismissRequest = popupOnDismissRequest,
|
||||
windowInsets = windowInsets,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val widthDp = configuration.screenWidthDp.dp
|
||||
val height = configuration.screenHeightDp.dp
|
||||
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(true) {
|
||||
sheetState.hide()
|
||||
onDismissRequest()
|
||||
}
|
||||
val predictiveModifier = Modifier.graphicsLayer {
|
||||
val scale = lerp(1f, 0.90f, predictiveState.absoluteValue)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
val ofsY = lerp(0f, height.toPx() * 0.0625f, predictiveState.absoluteValue).roundToInt()
|
||||
val ofsX = lerp(0f, widthDp.toPx() * 0.025f, predictiveState).roundToInt()
|
||||
IntOffset(ofsX, ofsY)
|
||||
}
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val fullHeight = constraints.maxHeight
|
||||
Scrim(
|
||||
color = scrimColor,
|
||||
onDismissRequest = animateToDismiss,
|
||||
visible = sheetState.targetValue != SheetValue.Hidden,
|
||||
)
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.widthIn(max = sheetMaxWidth)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
.offset {
|
||||
IntOffset(
|
||||
0,
|
||||
sheetState
|
||||
.requireOffset()
|
||||
.toInt(),
|
||||
)
|
||||
} then predictiveModifier
|
||||
.nestedScroll(
|
||||
remember(sheetState) {
|
||||
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
||||
sheetState = sheetState,
|
||||
orientation = Orientation.Vertical,
|
||||
onFling = settleToDismiss,
|
||||
)
|
||||
},
|
||||
)
|
||||
.anchoredDraggable(
|
||||
state = sheetState.anchoredDraggableState,
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = sheetState.isVisible,
|
||||
startDragImmediately = sheetState.anchoredDraggableState.isAnimationRunning,
|
||||
)
|
||||
.modalBottomSheetAnchors(
|
||||
sheetState = sheetState,
|
||||
fullHeight = fullHeight.toFloat(),
|
||||
),
|
||||
shape = shape,
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
tonalElevation = tonalElevation,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (dragHandle != null) {
|
||||
Box(Modifier.align(Alignment.CenterHorizontally)) {
|
||||
dragHandle()
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sheetState.hasExpandedState) {
|
||||
LaunchedEffect(sheetState) {
|
||||
sheetState.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
|
||||
sheetState: SheetState2,
|
||||
orientation: Orientation,
|
||||
onFling: (velocity: Float) -> Unit,
|
||||
): NestedScrollConnection = object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.toFloat()
|
||||
return if (delta < 0 && source == NestedScrollSource.Drag) {
|
||||
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
return if (source == NestedScrollSource.Drag) {
|
||||
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val toFling = available.toFloat()
|
||||
val currentOffset = sheetState.requireOffset()
|
||||
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
|
||||
return if (toFling < 0 && currentOffset > minAnchor) {
|
||||
onFling(toFling)
|
||||
// since we go to the anchor with tween settling, consume all for the best UX
|
||||
available
|
||||
} else {
|
||||
Velocity.Zero
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
onFling(available.toFloat())
|
||||
return available
|
||||
}
|
||||
|
||||
private fun Float.toOffset(): Offset = Offset(
|
||||
x = if (orientation == Orientation.Horizontal) this else 0f,
|
||||
y = if (orientation == Orientation.Vertical) this else 0f,
|
||||
)
|
||||
|
||||
@JvmName("velocityToFloat")
|
||||
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
|
||||
|
||||
@JvmName("offsetToFloat")
|
||||
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties used to customize the behavior of a [ModalBottomSheetFix].
|
||||
*
|
||||
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
|
||||
* sheet's window.
|
||||
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
|
||||
* the modal bottom sheet will receive IME events and key presses, such as when
|
||||
* the back button is pressed.
|
||||
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
|
||||
* the back button. If true, pressing the back button will call onDismissRequest.
|
||||
* Note that [isFocusable] must be set to true in order to receive key events such as
|
||||
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
class ModalBottomSheetProperties2(
|
||||
val securePolicy: SecureFlagPolicy,
|
||||
val isFocusable: Boolean,
|
||||
val shouldDismissOnBackPress: Boolean,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is ModalBottomSheetProperties2) return false
|
||||
|
||||
if (securePolicy != other.securePolicy) return false
|
||||
if (isFocusable != other.isFocusable) return false
|
||||
if (shouldDismissOnBackPress != other.shouldDismissOnBackPress) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = securePolicy.hashCode()
|
||||
result = 31 * result + isFocusable.hashCode()
|
||||
result = 31 * result + shouldDismissOnBackPress.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for [ModalBottomSheetFix]
|
||||
*/
|
||||
@Immutable
|
||||
@ExperimentalMaterial3Api
|
||||
object ModalBottomSheetDefaults2 {
|
||||
/**
|
||||
* Properties used to customize the behavior of a [ModalBottomSheetFix].
|
||||
*
|
||||
* @param securePolicy Policy for setting [WindowManager.LayoutParams.FLAG_SECURE] on the bottom
|
||||
* sheet's window.
|
||||
* @param isFocusable Whether the modal bottom sheet is focusable. When true,
|
||||
* the modal bottom sheet will receive IME events and key presses, such as when
|
||||
* the back button is pressed.
|
||||
* @param shouldDismissOnBackPress Whether the modal bottom sheet can be dismissed by pressing
|
||||
* the back button. If true, pressing the back button will call onDismissRequest.
|
||||
* Note that [isFocusable] must be set to true in order to receive key events such as
|
||||
* the back button - if the modal bottom sheet is not focusable then this property does nothing.
|
||||
*/
|
||||
fun properties(
|
||||
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
|
||||
isFocusable: Boolean = true,
|
||||
shouldDismissOnBackPress: Boolean = true,
|
||||
) = ModalBottomSheetProperties2(securePolicy, isFocusable, shouldDismissOnBackPress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [SheetState2] for [ModalBottomSheetFix].
|
||||
*
|
||||
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is tall enough,
|
||||
* should be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
|
||||
* [Hidden] state when hiding the sheet, either programmatically or by user interaction.
|
||||
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
fun rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded: Boolean = false,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
) = rememberSheetState(skipPartiallyExpanded, confirmValueChange, SheetValue.Hidden)
|
||||
|
||||
@Composable
|
||||
@ExperimentalMaterial3Api
|
||||
internal fun rememberSheetState(
|
||||
skipPartiallyExpanded: Boolean = false,
|
||||
confirmValueChange: (SheetValue) -> Boolean = { true },
|
||||
initialValue: SheetValue = SheetValue.Hidden,
|
||||
skipHiddenState: Boolean = false,
|
||||
): SheetState2 {
|
||||
val density = LocalDensity.current
|
||||
return rememberSaveable(
|
||||
skipPartiallyExpanded,
|
||||
confirmValueChange,
|
||||
saver = SheetState2.Saver(
|
||||
skipPartiallyExpanded = skipPartiallyExpanded,
|
||||
confirmValueChange = confirmValueChange,
|
||||
density = density,
|
||||
),
|
||||
) {
|
||||
SheetState2(
|
||||
skipPartiallyExpanded,
|
||||
density,
|
||||
initialValue,
|
||||
confirmValueChange,
|
||||
skipHiddenState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Scrim(
|
||||
color: Color,
|
||||
onDismissRequest: () -> Unit,
|
||||
visible: Boolean,
|
||||
) {
|
||||
if (color.isSpecified) {
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (visible) 1f else 0f,
|
||||
animationSpec = TweenSpec(),
|
||||
label = "scrim",
|
||||
)
|
||||
val dismissSheet = if (visible) {
|
||||
Modifier
|
||||
.pointerInput(onDismissRequest) {
|
||||
detectTapGestures {
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics {}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Canvas(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.then(dismissSheet),
|
||||
) {
|
||||
drawRect(color = color, alpha = alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
private fun Modifier.modalBottomSheetAnchors(
|
||||
sheetState: SheetState2,
|
||||
fullHeight: Float,
|
||||
) = onSizeChanged { sheetSize ->
|
||||
|
||||
val newAnchors = DraggableAnchors {
|
||||
SheetValue.Hidden at fullHeight
|
||||
if (sheetSize.height > (fullHeight / 2) && !sheetState.skipPartiallyExpanded) {
|
||||
SheetValue.PartiallyExpanded at fullHeight / 2f
|
||||
}
|
||||
if (sheetSize.height != 0) {
|
||||
SheetValue.Expanded at max(0f, fullHeight - sheetSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
val newTarget = when (sheetState.anchoredDraggableState.targetValue) {
|
||||
SheetValue.Hidden -> SheetValue.Hidden
|
||||
SheetValue.PartiallyExpanded, SheetValue.Expanded -> {
|
||||
val hasPartiallyExpandedState = newAnchors.hasAnchorFor(SheetValue.PartiallyExpanded)
|
||||
val newTarget = if (hasPartiallyExpandedState) {
|
||||
SheetValue.PartiallyExpanded
|
||||
} else if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.Hidden
|
||||
newTarget
|
||||
}
|
||||
}
|
||||
|
||||
sheetState.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
* Popup specific for modal bottom sheet.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun ModalBottomSheetPopup(
|
||||
properties: ModalBottomSheetProperties2,
|
||||
onDismissRequest: () -> Unit,
|
||||
windowInsets: WindowInsets,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val view = LocalView.current
|
||||
val id = rememberSaveable { UUID.randomUUID() }
|
||||
val parentComposition = rememberCompositionContext()
|
||||
val currentContent by rememberUpdatedState(content)
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val modalBottomSheetWindow = remember(configuration) {
|
||||
ModalBottomSheetWindow2(
|
||||
properties = properties,
|
||||
onDismissRequest = onDismissRequest,
|
||||
composeView = view,
|
||||
saveId = id,
|
||||
).apply {
|
||||
setCustomContent(
|
||||
parent = parentComposition,
|
||||
content = {
|
||||
Box(
|
||||
Modifier
|
||||
.semantics { this.popup() }
|
||||
.windowInsetsPadding(windowInsets)
|
||||
.then(
|
||||
// TODO(b/290893168): Figure out a solution for APIs < 30.
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Modifier.imePadding()
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
) {
|
||||
currentContent()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(modalBottomSheetWindow) {
|
||||
modalBottomSheetWindow.show()
|
||||
modalBottomSheetWindow.superSetLayoutDirection(layoutDirection)
|
||||
onDispose {
|
||||
modalBottomSheetWindow.disposeComposition()
|
||||
modalBottomSheetWindow.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Custom compose view for [ModalBottomSheetFix] */
|
||||
@SuppressLint("ViewConstructor")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private class ModalBottomSheetWindow2(
|
||||
private val properties: ModalBottomSheetProperties2,
|
||||
private var onDismissRequest: () -> Unit,
|
||||
private val composeView: View,
|
||||
saveId: UUID,
|
||||
) : AbstractComposeView(composeView.context),
|
||||
ViewTreeObserver.OnGlobalLayoutListener,
|
||||
ViewRootForInspector,
|
||||
OnBackPressedDispatcherOwner {
|
||||
|
||||
init {
|
||||
id = android.R.id.content
|
||||
// Set up view owners
|
||||
setViewTreeLifecycleOwner(composeView.findViewTreeLifecycleOwner())
|
||||
setViewTreeViewModelStoreOwner(composeView.findViewTreeViewModelStoreOwner())
|
||||
setViewTreeSavedStateRegistryOwner(composeView.findViewTreeSavedStateRegistryOwner())
|
||||
setTag(androidx.compose.ui.R.id.compose_view_saveable_id_tag, "Popup:$saveId")
|
||||
// Enable children to draw their shadow by not clipping them
|
||||
clipChildren = false
|
||||
}
|
||||
|
||||
override val lifecycle = composeView.findViewTreeLifecycleOwner()!!.lifecycle
|
||||
override val onBackPressedDispatcher = OnBackPressedDispatcher {
|
||||
onDismissRequest()
|
||||
}
|
||||
|
||||
private val windowManager =
|
||||
composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
|
||||
private val displayWidth: Int
|
||||
get() = context.resources.displayMetrics.widthPixels
|
||||
|
||||
private val params: WindowManager.LayoutParams =
|
||||
WindowManager.LayoutParams().apply {
|
||||
// Position bottom sheet from the bottom of the screen
|
||||
gravity = Gravity.BOTTOM or Gravity.START
|
||||
// Application panel window
|
||||
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
|
||||
// Fill up the entire app view
|
||||
width = displayWidth
|
||||
height = WindowManager.LayoutParams.MATCH_PARENT
|
||||
|
||||
// Format of screen pixels
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
// Title used as fallback for a11y services
|
||||
// TODO: Provide bottom sheet window resource
|
||||
title = composeView.context.resources.getString(
|
||||
androidx.compose.ui.R.string.default_popup_window_title,
|
||||
)
|
||||
// Get the Window token from the parent view
|
||||
token = composeView.applicationWindowToken
|
||||
|
||||
// Flags specific to modal bottom sheet.
|
||||
flags = flags and (
|
||||
WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES or
|
||||
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
|
||||
).inv()
|
||||
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
|
||||
|
||||
// Security flag
|
||||
val secureFlagEnabled =
|
||||
properties.securePolicy.shouldApplySecureFlag(composeView.isFlagSecureEnabled())
|
||||
if (secureFlagEnabled) {
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
|
||||
} else {
|
||||
flags = flags and (WindowManager.LayoutParams.FLAG_SECURE.inv())
|
||||
}
|
||||
|
||||
// Focusable
|
||||
if (!properties.isFocusable) {
|
||||
flags = flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
||||
} else {
|
||||
flags = flags and (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv())
|
||||
}
|
||||
}
|
||||
|
||||
private var content: @Composable () -> Unit by mutableStateOf({})
|
||||
|
||||
override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
|
||||
private set
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
CompositionLocalProvider(LocalOnBackPressedDispatcherOwner provides this) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomContent(
|
||||
parent: CompositionContext? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
parent?.let { setParentCompositionContext(it) }
|
||||
this.content = content
|
||||
shouldCreateCompositionOnAttachedToWindow = true
|
||||
}
|
||||
|
||||
fun show() {
|
||||
windowManager.addView(this, params)
|
||||
if (isAtLeastT) {
|
||||
onBackPressedDispatcher.setOnBackInvokedDispatcher(findOnBackInvokedDispatcher()!!)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
setViewTreeLifecycleOwner(null)
|
||||
setViewTreeSavedStateRegistryOwner(null)
|
||||
composeView.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
windowManager.removeViewImmediate(this)
|
||||
}
|
||||
|
||||
override fun onGlobalLayout() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
override fun setLayoutDirection(layoutDirection: Int) {
|
||||
// Do nothing. ViewRootImpl will call this method attempting to set the layout direction
|
||||
// from the context's locale, but we have one already from the parent composition.
|
||||
}
|
||||
|
||||
// Sets the "real" layout direction for our content that we obtain from the parent composition.
|
||||
fun superSetLayoutDirection(layoutDirection: LayoutDirection) {
|
||||
val direction = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> android.util.LayoutDirection.LTR
|
||||
LayoutDirection.Rtl -> android.util.LayoutDirection.RTL
|
||||
}
|
||||
super.setLayoutDirection(direction)
|
||||
}
|
||||
}
|
||||
|
||||
// Taken from AndroidPopup.android.kt
|
||||
private fun View.isFlagSecureEnabled(): Boolean {
|
||||
val windowParams = rootView.layoutParams as? WindowManager.LayoutParams
|
||||
if (windowParams != null) {
|
||||
return (windowParams.flags and WindowManager.LayoutParams.FLAG_SECURE) != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Taken from AndroidPopup.android.kt
|
||||
private fun SecureFlagPolicy.shouldApplySecureFlag(isSecureFlagSetOnParent: Boolean): Boolean {
|
||||
return when (this) {
|
||||
SecureFlagPolicy.SecureOff -> false
|
||||
SecureFlagPolicy.SecureOn -> true
|
||||
SecureFlagPolicy.Inherit -> isSecureFlagSetOnParent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.TweenSpec
|
||||
import androidx.compose.animation.core.animate
|
||||
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.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
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
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.Saver
|
||||
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.graphics.BlurEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.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.unit.lerp
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.hippo.ehviewer.ui.tools.animateFloatMergeOneWayPredictiveBackAsState
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CancellationException
|
||||
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
|
||||
class DrawerState2(
|
||||
initialValue: DrawerValue,
|
||||
val confirmStateChange: (DrawerValue) -> Boolean = { true },
|
||||
) {
|
||||
|
||||
val anchoredDraggableState = AnchoredDraggableState(
|
||||
initialValue = initialValue,
|
||||
animationSpec = AnimationSpec,
|
||||
confirmValueChange = confirmStateChange,
|
||||
positionalThreshold = { distance -> distance * DrawerPositionalThreshold },
|
||||
velocityThreshold = { with(requireDensity()) { DrawerVelocityThreshold.toPx() } },
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the drawer is open.
|
||||
*/
|
||||
val isOpen: Boolean
|
||||
get() = currentValue == DrawerValue.Open
|
||||
|
||||
/**
|
||||
* Whether the drawer is closed.
|
||||
*/
|
||||
val isClosed: Boolean
|
||||
get() = currentValue == DrawerValue.Closed
|
||||
|
||||
/**
|
||||
* The current value of the state.
|
||||
*
|
||||
* If no swipe or animation is in progress, this corresponds to the start the drawer
|
||||
* currently in. If a swipe or an animation is in progress, this corresponds the state drawer
|
||||
* was in before the swipe or animation started.
|
||||
*/
|
||||
val currentValue: DrawerValue
|
||||
get() {
|
||||
return anchoredDraggableState.currentValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the state is currently animating.
|
||||
*/
|
||||
val isAnimationRunning: Boolean
|
||||
get() {
|
||||
return anchoredDraggableState.isAnimationRunning
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the drawer with animation and suspend until it if fully opened or animation has been
|
||||
* cancelled. This method will throw [CancellationException] if the animation is
|
||||
* interrupted
|
||||
*
|
||||
* @return the reason the open animation ended
|
||||
*/
|
||||
suspend fun open() = animateTo(DrawerValue.Open)
|
||||
|
||||
/**
|
||||
* Close the drawer with animation and suspend until it if fully closed or animation has been
|
||||
* cancelled. This method will throw [CancellationException] if the animation is
|
||||
* interrupted
|
||||
*
|
||||
* @return the reason the close animation ended
|
||||
*/
|
||||
suspend fun close() = animateTo(DrawerValue.Closed)
|
||||
|
||||
/**
|
||||
* Set the state of the drawer with specific animation
|
||||
*
|
||||
* @param targetValue The new value to animate to.
|
||||
* @param anim The animation that will be used to animate to the new value.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "This method has been replaced by the open and close methods. The animation " +
|
||||
"spec is now an implementation detail of ModalDrawer.",
|
||||
)
|
||||
suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec<Float>) {
|
||||
animateTo(targetValue = targetValue, animationSpec = anim)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state without any animation and suspend until it's set
|
||||
*
|
||||
* @param targetValue The new target value
|
||||
*/
|
||||
suspend fun snapTo(targetValue: DrawerValue) {
|
||||
anchoredDraggableState.snapTo(targetValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* The target value of the drawer state.
|
||||
*
|
||||
* If a swipe is in progress, this is the value that the Drawer would animate to if the
|
||||
* swipe finishes. If an animation is running, this is the target value of that animation.
|
||||
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
|
||||
*/
|
||||
val targetValue: DrawerValue
|
||||
get() = anchoredDraggableState.targetValue
|
||||
|
||||
/**
|
||||
* The current position (in pixels) of the drawer sheet, or Float.NaN before the offset is
|
||||
* initialized.
|
||||
*
|
||||
* @see [AnchoredDraggableState.offset] for more information.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Please access the offset through currentOffset, which returns the value " +
|
||||
"directly instead of wrapping it in a state object.",
|
||||
replaceWith = ReplaceWith("currentOffset"),
|
||||
)
|
||||
val offset: State<Float> = object : State<Float> {
|
||||
override val value: Float get() = anchoredDraggableState.offset
|
||||
}
|
||||
|
||||
/**
|
||||
* The current position (in pixels) of the drawer sheet, or Float.NaN before the offset is
|
||||
* initialized.
|
||||
*
|
||||
* @see [AnchoredDraggableState.offset] for more information.
|
||||
*/
|
||||
val currentOffset: Float get() = anchoredDraggableState.offset
|
||||
|
||||
internal var density: Density? by mutableStateOf(null)
|
||||
|
||||
private fun requireDensity() = requireNotNull(density) {
|
||||
"The density on BottomDrawerState ($this) was not set. Did you use BottomDrawer" +
|
||||
" with the BottomDrawer composable?"
|
||||
}
|
||||
|
||||
internal fun requireOffset(): Float = anchoredDraggableState.requireOffset()
|
||||
|
||||
private suspend fun animateTo(
|
||||
targetValue: DrawerValue,
|
||||
animationSpec: AnimationSpec<Float> = AnimationSpec,
|
||||
velocity: Float = anchoredDraggableState.lastVelocity,
|
||||
) {
|
||||
anchoredDraggableState.anchoredDrag(targetValue = targetValue) { anchors, latestTarget ->
|
||||
val targetOffset = anchors.positionOf(latestTarget)
|
||||
if (!targetOffset.isNaN()) {
|
||||
var prev = if (currentOffset.isNaN()) 0f else currentOffset
|
||||
animate(prev, targetOffset, velocity, animationSpec) { value, velocity ->
|
||||
// Our onDrag coerces the value within the bounds, but an animation may
|
||||
// overshoot, for example a spring animation or an overshooting interpolator
|
||||
// We respect the user's intention and allow the overshoot, but still use
|
||||
// DraggableState's drag for its mutex.
|
||||
dragTo(value, velocity)
|
||||
prev = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default [Saver] implementation for [DrawerState2].
|
||||
*/
|
||||
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) =
|
||||
Saver<DrawerState2, DrawerValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { DrawerState2(it, confirmStateChange) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and [remember] a [DrawerState2].
|
||||
*
|
||||
* @param initialValue The initial value of the state.
|
||||
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDrawerState(
|
||||
initialValue: DrawerValue,
|
||||
confirmStateChange: (DrawerValue) -> Boolean = { true },
|
||||
): DrawerState2 {
|
||||
return rememberSaveable(saver = DrawerState2.Saver(confirmStateChange)) {
|
||||
DrawerState2(initialValue, confirmStateChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModalNavigationDrawer(
|
||||
drawerContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
|
||||
gesturesEnabled: Boolean = true,
|
||||
scrimColor: Color = DrawerDefaults.scrimColor,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) }
|
||||
val maxValue = 0f
|
||||
|
||||
SideEffect {
|
||||
drawerState.density = density
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at minValue
|
||||
DrawerValue.Open at maxValue
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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.isClosed) || (slop < 0 && drawerState.isOpen)
|
||||
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)
|
||||
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)) {
|
||||
val blurModifier = Modifier.graphicsLayer {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
val radius = lerp(0.dp, 10.dp, step)
|
||||
if (radius != 0.dp) {
|
||||
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
}
|
||||
Box(modifier = blurModifier) {
|
||||
content()
|
||||
}
|
||||
Scrim(
|
||||
open = drawerState.isOpen,
|
||||
onClose = {
|
||||
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
fraction = {
|
||||
calculateFraction(minValue, maxValue, drawerState.requireOffset())
|
||||
},
|
||||
color = scrimColor,
|
||||
)
|
||||
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
|
||||
drawerState.close()
|
||||
}
|
||||
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
|
||||
val predictiveModifier = if (drawerState.isOpen) {
|
||||
if (predictiveState > 0) {
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val multiplierX = lerp(1f, 1.05f, predictiveState)
|
||||
val multiplierY = lerp(1f, 0.95f, predictiveState)
|
||||
val scaledWidth = (multiplierX * placeable.width).roundToInt()
|
||||
val scaledHeight = (multiplierY * placeable.height).roundToInt()
|
||||
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
|
||||
layout(scaledWidth, scaledHeight) {
|
||||
reMeasured.placeRelative(scaledWidth - placeable.width, 0)
|
||||
}
|
||||
}.background(
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
|
||||
shape = DrawerDefaults.shape,
|
||||
)
|
||||
} else {
|
||||
Modifier.graphicsLayer {
|
||||
val scale = lerp(1f, 0.95f, -predictiveState)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
IntOffset(lerp(0f, minValue * 0.05f, -predictiveState).roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.onGloballyPositioned {
|
||||
val w = it.size.width
|
||||
if (w != 0) {
|
||||
minValue = -it.size.width.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(drawerState.requireOffset().roundToInt(), 0)
|
||||
} then predictiveModifier.align(Alignment.CenterStart),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
drawerContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SideDrawer(
|
||||
drawerContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
|
||||
gesturesEnabled: Boolean = true,
|
||||
scrimColor: Color = DrawerDefaults.scrimColor,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) }
|
||||
val maxValue = 0f
|
||||
|
||||
SideEffect {
|
||||
drawerState.density = density
|
||||
drawerState.anchoredDraggableState.updateAnchors(
|
||||
DraggableAnchors {
|
||||
DrawerValue.Closed at minValue
|
||||
DrawerValue.Open at maxValue
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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)) {
|
||||
val blurModifier = Modifier.graphicsLayer {
|
||||
val step = calculateFraction(minValue, maxValue, drawerState.currentOffset)
|
||||
val radius = lerp(0.dp, 10.dp, step)
|
||||
if (radius != 0.dp) {
|
||||
renderEffect = BlurEffect(radius.toPx(), radius.toPx(), TileMode.Clamp)
|
||||
shape = RectangleShape
|
||||
clip = true
|
||||
}
|
||||
}
|
||||
Box(modifier = blurModifier) {
|
||||
content()
|
||||
}
|
||||
Scrim(
|
||||
open = drawerState.isOpen,
|
||||
onClose = {
|
||||
if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
fraction = {
|
||||
calculateFraction(minValue, maxValue, drawerState.requireOffset())
|
||||
},
|
||||
color = scrimColor,
|
||||
)
|
||||
val predictiveState by animateFloatMergeOneWayPredictiveBackAsState(drawerState.isOpen) {
|
||||
drawerState.close()
|
||||
}
|
||||
val absoluteElevation = LocalAbsoluteTonalElevation.current + DrawerDefaults.ModalDrawerElevation
|
||||
val predictiveModifier = if (drawerState.isOpen) {
|
||||
if (predictiveState < 0) {
|
||||
Modifier.layout { measurable, constraints ->
|
||||
val placeable = measurable.measure(constraints)
|
||||
val multiplierX = lerp(1f, 1.05f, -predictiveState)
|
||||
val multiplierY = lerp(1f, 0.95f, -predictiveState)
|
||||
val scaledWidth = (multiplierX * placeable.width).roundToInt()
|
||||
val scaledHeight = (multiplierY * placeable.height).roundToInt()
|
||||
val reMeasured = measurable.measure(Constraints.fixed(scaledWidth, scaledHeight))
|
||||
layout(scaledWidth, scaledHeight) {
|
||||
reMeasured.placeRelative(-(scaledWidth - placeable.width), 0)
|
||||
}
|
||||
}.background(
|
||||
color = MaterialTheme.colorScheme.surfaceColorAtElevation(absoluteElevation),
|
||||
shape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
|
||||
)
|
||||
} else {
|
||||
Modifier.graphicsLayer {
|
||||
val scale = lerp(1f, 0.95f, predictiveState)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}.offset {
|
||||
IntOffset(lerp(0f, minValue * 0.05f, predictiveState).roundToInt(), 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.onGloballyPositioned {
|
||||
val w = it.size.width
|
||||
if (w != 0) {
|
||||
minValue = it.size.width.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.offset {
|
||||
IntOffset(drawerState.requireOffset().roundToInt(), 0)
|
||||
}.align(Alignment.CenterEnd) then predictiveModifier,
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
drawerContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
private fun calculateFraction(a: Float, b: Float, pos: Float) = ((pos - a) / (b - a)).coerceIn(0f, 1f)
|
||||
|
||||
@Composable
|
||||
private fun Scrim(open: Boolean, onClose: () -> Unit, fraction: () -> Float, color: Color) {
|
||||
val dismissDrawer = if (open) {
|
||||
Modifier.pointerInput(onClose) { detectTapGestures { onClose() } }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Canvas(Modifier.fillMaxSize().then(dismissDrawer)) {
|
||||
drawRect(color, alpha = fraction())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
@file:Suppress("ktlint:standard:property-naming")
|
||||
|
||||
package androidx.compose.material3
|
||||
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
import androidx.compose.foundation.interaction.Interaction
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.MutableWindowInsets
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.GenericShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text2.BasicTextField2
|
||||
import androidx.compose.foundation.text2.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text2.input.TextFieldState
|
||||
import androidx.compose.material3.SearchBarDefaults.InputFieldHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.structuralEqualityPolicy
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.toRect
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.offset
|
||||
import androidx.compose.ui.util.lerp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* <a href="https://m3.material.io/components/search/overview" class="external" target="_blank">Material Design search</a>.
|
||||
*
|
||||
* A search bar represents a floating search field that allows users to enter a keyword or phrase
|
||||
* and get relevant information. It can be used as a way to navigate through an app via search
|
||||
* queries.
|
||||
*
|
||||
* An active search bar expands into a search "view" and can be used to display dynamic suggestions.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* A [SearchBar] expands to occupy the entirety of its allowed size when active. For full-screen
|
||||
* behavior as specified by Material guidelines, parent layouts of the [SearchBar] must not pass
|
||||
* any [Constraints] that limit its size, and the host activity should set
|
||||
* `WindowCompat.setDecorFitsSystemWindows(window, false)`.
|
||||
*
|
||||
* If this expansion behavior is undesirable, for example on large tablet screens, [DockedSearchBar]
|
||||
* can be used instead.
|
||||
*
|
||||
* @param state the query text to be shown in the search bar's input field
|
||||
* @param onSearch the callback to be invoked when the input service triggers the [ImeAction.Search]
|
||||
* action.
|
||||
* @param active whether this search bar is active
|
||||
* @param onActiveChange the callback to be invoked when this search bar's active state is changed
|
||||
* @param modifier the [Modifier] to be applied to this search bar
|
||||
* @param enabled controls the enabled state of this search bar. When `false`, this component will
|
||||
* not respond to user input, and it will appear visually disabled and disabled to accessibility
|
||||
* services.
|
||||
* @param placeholder the placeholder to be displayed when the search bar's query is empty.
|
||||
* @param leadingIcon the leading icon to be displayed at the beginning of the search bar container
|
||||
* @param trailingIcon the trailing icon to be displayed at the end of the search bar container
|
||||
* @param shape the shape of this search bar when it is not [active]. When [active], the shape will
|
||||
* always be [SearchBarDefaults.fullScreenShape].
|
||||
* @param colors [SearchBarColors] that will be used to resolve the colors used for this search bar
|
||||
* in different states. See [SearchBarDefaults.colors].
|
||||
* @param tonalElevation when [SearchBarColors.containerColor] is [ColorScheme.surface], a
|
||||
* translucent primary color overlay is applied on top of the container. A higher tonal elevation
|
||||
* value will result in a darker color in light theme and lighter color in dark theme. See also:
|
||||
* [Surface].
|
||||
* @param shadowElevation the elevation for the shadow below the search bar
|
||||
* @param windowInsets the window insets that the search bar will respect
|
||||
* @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
|
||||
* for this search bar. You can create and pass in your own `remember`ed instance to observe
|
||||
* [Interaction]s and customize the appearance / behavior of this search bar in different states.
|
||||
* @param content the content of this search bar that will be displayed below the input field
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
state: TextFieldState,
|
||||
onSearch: () -> Unit,
|
||||
active: Boolean,
|
||||
onActiveChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
shape: Shape = SearchBarDefaults.inputFieldShape,
|
||||
colors: SearchBarColors = SearchBarDefaults.colors(),
|
||||
tonalElevation: Dp = SearchBarDefaults.TonalElevation,
|
||||
shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
|
||||
windowInsets: WindowInsets = SearchBarDefaults.windowInsets,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val animationProgress = remember { Animatable(initialValue = if (active) 1f else 0f) }
|
||||
var firstBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
|
||||
var currentBackEvent by remember { mutableStateOf<BackEventCompat?>(null) }
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
val density = LocalDensity.current
|
||||
|
||||
val defaultInputFieldShape = SearchBarDefaults.inputFieldShape
|
||||
val defaultFullScreenShape = SearchBarDefaults.fullScreenShape
|
||||
val useFullScreenShape by remember {
|
||||
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value == 1f }
|
||||
}
|
||||
val animatedShape = remember(useFullScreenShape, shape) {
|
||||
when {
|
||||
shape == defaultInputFieldShape ->
|
||||
// The shape can only be animated if it's the default spec value
|
||||
GenericShape { size, _ ->
|
||||
val radius = with(density) {
|
||||
(SearchBarCornerRadius * (1 - animationProgress.value)).toPx()
|
||||
}
|
||||
addRoundRect(RoundRect(size.toRect(), CornerRadius(radius)))
|
||||
}
|
||||
useFullScreenShape -> defaultFullScreenShape
|
||||
else -> shape
|
||||
}
|
||||
}
|
||||
|
||||
// The main animation complexity is allowing the component to smoothly expand while keeping the
|
||||
// input field at the same relative location on screen. `Modifier.windowInsetsPadding` does not
|
||||
// support animation and thus is not suitable. Instead, we convert the insets to a padding
|
||||
// applied to the Surface, which gradually becomes padding applied to the input field as the
|
||||
// animation proceeds.
|
||||
val unconsumedInsets = remember { MutableWindowInsets() }
|
||||
val topPadding = remember(density) {
|
||||
derivedStateOf {
|
||||
SearchBarVerticalPadding +
|
||||
unconsumedInsets.asPaddingValues(density).calculateTopPadding()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = animatedShape,
|
||||
color = colors.containerColor,
|
||||
contentColor = contentColorFor(colors.containerColor),
|
||||
tonalElevation = tonalElevation,
|
||||
shadowElevation = shadowElevation,
|
||||
modifier = modifier
|
||||
.padding(horizontal = lerp(SearchBarHorizontalPadding, 0.dp, animationProgress.value))
|
||||
.zIndex(1f)
|
||||
.onConsumedWindowInsetsChanged { consumedInsets ->
|
||||
unconsumedInsets.insets = windowInsets.exclude(consumedInsets)
|
||||
}
|
||||
.consumeWindowInsets(unconsumedInsets)
|
||||
.layout { measurable, constraints ->
|
||||
val animatedTopPadding =
|
||||
lerp(topPadding.value, 0.dp, animationProgress.value).roundToPx()
|
||||
|
||||
val startWidth: Int
|
||||
val startHeight: Int
|
||||
if (currentBackEvent == null) {
|
||||
startWidth = max(constraints.minWidth, SearchBarMinWidth.roundToPx())
|
||||
.coerceAtMost(min(constraints.maxWidth, SearchBarMaxWidth.roundToPx()))
|
||||
startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx())
|
||||
.coerceAtMost(constraints.maxHeight)
|
||||
} else {
|
||||
startWidth =
|
||||
(constraints.maxWidth * SearchBarPredictiveBackMinScale).roundToInt()
|
||||
startHeight =
|
||||
(constraints.maxHeight * SearchBarPredictiveBackMinScale).roundToInt()
|
||||
}
|
||||
val endWidth = constraints.maxWidth
|
||||
val endHeight = constraints.maxHeight
|
||||
|
||||
val width = lerp(startWidth, endWidth, animationProgress.value)
|
||||
val height =
|
||||
lerp(startHeight, endHeight, animationProgress.value) + animatedTopPadding
|
||||
|
||||
val minOffsetMargin = SearchBarPredictiveBackMinMargin.roundToPx()
|
||||
val predictiveBackOffsetX = calculatePredictiveBackOffsetX(
|
||||
constraints,
|
||||
minOffsetMargin,
|
||||
currentBackEvent,
|
||||
animationProgress.value,
|
||||
)
|
||||
val predictiveBackOffsetY = calculatePredictiveBackOffsetY(
|
||||
constraints,
|
||||
minOffsetMargin,
|
||||
currentBackEvent,
|
||||
firstBackEvent,
|
||||
height,
|
||||
SearchBarPredictiveBackMaxOffsetY.roundToPx(),
|
||||
)
|
||||
val placeable = measurable.measure(
|
||||
Constraints
|
||||
.fixed(width, height)
|
||||
.offset(
|
||||
vertical = -animatedTopPadding,
|
||||
),
|
||||
)
|
||||
layout(width, height) {
|
||||
placeable.placeRelative(
|
||||
predictiveBackOffsetX,
|
||||
animatedTopPadding + predictiveBackOffsetY,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
val animatedInputFieldPadding = remember {
|
||||
AnimatedPaddingValues2(animationProgress.asState(), topPadding)
|
||||
}
|
||||
SearchBarInputField(
|
||||
state = state,
|
||||
onSearch = onSearch,
|
||||
onActiveChange = onActiveChange,
|
||||
modifier = Modifier.padding(paddingValues = animatedInputFieldPadding),
|
||||
enabled = enabled,
|
||||
label = title.takeUnless { active || animationProgress.value > 0 || state.text.isNotEmpty() },
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon,
|
||||
trailingIcon = trailingIcon,
|
||||
colors = colors.inputFieldColors,
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
|
||||
val showResults by remember {
|
||||
derivedStateOf(structuralEqualityPolicy()) { animationProgress.value > 0 }
|
||||
}
|
||||
if (showResults) {
|
||||
Column(Modifier.graphicsLayer { alpha = animationProgress.value }) {
|
||||
HorizontalDivider(color = colors.dividerColor)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||
val shouldClearFocus = !active && isFocused
|
||||
if (shouldClearFocus) {
|
||||
LaunchedEffect(true) {
|
||||
// Not strictly needed according to the motion spec, but since the animation already has
|
||||
// a delay, this works around b/261632544.
|
||||
delay(AnimationDelayMillis.toLong())
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(active) {
|
||||
val animationInProgress = animationProgress.value > 0 && animationProgress.value < 1
|
||||
val animationSpec =
|
||||
if (animationInProgress) {
|
||||
AnimationPredictiveBackExitFloatSpec
|
||||
} else if (active) {
|
||||
AnimationEnterFloatSpec
|
||||
} else {
|
||||
AnimationExitFloatSpec
|
||||
}
|
||||
animationProgress.animateTo(
|
||||
targetValue = if (active) 1f else 0f,
|
||||
animationSpec = animationSpec,
|
||||
)
|
||||
}
|
||||
val mutatorMutex = remember { MutatorMutex() }
|
||||
if (active) {
|
||||
PredictiveBackHandler(true) { progress ->
|
||||
mutatorMutex.mutate {
|
||||
var canceled = false
|
||||
try {
|
||||
progress.collect { backEvent ->
|
||||
if (firstBackEvent == null) {
|
||||
firstBackEvent = backEvent
|
||||
}
|
||||
currentBackEvent = backEvent
|
||||
val interpolatedProgress = EaseOut.transform(backEvent.progress)
|
||||
animationProgress.snapTo(targetValue = 1 - interpolatedProgress)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
canceled = true
|
||||
} finally {
|
||||
firstBackEvent = null
|
||||
currentBackEvent = null
|
||||
onActiveChange(canceled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculatePredictiveBackOffsetX(
|
||||
constraints: Constraints,
|
||||
minMargin: Int,
|
||||
currentBackEvent: BackEventCompat?,
|
||||
progress: Float,
|
||||
): Int {
|
||||
if (currentBackEvent == null) {
|
||||
return 0
|
||||
}
|
||||
val directionMultiplier = if (currentBackEvent.swipeEdge == BackEventCompat.EDGE_LEFT) 1 else -1
|
||||
val maxOffsetX =
|
||||
(constraints.maxWidth * SearchBarPredictiveBackMaxOffsetXRatio) - minMargin
|
||||
return (maxOffsetX * (1 - progress)).toInt() * directionMultiplier
|
||||
}
|
||||
|
||||
private fun calculatePredictiveBackOffsetY(
|
||||
constraints: Constraints,
|
||||
minMargin: Int,
|
||||
currentBackEvent: BackEventCompat?,
|
||||
firstBackEvent: BackEventCompat?,
|
||||
height: Int,
|
||||
maxOffsetY: Int,
|
||||
): Int {
|
||||
if (firstBackEvent == null || currentBackEvent == null) {
|
||||
return 0
|
||||
}
|
||||
val availableVerticalSpace = max(0, (constraints.maxHeight - height) / 2 - minMargin)
|
||||
val adjustedMaxOffsetY = min(availableVerticalSpace, maxOffsetY)
|
||||
val yDelta = currentBackEvent.touchY - firstBackEvent.touchY
|
||||
val yProgress = abs(yDelta) / constraints.maxHeight
|
||||
val directionMultiplier = sign(yDelta)
|
||||
return (lerp(0, adjustedMaxOffsetY, yProgress) * directionMultiplier).roundToInt()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBarInputField(
|
||||
state: TextFieldState,
|
||||
onSearch: () -> Unit,
|
||||
onActiveChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
label: @Composable (() -> Unit)? = null,
|
||||
placeholder: @Composable (() -> Unit)? = null,
|
||||
leadingIcon: @Composable (() -> Unit)? = null,
|
||||
trailingIcon: @Composable (() -> Unit)? = null,
|
||||
colors: TextFieldColors = SearchBarDefaults.inputFieldColors(),
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val textColor = LocalTextStyle.current.color.takeOrElse {
|
||||
if (focused) colors.focusedTextColor else colors.unfocusedTextColor
|
||||
}
|
||||
|
||||
BasicTextField2(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
.height(InputFieldHeight)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { if (it.isFocused) onActiveChange(true) }
|
||||
.semantics {
|
||||
onClick {
|
||||
focusRequester.requestFocus()
|
||||
true
|
||||
}
|
||||
},
|
||||
enabled = enabled,
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)),
|
||||
cursorBrush = SolidColor(colors.cursorColor),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { onSearch() }),
|
||||
interactionSource = interactionSource,
|
||||
decorator = @Composable { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = state.text.toString(),
|
||||
innerTextField = innerTextField,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
interactionSource = interactionSource,
|
||||
label = label,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = leadingIcon?.let { leading ->
|
||||
{
|
||||
Box(Modifier.offset(x = SearchBarIconOffsetX)) { leading() }
|
||||
}
|
||||
},
|
||||
trailingIcon = trailingIcon?.let { trailing ->
|
||||
{
|
||||
Box(Modifier.offset(x = -SearchBarIconOffsetX)) { trailing() }
|
||||
}
|
||||
},
|
||||
shape = SearchBarDefaults.inputFieldShape,
|
||||
colors = colors,
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(),
|
||||
container = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Stable
|
||||
private class AnimatedPaddingValues2(
|
||||
val animationProgress: State<Float>,
|
||||
val topPadding: State<Dp>,
|
||||
) : PaddingValues {
|
||||
override fun calculateTopPadding(): Dp = topPadding.value * animationProgress.value
|
||||
override fun calculateBottomPadding(): Dp = SearchBarVerticalPadding * animationProgress.value
|
||||
|
||||
override fun calculateLeftPadding(layoutDirection: LayoutDirection): Dp = 0.dp
|
||||
override fun calculateRightPadding(layoutDirection: LayoutDirection): Dp = 0.dp
|
||||
}
|
||||
|
||||
// Measurement specs
|
||||
private val SearchBarCornerRadius: Dp = InputFieldHeight / 2
|
||||
internal val SearchBarMinWidth: Dp = 360.dp
|
||||
private val SearchBarMaxWidth: Dp = 720.dp
|
||||
internal val SearchBarVerticalPadding: Dp = 8.dp
|
||||
internal val SearchBarHorizontalPadding: Dp = 16.dp
|
||||
|
||||
// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp.
|
||||
private val SearchBarIconOffsetX: Dp = 4.dp
|
||||
|
||||
private const val SearchBarPredictiveBackMinScale: Float = 9f / 10f
|
||||
private val SearchBarPredictiveBackMinMargin: Dp = 8.dp
|
||||
private const val SearchBarPredictiveBackMaxOffsetXRatio: Float = 1f / 20f
|
||||
private val SearchBarPredictiveBackMaxOffsetY: Dp = 24.dp
|
||||
|
||||
// Animation specs
|
||||
private const val AnimationEnterDurationMillis: Int = 600
|
||||
private const val AnimationExitDurationMillis: Int = 350
|
||||
private const val AnimationDelayMillis: Int = 100
|
||||
private val AnimationEnterEasing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f)
|
||||
private val AnimationExitEasing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f)
|
||||
private val AnimationEnterFloatSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = AnimationEnterDurationMillis,
|
||||
delayMillis = AnimationDelayMillis,
|
||||
easing = AnimationEnterEasing,
|
||||
)
|
||||
private val AnimationExitFloatSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = AnimationExitDurationMillis,
|
||||
delayMillis = AnimationDelayMillis,
|
||||
easing = AnimationExitEasing,
|
||||
)
|
||||
private val AnimationPredictiveBackExitFloatSpec: FiniteAnimationSpec<Float> = tween(
|
||||
durationMillis = AnimationExitDurationMillis,
|
||||
easing = AnimationExitEasing,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2023 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.navigation.compose
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavGraph
|
||||
import androidx.navigation.NavGraphNavigator
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.NavigatorProvider
|
||||
|
||||
/**
|
||||
* Custom subclass of [NavGraphNavigator] that adds support for defining
|
||||
* transitions at the navigation graph level.
|
||||
*/
|
||||
@Navigator.Name("navigation")
|
||||
internal class ComposeNavGraphNavigator(
|
||||
navigatorProvider: NavigatorProvider,
|
||||
) : NavGraphNavigator(navigatorProvider) {
|
||||
override fun createDestination(): NavGraph {
|
||||
return ComposeNavGraph(this)
|
||||
}
|
||||
|
||||
internal class ComposeNavGraph(
|
||||
navGraphNavigator: Navigator<out NavGraph>,
|
||||
) : NavGraph(navGraphNavigator) {
|
||||
internal var enterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null
|
||||
|
||||
internal var exitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null
|
||||
|
||||
internal var popEnterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null
|
||||
|
||||
internal var popExitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.navigation.compose
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.compose.ComposeNavigator.Destination
|
||||
|
||||
/**
|
||||
* Navigator that navigates through [Composable]s. Every destination using this Navigator must
|
||||
* set a valid [Composable] by setting it directly on an instantiated [Destination] or calling
|
||||
* [composable].
|
||||
*/
|
||||
@Navigator.Name("composable")
|
||||
public class ComposeNavigator : Navigator<Destination>() {
|
||||
|
||||
/**
|
||||
* Get the map of transitions currently in progress from the [state].
|
||||
*/
|
||||
internal val transitionsInProgress get() = state.transitionsInProgress
|
||||
|
||||
/**
|
||||
* Get the back stack from the [state].
|
||||
*/
|
||||
public val backStack get() = state.backStack
|
||||
|
||||
internal val isPop = mutableStateOf(false)
|
||||
|
||||
override fun navigate(
|
||||
entries: List<NavBackStackEntry>,
|
||||
navOptions: NavOptions?,
|
||||
navigatorExtras: Extras?,
|
||||
) {
|
||||
entries.forEach { entry ->
|
||||
state.pushWithTransition(entry)
|
||||
}
|
||||
isPop.value = false
|
||||
}
|
||||
|
||||
override fun createDestination(): Destination {
|
||||
return Destination(this) { }
|
||||
}
|
||||
|
||||
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
|
||||
state.popWithTransition(popUpTo, savedState)
|
||||
isPop.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to mark a navigation in transition as complete.
|
||||
*
|
||||
* This should be called in conjunction with [navigate] and [popBackStack] as those
|
||||
* calls merely start a transition to the target destination, and requires manually marking
|
||||
* the transition as complete by calling this method.
|
||||
*
|
||||
* Failing to call this method could result in entries being prevented from reaching their
|
||||
* final [Lifecycle.State].
|
||||
*/
|
||||
public fun onTransitionComplete(entry: NavBackStackEntry) {
|
||||
state.markTransitionComplete(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* NavDestination specific to [ComposeNavigator]
|
||||
*/
|
||||
@NavDestination.ClassType(Composable::class)
|
||||
public class Destination(
|
||||
navigator: ComposeNavigator,
|
||||
internal val content:
|
||||
@Composable AnimatedContentScope.(@JvmSuppressWildcards NavBackStackEntry) -> Unit,
|
||||
) : NavDestination(navigator) {
|
||||
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of Destination that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
constructor(
|
||||
navigator: ComposeNavigator,
|
||||
content: @Composable (NavBackStackEntry) -> @JvmSuppressWildcards Unit,
|
||||
) : this(navigator, content = { entry -> content(entry) })
|
||||
|
||||
internal var enterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null
|
||||
|
||||
internal var exitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null
|
||||
|
||||
internal var popEnterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null
|
||||
|
||||
internal var popExitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
internal const val NAME = "composable"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2021 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.navigation.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.DialogNavigator.Destination
|
||||
|
||||
/**
|
||||
* Show each [Destination] on the [DialogNavigator]'s back stack as a [Dialog].
|
||||
*
|
||||
* Note that [NavHost] will call this for you; you do not need to call it manually.
|
||||
*/
|
||||
@Composable
|
||||
public fun DialogHost(dialogNavigator: DialogNavigator) {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
val dialogBackStack by dialogNavigator.backStack.collectAsState()
|
||||
val visibleBackStack = rememberVisibleList(dialogBackStack)
|
||||
visibleBackStack.PopulateVisibleList(dialogBackStack)
|
||||
|
||||
val transitionInProgress by dialogNavigator.transitionInProgress.collectAsState()
|
||||
val dialogsToDispose = remember { mutableStateListOf<NavBackStackEntry>() }
|
||||
|
||||
visibleBackStack.forEach { backStackEntry ->
|
||||
val destination = backStackEntry.destination as Destination
|
||||
Dialog(
|
||||
onDismissRequest = { dialogNavigator.dismiss(backStackEntry) },
|
||||
properties = destination.dialogProperties,
|
||||
) {
|
||||
DisposableEffect(backStackEntry) {
|
||||
dialogsToDispose.add(backStackEntry)
|
||||
onDispose {
|
||||
dialogNavigator.onTransitionComplete(backStackEntry)
|
||||
dialogsToDispose.remove(backStackEntry)
|
||||
}
|
||||
}
|
||||
|
||||
// while in the scope of the composable, we provide the navBackStackEntry as the
|
||||
// ViewModelStoreOwner and LifecycleOwner
|
||||
backStackEntry.LocalOwnersProvider(saveableStateHolder) {
|
||||
destination.content(backStackEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dialogs may have been popped before it was composed. To prevent leakage, we need to
|
||||
// mark popped entries as complete here. Check that we don't accidentally complete popped
|
||||
// entries that were composed, unless they were disposed of already.
|
||||
LaunchedEffect(transitionInProgress, dialogsToDispose) {
|
||||
transitionInProgress.forEach { entry ->
|
||||
if (!dialogNavigator.backStack.value.contains(entry) &&
|
||||
!dialogsToDispose.contains(entry)
|
||||
) {
|
||||
dialogNavigator.onTransitionComplete(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MutableList<NavBackStackEntry>.PopulateVisibleList(
|
||||
backStack: Collection<NavBackStackEntry>,
|
||||
) {
|
||||
val isInspecting = LocalInspectionMode.current
|
||||
backStack.forEach { entry ->
|
||||
DisposableEffect(entry.lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
// show dialog in preview
|
||||
if (isInspecting && !contains(entry)) {
|
||||
add(entry)
|
||||
}
|
||||
// ON_START -> add to visibleBackStack, ON_STOP -> remove from visibleBackStack
|
||||
if (event == Lifecycle.Event.ON_START) {
|
||||
// We want to treat the visible lists as Sets but we want to keep
|
||||
// the functionality of mutableStateListOf() so that we recompose in response
|
||||
// to adds and removes.
|
||||
if (!contains(entry)) {
|
||||
add(entry)
|
||||
}
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_STOP) {
|
||||
remove(entry)
|
||||
}
|
||||
}
|
||||
entry.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
entry.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberVisibleList(
|
||||
backStack: Collection<NavBackStackEntry>,
|
||||
): SnapshotStateList<NavBackStackEntry> {
|
||||
// show dialog in preview
|
||||
val isInspecting = LocalInspectionMode.current
|
||||
return remember(backStack) {
|
||||
mutableStateListOf<NavBackStackEntry>().also {
|
||||
it.addAll(
|
||||
backStack.filter { entry ->
|
||||
if (isInspecting) {
|
||||
true
|
||||
} else {
|
||||
entry.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* 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.navigation.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.FloatingWindow
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.compose.DialogNavigator.Destination
|
||||
|
||||
/**
|
||||
* Navigator that navigates through [Composable]s that will be hosted within a
|
||||
* [Dialog]. Every destination using this Navigator must set a valid [Composable] by setting it
|
||||
* directly on an instantiated [Destination] or calling [dialog].
|
||||
*/
|
||||
@Navigator.Name("dialog")
|
||||
public class DialogNavigator : Navigator<Destination>() {
|
||||
|
||||
/**
|
||||
* Get the back stack from the [state].
|
||||
*/
|
||||
internal val backStack get() = state.backStack
|
||||
|
||||
/**
|
||||
* Get the transitioning dialogs from the [state].
|
||||
*/
|
||||
internal val transitionInProgress get() = state.transitionsInProgress
|
||||
|
||||
/**
|
||||
* Dismiss the dialog destination associated with the given [backStackEntry].
|
||||
*/
|
||||
internal fun dismiss(backStackEntry: NavBackStackEntry) {
|
||||
popBackStack(backStackEntry, false)
|
||||
}
|
||||
|
||||
override fun navigate(
|
||||
entries: List<NavBackStackEntry>,
|
||||
navOptions: NavOptions?,
|
||||
navigatorExtras: Extras?,
|
||||
) {
|
||||
entries.forEach { entry ->
|
||||
state.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createDestination(): Destination {
|
||||
return Destination(this) { }
|
||||
}
|
||||
|
||||
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
|
||||
state.popWithTransition(popUpTo, savedState)
|
||||
// When popping, the incoming dialog is marked transitioning to hold it in
|
||||
// STARTED. With pop complete, we can remove it from transition so it can move to RESUMED.
|
||||
val popIndex = state.transitionsInProgress.value.indexOf(popUpTo)
|
||||
// do not mark complete for entries up to and including popUpTo
|
||||
state.transitionsInProgress.value.forEachIndexed { index, entry ->
|
||||
if (index > popIndex) onTransitionComplete(entry)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onTransitionComplete(entry: NavBackStackEntry) {
|
||||
state.markTransitionComplete(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* NavDestination specific to [DialogNavigator]
|
||||
*/
|
||||
@NavDestination.ClassType(Composable::class)
|
||||
public class Destination(
|
||||
navigator: DialogNavigator,
|
||||
internal val dialogProperties: DialogProperties = DialogProperties(),
|
||||
internal val content: @Composable (NavBackStackEntry) -> Unit,
|
||||
) : NavDestination(navigator), FloatingWindow
|
||||
|
||||
internal companion object {
|
||||
internal const val NAME = "dialog"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2021 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.
|
||||
*/
|
||||
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.saveable.SaveableStateHolder
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalSavedStateRegistryOwner
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Provides [this] [NavBackStackEntry] as [LocalViewModelStoreOwner], [LocalLifecycleOwner] and
|
||||
* [LocalSavedStateRegistryOwner] to the [content] and saves the [content]'s saveable states with
|
||||
* the given [saveableStateHolder].
|
||||
*
|
||||
* @param saveableStateHolder The [SaveableStateHolder] that holds the saved states. The same
|
||||
* holder should be used for all [NavBackStackEntry]s in the encapsulating [Composable] and the
|
||||
* holder should be hoisted.
|
||||
* @param content The content [Composable]
|
||||
*/
|
||||
@Composable
|
||||
public fun NavBackStackEntry.LocalOwnersProvider(
|
||||
saveableStateHolder: SaveableStateHolder,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalViewModelStoreOwner provides this,
|
||||
LocalLifecycleOwner provides this,
|
||||
LocalSavedStateRegistryOwner provides this,
|
||||
) {
|
||||
saveableStateHolder.SaveableStateProvider(content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SaveableStateHolder.SaveableStateProvider(content: @Composable () -> Unit) {
|
||||
val viewModel = viewModel<BackStackEntryIdViewModel>()
|
||||
// Stash a reference to the SaveableStateHolder in the ViewModel so that
|
||||
// it is available when the ViewModel is cleared, marking the permanent removal of this
|
||||
// NavBackStackEntry from the back stack. Which, because of animations,
|
||||
// only happens after this leaves composition. Which means we can't rely on
|
||||
// DisposableEffect to clean up this reference (as it'll be cleaned up too early)
|
||||
viewModel.saveableStateHolderRef = WeakReference(this)
|
||||
SaveableStateProvider(viewModel.id, content)
|
||||
}
|
||||
|
||||
internal class BackStackEntryIdViewModel(handle: SavedStateHandle) : ViewModel() {
|
||||
|
||||
private val IdKey = "SaveableStateHolder_BackStackEntryKey"
|
||||
|
||||
// we create our own id for each back stack entry to support multiple entries of the same
|
||||
// destination. this id will be restored by SavedStateHandle
|
||||
val id: UUID = handle.get<UUID>(IdKey) ?: UUID.randomUUID().also { handle.set(IdKey, it) }
|
||||
|
||||
lateinit var saveableStateHolderRef: WeakReference<SaveableStateHolder>
|
||||
|
||||
// onCleared will be called on the entries removed from the back stack. here we notify
|
||||
// SaveableStateProvider that we should remove any state is had associated with this
|
||||
// destination as it is no longer needed.
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
saveableStateHolderRef.get()?.removeState(id)
|
||||
saveableStateHolderRef.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* 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.navigation.compose
|
||||
|
||||
import androidx.compose.animation.AnimatedContentScope
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDeepLink
|
||||
import androidx.navigation.NavGraph
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.get
|
||||
|
||||
/**
|
||||
* Add the [Composable] to the [NavGraphBuilder]
|
||||
*
|
||||
* @param route route for the destination
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param content composable for the destination
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of composable builder that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
public fun NavGraphBuilder.composable(
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
content: @Composable (NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
ComposeNavigator.Destination(provider[ComposeNavigator::class]) {
|
||||
entry ->
|
||||
content(entry)
|
||||
}.apply {
|
||||
this.route = route
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the [Composable] to the [NavGraphBuilder]
|
||||
*
|
||||
* @param route route for the destination
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param enterTransition callback to determine the destination's enter transition
|
||||
* @param exitTransition callback to determine the destination's exit transition
|
||||
* @param popEnterTransition callback to determine the destination's popEnter transition
|
||||
* @param popExitTransition callback to determine the destination's popExit transition
|
||||
* @param content composable for the destination
|
||||
*/
|
||||
public fun NavGraphBuilder.composable(
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
enterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = null,
|
||||
exitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = null,
|
||||
popEnterTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? =
|
||||
enterTransition,
|
||||
popExitTransition: (
|
||||
@JvmSuppressWildcards
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? =
|
||||
exitTransition,
|
||||
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
ComposeNavigator.Destination(
|
||||
provider[ComposeNavigator::class],
|
||||
content,
|
||||
).apply {
|
||||
this.route = route
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
this.enterTransition = enterTransition
|
||||
this.exitTransition = exitTransition
|
||||
this.popEnterTransition = popEnterTransition
|
||||
this.popExitTransition = popExitTransition
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a nested [NavGraph]
|
||||
*
|
||||
* @sample androidx.navigation.compose.samples.NestedNavInGraphWithArgs
|
||||
*
|
||||
* @param startDestination the starting destination's route for this NavGraph
|
||||
* @param route the destination's unique route
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param builder the builder used to construct the graph
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of navigation builder that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
public fun NavGraphBuilder.navigation(
|
||||
startDestination: String,
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply {
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a nested [NavGraph]
|
||||
*
|
||||
* @param startDestination the starting destination's route for this NavGraph
|
||||
* @param route the destination's unique route
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param enterTransition callback to define enter transitions for destination in this NavGraph
|
||||
* @param exitTransition callback to define exit transitions for destination in this NavGraph
|
||||
* @param popEnterTransition callback to define pop enter transitions for destination in this
|
||||
* NavGraph
|
||||
* @param popExitTransition callback to define pop exit transitions for destination in this NavGraph
|
||||
* @param builder the builder used to construct the graph
|
||||
*
|
||||
* @return the newly constructed nested NavGraph
|
||||
*/
|
||||
public fun NavGraphBuilder.navigation(
|
||||
startDestination: String,
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
|
||||
null,
|
||||
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
|
||||
null,
|
||||
popEnterTransition: (
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?
|
||||
)? = enterTransition,
|
||||
popExitTransition: (
|
||||
AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?
|
||||
)? = exitTransition,
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
NavGraphBuilder(provider, startDestination, route).apply(builder).build().apply {
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
if (this is ComposeNavGraphNavigator.ComposeNavGraph) {
|
||||
this.enterTransition = enterTransition
|
||||
this.exitTransition = exitTransition
|
||||
this.popEnterTransition = popEnterTransition
|
||||
this.popExitTransition = popExitTransition
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the [Composable] to the [NavGraphBuilder] that will be hosted within a
|
||||
* [androidx.compose.ui.window.Dialog]. This is suitable only when this dialog represents
|
||||
* a separate screen in your app that needs its own lifecycle and saved state, independent
|
||||
* of any other destination in your navigation graph. For use cases such as `AlertDialog`,
|
||||
* you should use those APIs directly in the [composable] destination that wants to show that
|
||||
* dialog.
|
||||
*
|
||||
* @param route route for the destination
|
||||
* @param arguments list of arguments to associate with destination
|
||||
* @param deepLinks list of deep links to associate with the destinations
|
||||
* @param dialogProperties properties that should be passed to [androidx.compose.ui.window.Dialog].
|
||||
* @param content composable content for the destination that will be hosted within the Dialog
|
||||
*/
|
||||
public fun NavGraphBuilder.dialog(
|
||||
route: String,
|
||||
arguments: List<NamedNavArgument> = emptyList(),
|
||||
deepLinks: List<NavDeepLink> = emptyList(),
|
||||
dialogProperties: DialogProperties = DialogProperties(),
|
||||
content: @Composable (NavBackStackEntry) -> Unit,
|
||||
) {
|
||||
addDestination(
|
||||
DialogNavigator.Destination(
|
||||
provider[DialogNavigator::class],
|
||||
dialogProperties,
|
||||
content,
|
||||
).apply {
|
||||
this.route = route
|
||||
arguments.forEach { (argumentName, argument) ->
|
||||
addArgument(argumentName, argument)
|
||||
}
|
||||
deepLinks.forEach { deepLink ->
|
||||
addDeepLink(deepLink)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
* 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.navigation.compose
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.EaseOut
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.Navigator
|
||||
import androidx.navigation.createGraph
|
||||
import androidx.navigation.get
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.collectAsState
|
||||
import kotlin.math.roundToLong
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
|
||||
* contents of the builder cannot be changed.
|
||||
*
|
||||
* @sample androidx.navigation.compose.samples.NavScaffold
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param startDestination the route for the start destination
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param route the route for the graph
|
||||
* @param builder the builder used to construct the graph
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of NavHost that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
@Composable
|
||||
public fun NavHost(
|
||||
navController: NavHostController,
|
||||
startDestination: String,
|
||||
modifier: Modifier = Modifier,
|
||||
route: String? = null,
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
NavHost(
|
||||
navController,
|
||||
remember(route, startDestination, builder) {
|
||||
navController.createGraph(startDestination, route, builder)
|
||||
},
|
||||
modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* The builder passed into this method is [remember]ed. This means that for this NavHost, the
|
||||
* contents of the builder cannot be changed.
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param startDestination the route for the start destination
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param contentAlignment The [Alignment] of the [AnimatedContent]
|
||||
* @param route the route for the graph
|
||||
* @param enterTransition callback to define enter transitions for destination in this host
|
||||
* @param exitTransition callback to define exit transitions for destination in this host
|
||||
* @param popEnterTransition callback to define popEnter transitions for destination in this host
|
||||
* @param popExitTransition callback to define popExit transitions for destination in this host
|
||||
* @param builder the builder used to construct the graph
|
||||
*/
|
||||
@Composable
|
||||
public fun NavHost(
|
||||
navController: NavHostController,
|
||||
startDestination: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentAlignment: Alignment = Alignment.Center,
|
||||
route: String? = null,
|
||||
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
|
||||
{ fadeIn(animationSpec = tween(700)) },
|
||||
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
|
||||
{ fadeOut(animationSpec = tween(700)) },
|
||||
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
|
||||
enterTransition,
|
||||
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
|
||||
exitTransition,
|
||||
builder: NavGraphBuilder.() -> Unit,
|
||||
) {
|
||||
NavHost(
|
||||
navController,
|
||||
remember(route, startDestination, builder) {
|
||||
navController.createGraph(startDestination, route, builder)
|
||||
},
|
||||
modifier,
|
||||
contentAlignment,
|
||||
enterTransition,
|
||||
exitTransition,
|
||||
popEnterTransition,
|
||||
popExitTransition,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* The graph passed into this method is [remember]ed. This means that for this NavHost, the graph
|
||||
* cannot be changed.
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param graph the graph for this host
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "Deprecated in favor of NavHost that supports AnimatedContent",
|
||||
level = DeprecationLevel.HIDDEN,
|
||||
)
|
||||
@Composable
|
||||
public fun NavHost(
|
||||
navController: NavHostController,
|
||||
graph: NavGraph,
|
||||
modifier: Modifier = Modifier,
|
||||
) = NavHost(navController, graph, modifier)
|
||||
|
||||
/**
|
||||
* Provides in place in the Compose hierarchy for self contained navigation to occur.
|
||||
*
|
||||
* Once this is called, any Composable within the given [NavGraphBuilder] can be navigated to from
|
||||
* the provided [navController].
|
||||
*
|
||||
* @param navController the navController for this host
|
||||
* @param graph the graph for this host
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
* @param contentAlignment The [Alignment] of the [AnimatedContent]
|
||||
* @param enterTransition callback to define enter transitions for destination in this host
|
||||
* @param exitTransition callback to define exit transitions for destination in this host
|
||||
* @param popEnterTransition callback to define popEnter transitions for destination in this host
|
||||
* @param popExitTransition callback to define popExit transitions for destination in this host
|
||||
*/
|
||||
@SuppressLint("StateFlowValueCalledInComposition")
|
||||
@Composable
|
||||
public fun NavHost(
|
||||
navController: NavHostController,
|
||||
graph: NavGraph,
|
||||
modifier: Modifier = Modifier,
|
||||
contentAlignment: Alignment = Alignment.Center,
|
||||
enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
|
||||
{ fadeIn(animationSpec = tween(700)) },
|
||||
exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
|
||||
{ fadeOut(animationSpec = tween(700)) },
|
||||
popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition) =
|
||||
enterTransition,
|
||||
popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition) =
|
||||
exitTransition,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
|
||||
"NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"
|
||||
}
|
||||
|
||||
navController.setViewModelStore(viewModelStoreOwner.viewModelStore)
|
||||
|
||||
// Then set the graph
|
||||
navController.graph = graph
|
||||
|
||||
// Find the ComposeNavigator, returning early if it isn't found
|
||||
// (such as is the case when using TestNavHostController)
|
||||
val composeNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
|
||||
ComposeNavigator.NAME,
|
||||
) as? ComposeNavigator ?: return
|
||||
|
||||
val currentBackStack by composeNavigator.backStack.collectAsState()
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
// Setup the navController with proper owners
|
||||
navController.setLifecycleOwner(lifecycleOwner)
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
|
||||
val allVisibleEntries by navController.visibleEntries.collectAsState()
|
||||
|
||||
// Intercept back only when there's a destination to pop
|
||||
val visibleEntries by remember {
|
||||
derivedStateOf {
|
||||
allVisibleEntries.filter { entry ->
|
||||
entry.destination.navigatorName == ComposeNavigator.NAME
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val backStackEntry: NavBackStackEntry? = visibleEntries.lastOrNull()
|
||||
|
||||
val zIndices = remember { mutableMapOf<String, Float>() }
|
||||
|
||||
if (backStackEntry != null) {
|
||||
val finalEnter: AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition = {
|
||||
val targetDestination = targetState.destination as ComposeNavigator.Destination
|
||||
|
||||
if (composeNavigator.isPop.value) {
|
||||
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
destination.createPopEnterTransition(this)
|
||||
} ?: popEnterTransition.invoke(this)
|
||||
} else {
|
||||
targetDestination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
destination.createEnterTransition(this)
|
||||
} ?: enterTransition.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
val finalExit: AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition = {
|
||||
val initialDestination = initialState.destination as ComposeNavigator.Destination
|
||||
|
||||
if (composeNavigator.isPop.value) {
|
||||
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
destination.createPopExitTransition(this)
|
||||
} ?: popExitTransition.invoke(this)
|
||||
} else {
|
||||
initialDestination.hierarchy.firstNotNullOfOrNull { destination ->
|
||||
destination.createExitTransition(this)
|
||||
} ?: exitTransition.invoke(this)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(true) {
|
||||
onDispose {
|
||||
visibleEntries.forEach { entry ->
|
||||
composeNavigator.onTransitionComplete(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val enablePredictiveBack by Settings.predictiveNavAnim.collectAsState()
|
||||
val transition = if (enablePredictiveBack) {
|
||||
val duration = 500000000L
|
||||
val transitionState = remember { MutableTransitionState(backStackEntry) }
|
||||
val transition = updateTransition(transitionState = transitionState, label = "entry")
|
||||
val animatedFraction = remember { Animatable(0f).also { it.updateBounds(lowerBound = 0f, upperBound = 1f) } }
|
||||
|
||||
fun seekToFraction(lState: NavBackStackEntry, rState: NavBackStackEntry) {
|
||||
val fraction = animatedFraction.value
|
||||
val playTimeNanos = (fraction * duration).roundToLong()
|
||||
transition.setPlaytimeAfterInitialAndTargetStateEstablished(lState, rState, playTimeNanos)
|
||||
}
|
||||
|
||||
LaunchedEffect(backStackEntry) {
|
||||
try {
|
||||
animatedFraction.animateTo(1f, animationSpec = tween(500, easing = LinearEasing)) {
|
||||
seekToFraction(transitionState.currentState, backStackEntry)
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
animatedFraction.snapTo(0f)
|
||||
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
|
||||
backStackEntry,
|
||||
backStackEntry,
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PredictiveBackHandler(currentBackStack.size > 1) { flow ->
|
||||
// Prev can never be null, i.e. BackHandler should not enabled when prev is null
|
||||
val prev = currentBackStack.last { it != backStackEntry }
|
||||
try {
|
||||
flow.collect {
|
||||
val interpolated = EaseOut.transform(it.progress)
|
||||
animatedFraction.snapTo(interpolated)
|
||||
seekToFraction(backStackEntry, prev)
|
||||
}
|
||||
navController.popBackStack()
|
||||
} catch (e: CancellationException) {
|
||||
withContext(NonCancellable) {
|
||||
animatedFraction.snapTo(0f)
|
||||
transition.setPlaytimeAfterInitialAndTargetStateEstablished(
|
||||
backStackEntry,
|
||||
backStackEntry,
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
transition
|
||||
} else {
|
||||
BackHandler(currentBackStack.size > 1) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
updateTransition(backStackEntry, label = "entry")
|
||||
}
|
||||
|
||||
transition.AnimatedContent(
|
||||
modifier,
|
||||
transitionSpec = {
|
||||
// If the initialState of the AnimatedContent is not in visibleEntries, we are in
|
||||
// a case where visible has cleared the old state for some reason, so instead of
|
||||
// attempting to animate away from the initialState, we skip the animation.
|
||||
if (initialState in visibleEntries) {
|
||||
val initialZIndex = zIndices[initialState.id]
|
||||
?: 0f.also { zIndices[initialState.id] = 0f }
|
||||
val targetZIndex = when {
|
||||
targetState.id == initialState.id -> initialZIndex
|
||||
composeNavigator.isPop.value -> initialZIndex - 1f
|
||||
else -> initialZIndex + 1f
|
||||
}.also { zIndices[targetState.id] = it }
|
||||
|
||||
ContentTransform(finalEnter(this), finalExit(this), targetZIndex)
|
||||
} else {
|
||||
EnterTransition.None togetherWith ExitTransition.None
|
||||
}
|
||||
},
|
||||
contentAlignment,
|
||||
contentKey = { it.id },
|
||||
) {
|
||||
// while in the scope of the composable, we provide the navBackStackEntry as the
|
||||
// ViewModelStoreOwner and LifecycleOwner
|
||||
it.LocalOwnersProvider(saveableStateHolder) {
|
||||
(it.destination as ComposeNavigator.Destination)
|
||||
.content(this, it)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(transition.currentState, transition.targetState) {
|
||||
if (transition.currentState == transition.targetState) {
|
||||
visibleEntries.forEach { entry ->
|
||||
composeNavigator.onTransitionComplete(entry)
|
||||
}
|
||||
zIndices
|
||||
.filter { it.key != transition.targetState.id }
|
||||
.forEach { zIndices.remove(it.key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dialogNavigator = navController.navigatorProvider.get<Navigator<out NavDestination>>(
|
||||
DialogNavigator.NAME,
|
||||
) as? DialogNavigator ?: return
|
||||
|
||||
// Show any dialog destinations
|
||||
DialogHost(dialogNavigator)
|
||||
}
|
||||
|
||||
private fun NavDestination.createEnterTransition(
|
||||
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
|
||||
): EnterTransition? = when (this) {
|
||||
is ComposeNavigator.Destination -> this.enterTransition?.invoke(scope)
|
||||
is ComposeNavGraphNavigator.ComposeNavGraph -> this.enterTransition?.invoke(scope)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun NavDestination.createExitTransition(
|
||||
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
|
||||
): ExitTransition? = when (this) {
|
||||
is ComposeNavigator.Destination -> this.exitTransition?.invoke(scope)
|
||||
is ComposeNavGraphNavigator.ComposeNavGraph -> this.exitTransition?.invoke(scope)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun NavDestination.createPopEnterTransition(
|
||||
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
|
||||
): EnterTransition? = when (this) {
|
||||
is ComposeNavigator.Destination -> this.popEnterTransition?.invoke(scope)
|
||||
is ComposeNavGraphNavigator.ComposeNavGraph -> this.popEnterTransition?.invoke(scope)
|
||||
else -> null
|
||||
}
|
||||
private fun NavDestination.createPopExitTransition(
|
||||
scope: AnimatedContentTransitionScope<NavBackStackEntry>,
|
||||
): ExitTransition? = when (this) {
|
||||
is ComposeNavigator.Destination -> this.popExitTransition?.invoke(scope)
|
||||
is ComposeNavGraphNavigator.ComposeNavGraph -> this.popExitTransition?.invoke(scope)
|
||||
else -> null
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:Suppress("ktlint:standard:property-naming", "ktlint:standard:function-naming")
|
||||
|
||||
package androidx.navigation.compose
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.Navigator
|
||||
|
||||
/**
|
||||
* Gets the current navigation back stack entry as a [MutableState]. When the given navController
|
||||
* changes the back stack due to a [NavController.navigate] or [NavController.popBackStack] this
|
||||
* will trigger a recompose and return the top entry on the back stack.
|
||||
*
|
||||
* @return a mutable state of the current back stack entry
|
||||
*/
|
||||
@Composable
|
||||
public fun NavController.currentBackStackEntryAsState(): State<NavBackStackEntry?> {
|
||||
return currentBackStackEntryFlow.collectAsState(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a NavHostController that handles the adding of the [ComposeNavigator] and
|
||||
* [DialogNavigator]. Additional [Navigator] instances can be passed through [navigators] to
|
||||
* be applied to the returned NavController. Note that each [Navigator] must be separately
|
||||
* remembered before being passed in here: any changes to those inputs will cause the
|
||||
* NavController to be recreated.
|
||||
*
|
||||
* @see NavHost
|
||||
*/
|
||||
@Composable
|
||||
public fun rememberNavController(
|
||||
vararg navigators: Navigator<out NavDestination>,
|
||||
): NavHostController {
|
||||
val context = LocalContext.current
|
||||
return rememberSaveable(inputs = navigators, saver = NavControllerSaver(context)) {
|
||||
createNavController(context)
|
||||
}.apply {
|
||||
for (navigator in navigators) {
|
||||
navigatorProvider.addNavigator(navigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNavController(context: Context) =
|
||||
NavHostController(context).apply {
|
||||
navigatorProvider.addNavigator(ComposeNavGraphNavigator(navigatorProvider))
|
||||
navigatorProvider.addNavigator(ComposeNavigator())
|
||||
navigatorProvider.addNavigator(DialogNavigator())
|
||||
}
|
||||
|
||||
/**
|
||||
* Saver to save and restore the NavController across config change and process death.
|
||||
*/
|
||||
private fun NavControllerSaver(
|
||||
context: Context,
|
||||
): Saver<NavHostController, *> = Saver<NavHostController, Bundle>(
|
||||
save = { it.saveState() },
|
||||
restore = { createNavController(context).apply { restoreState(it) } },
|
||||
)
|
||||
+32
-1081
File diff suppressed because it is too large.
Load diff
+27
-9
@@ -20,6 +20,7 @@ import android.app.Application
|
||||
import android.content.ComponentCallbacks2
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import coil.ImageLoaderFactory
|
||||
@@ -47,11 +48,15 @@ import com.hippo.ehviewer.util.Crash
|
||||
import com.hippo.ehviewer.util.FavouriteStatusRouter
|
||||
import com.hippo.ehviewer.util.FileUtils
|
||||
import com.hippo.ehviewer.util.ReadableTime
|
||||
import com.hippo.ehviewer.util.isAtLeastP
|
||||
import com.hippo.ehviewer.util.isAtLeastQ
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import eu.kanade.tachiyomi.network.interceptor.CloudflareInterceptor
|
||||
import eu.kanade.tachiyomi.network.interceptor.UncaughtExceptionInterceptor
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||
import eu.kanade.tachiyomi.util.system.logcat
|
||||
import kotlinx.coroutines.launch
|
||||
import moe.tarsin.kt.unreachable
|
||||
import okhttp3.AsyncDns
|
||||
@@ -65,6 +70,15 @@ private val lifecycleScope = lifecycle.coroutineScope
|
||||
|
||||
class EhApplication : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
// Initialize Settings on first access
|
||||
lifecycleScope.launchIO {
|
||||
val mode = Settings.theme
|
||||
if (!isAtLeastS) {
|
||||
withUIContext {
|
||||
AppCompatDelegate.setDefaultNightMode(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycle.addObserver(lockObserver)
|
||||
val handler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { t, e ->
|
||||
@@ -77,21 +91,17 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
handler?.uncaughtException(t, e)
|
||||
}
|
||||
super.onCreate()
|
||||
System.loadLibrary("ehviewer")
|
||||
if (isAtLeastP) {
|
||||
System.loadLibrary("ehviewer")
|
||||
}
|
||||
System.loadLibrary("ehviewer_rust")
|
||||
ReadableTime.initialize(this)
|
||||
lifecycleScope.launchIO {
|
||||
val theme = Settings.theme
|
||||
withUIContext {
|
||||
AppCompatDelegate.setDefaultNightMode(theme)
|
||||
}
|
||||
}
|
||||
lifecycleScope.launchIO {
|
||||
launchIO {
|
||||
migrateCookies()
|
||||
}
|
||||
launchIO {
|
||||
EhTagDatabase.update()
|
||||
EhTagDatabase
|
||||
}
|
||||
launchIO {
|
||||
EhDB
|
||||
@@ -109,6 +119,11 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
}
|
||||
}
|
||||
cleanObsoleteCache(this)
|
||||
if (BuildConfig.DEBUG) {
|
||||
Snapshot.registerApplyObserver { anies, _ ->
|
||||
logcat { anies.toString() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cleanupDownload() {
|
||||
@@ -150,7 +165,9 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
} else {
|
||||
callFactory(baseOkHttpClient)
|
||||
}
|
||||
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
|
||||
if (isAtLeastP) {
|
||||
add { result, options, _ -> ImageDecoderDecoder(result.source, options, false) }
|
||||
}
|
||||
add(MergeInterceptor)
|
||||
}
|
||||
diskCache(imageCache)
|
||||
@@ -168,6 +185,7 @@ class EhApplication : Application(), ImageLoaderFactory {
|
||||
dns(AsyncDns.toDns(AndroidAsyncDns.IPv4, AndroidAsyncDns.IPv6))
|
||||
}
|
||||
chunker { alwaysReadResponseBody(false) }
|
||||
addInterceptor(UncaughtExceptionInterceptor())
|
||||
addInterceptor(CloudflareInterceptor(appCtx))
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -130,7 +130,7 @@ object EhDB {
|
||||
deleteGalleryInfo(galleryInfo)
|
||||
}
|
||||
|
||||
suspend fun removeLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
suspend fun removeLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
removeLocalFavorites(it)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ object EhDB {
|
||||
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
suspend fun putLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
suspend fun putLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
putLocalFavorites(it)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ object EhDB {
|
||||
db.historyDao().upsert(HistoryInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
suspend fun modifyHistoryInfoFavslotNonRefresh(gid: Long, slot: Int) {
|
||||
suspend fun updateFavoriteSlot(gid: Long, slot: Int) {
|
||||
val dao = db.galleryDao()
|
||||
dao.load(gid)?.let {
|
||||
it.favoriteSlot = slot
|
||||
@@ -292,7 +292,7 @@ object EhDB {
|
||||
}
|
||||
runCatching {
|
||||
val downloadInfoList = oldDB.downloadsDao().joinList().asReversed()
|
||||
DownloadManager.addDownload(downloadInfoList, false)
|
||||
DownloadManager.addDownload(downloadInfoList)
|
||||
}
|
||||
runCatching {
|
||||
val historyInfoList = oldDB.historyDao().list()
|
||||
Executable → Regular
+103
-10
@@ -2,18 +2,95 @@
|
||||
|
||||
package com.hippo.ehviewer
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
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 java.util.Locale
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flatMapMerge
|
||||
import kotlinx.coroutines.flow.map
|
||||
import splitties.preferences.BoolPref
|
||||
import splitties.preferences.DataStorePreferences
|
||||
import splitties.preferences.FloatPref
|
||||
import splitties.preferences.IntPref
|
||||
import splitties.preferences.LongPref
|
||||
import splitties.preferences.PrefDelegate
|
||||
import splitties.preferences.StringOrNullPref
|
||||
import splitties.preferences.StringPref
|
||||
import splitties.preferences.StringSetOrNullPref
|
||||
import splitties.preferences.StringSetPref
|
||||
import splitties.preferences.edit
|
||||
import java.util.Locale
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
|
||||
fun <T> PrefDelegate<T>.getValue(): T = when (this) {
|
||||
is BoolPref -> value
|
||||
is IntPref -> value
|
||||
is FloatPref -> value
|
||||
is LongPref -> value
|
||||
is StringPref -> value
|
||||
is StringOrNullPref -> value
|
||||
is StringSetPref -> value
|
||||
is StringSetOrNullPref -> value
|
||||
} as T
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> PrefDelegate<T>.setValue(newValue: T) = when (this) {
|
||||
is BoolPref -> value = newValue as Boolean
|
||||
is IntPref -> value = newValue as Int
|
||||
is FloatPref -> value = newValue as Float
|
||||
is LongPref -> value = newValue as Long
|
||||
is StringPref -> value = newValue as String
|
||||
is StringOrNullPref -> value = newValue as? String
|
||||
is StringSetPref -> value = newValue as Set<String?>
|
||||
is StringSetOrNullPref -> value = newValue as? Set<String?>
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @DisallowComposableCalls (T) -> R): State<R> {
|
||||
val flow = remember { valueFlow().map { transform(it) } }
|
||||
val init = getValue()
|
||||
return flow.collectAsState(transform(init))
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> PrefDelegate<T>.collectAsState(): State<T> {
|
||||
val flow = remember { valueFlow() }
|
||||
val init = getValue()
|
||||
return flow.collectAsState(init)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
|
||||
val flow = remember { valueFlow() }
|
||||
val init = getValue()
|
||||
val readOnly = flow.collectAsState(init)
|
||||
return remember {
|
||||
object : MutableState<T> {
|
||||
override var value: T
|
||||
get() = readOnly.value
|
||||
set(value) {
|
||||
setValue(value)
|
||||
}
|
||||
override fun component1() = TODO()
|
||||
override fun component2() = TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object Settings : DataStorePreferences(null) {
|
||||
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
|
||||
@@ -24,30 +101,37 @@ object Settings : DataStorePreferences(null) {
|
||||
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
|
||||
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
|
||||
|
||||
val listMode = intPref("list_mode_2", 0)
|
||||
val detailSize = intPref("detail_size_2", 0)
|
||||
val thumbColumns = intPref("thumb_columns", 3)
|
||||
val listThumbSize = intPref("list_tile_size", 40)
|
||||
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
|
||||
val predictiveNavAnim = boolPref("predictive_nav_anim", true)
|
||||
|
||||
val needSignInFlow: Flow<Boolean>
|
||||
var downloadScheme by stringOrNullPref("image_scheme", null)
|
||||
var downloadAuthority by stringOrNullPref("image_authority", null)
|
||||
var downloadPath by stringOrNullPref("image_path", null)
|
||||
var downloadQuery by stringOrNullPref("image_query", null)
|
||||
var downloadFragment by stringOrNullPref("image_fragment", null)
|
||||
var archivePasswds by stringSetOrNullPref("archive_passwds")
|
||||
var downloadDelay by intPref("download_delay_2", 0)
|
||||
var downloadDelay by intPref("download_delay_3", 1000)
|
||||
var gallerySite by intPref("gallery_site_2", 0).observed { updateWhenGallerySiteChanges() }
|
||||
var multiThreadDownload by intPref("download_thread_2", 3)
|
||||
var preloadImage by intPref("preload_image_2", 5)
|
||||
var downloadTimeout by intPref("download_timeout", 60)
|
||||
var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() }
|
||||
var listMode by intPref("list_mode_2", 0)
|
||||
var detailSize by intPref("detail_size_2", 0)
|
||||
var thumbResolution by intPref("thumb_resolution_2", 0)
|
||||
var readCacheSize by intPref("read_cache_size_2", 640)
|
||||
var launchPage by intPref("launch_page_2", 0)
|
||||
var commentThreshold by intPref("comment_threshold", -101)
|
||||
var forceEhThumb by boolPref("force_eh_thumb", false)
|
||||
var showComments by boolPref("show_gallery_comments", true)
|
||||
var requestNews by boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
|
||||
var hideHvEvents by boolPref("hide_hv_events", false)
|
||||
var showJpnTitle by boolPref("show_jpn_title", false)
|
||||
var showGalleryPages by boolPref("show_gallery_pages", false)
|
||||
var showGalleryPages by boolPref("show_gallery_pages", true)
|
||||
var showTagTranslations by boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
|
||||
var meteredNetworkWarning by boolPref("cellular_network_warning", false)
|
||||
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
|
||||
var enabledSecurity by boolPref("enable_secure", false)
|
||||
var useCIUpdateChannel by boolPref("ci_update_channel", false)
|
||||
@@ -58,21 +142,22 @@ object Settings : DataStorePreferences(null) {
|
||||
var saveCrashLog by boolPref("save_crash_log", true)
|
||||
var security by boolPref("require_unlock", false)
|
||||
var removeImageFiles by boolPref("include_pic", true)
|
||||
var needSignIn by boolPref("need_sign_in", true)
|
||||
var needSignIn by boolPref("need_sign_in", true).also { needSignInFlow = it.valueFlow() }
|
||||
var blackDarkTheme by boolPref("black_dark_theme", false).observed { updateWhenAmoledModeChanges() }
|
||||
var harmonizeCategoryColor by boolPref("harmonize_category_color", true)
|
||||
var preloadThumbAggressively by boolPref("preload_thumb_aggressively", false)
|
||||
var downloadOriginImage by boolPref("download_origin_image", false)
|
||||
var thumbSizeDp by intPref("thumb_size_", 120)
|
||||
var enableCronet by boolPref("enable_cronet", true)
|
||||
var recentFavCat by intPref("recent_fav_cat", FavListUrlBuilder.FAV_CAT_LOCAL)
|
||||
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 listThumbSize by intPref("list_tile_size", 40)
|
||||
var searchCategory by intPref("search_pref", EhUtils.ALL_CATEGORY)
|
||||
var requestNewsTimerHour by intPref("request_news_timer_hour", -1)
|
||||
var requestNewsTimerMinute by intPref("request_news_timer_minute", -1)
|
||||
var updateIntervalDays by intPref("update_interval_days", 0)
|
||||
var recentToplist by stringPref("recent_toplist", "11")
|
||||
var userAgent by stringPref("user_agent", CHROME_USER_AGENT)
|
||||
var recentDownloadLabel by stringOrNullPref("recent_download_label", null)
|
||||
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
|
||||
var displayName by stringOrNullPref("display_name", null)
|
||||
@@ -80,6 +165,10 @@ object Settings : DataStorePreferences(null) {
|
||||
var language by stringPref("app_language", "system").observed { updateWhenLocaleChanges() }
|
||||
var lastDawnDay by longPref("last_dawn_day", 0)
|
||||
var lastUpdateDay by longPref("last_update_day", 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)
|
||||
|
||||
init {
|
||||
if ("CN" == Locale.getDefault().country) {
|
||||
@@ -97,6 +186,8 @@ object Settings : DataStorePreferences(null) {
|
||||
|
||||
private fun intArrayPref(key: String, count: Int) = object : Delegate<IntArray> {
|
||||
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
|
||||
|
||||
@Suppress("ktlint:standard:property-naming")
|
||||
private var _value = (0 until count).map { intPref("${key}_$it", 0) }.toTypedArray()
|
||||
override fun getValue(thisRef: Any?, prop: KProperty<*>?): IntArray = _value.map { it.value }.toIntArray()
|
||||
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: IntArray) {
|
||||
@@ -107,6 +198,8 @@ object Settings : DataStorePreferences(null) {
|
||||
|
||||
private fun stringArrayPref(key: String, count: Int, defMetaValue: String) = object : Delegate<Array<String>> {
|
||||
override val flowGetter: () -> Flow<Unit> = { _value.asFlow().flatMapMerge { it.changesFlow() }.conflate() }
|
||||
|
||||
@Suppress("ktlint:standard:property-naming")
|
||||
private var _value = (0 until count).map { stringPref("${key}_$it", "$defMetaValue $it") }.toTypedArray()
|
||||
override fun getValue(thisRef: Any?, prop: KProperty<*>?): Array<String> = _value.map { it.value }.toTypedArray()
|
||||
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array<String>) {
|
||||
+12
-1
@@ -1,5 +1,6 @@
|
||||
package com.hippo.ehviewer
|
||||
|
||||
import android.app.UiModeManager
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
|
||||
import androidx.core.os.LocaleListCompat
|
||||
@@ -8,6 +9,7 @@ import com.hippo.ehviewer.client.EhEngine
|
||||
import com.hippo.ehviewer.client.EhTagDatabase
|
||||
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
|
||||
import com.hippo.ehviewer.ui.keepNoMediaFileStatus
|
||||
import com.hippo.ehviewer.util.isAtLeastS
|
||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import splitties.init.appCtx
|
||||
import splitties.preferences.PrefDelegate
|
||||
import splitties.systemservices.uiModeManager
|
||||
|
||||
private val collectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
fun <T, R : PrefDelegate<T>> R.observed(func: (Unit) -> Unit) = apply { collectScope.launch { changesFlow().collect(func) } }
|
||||
@@ -44,6 +47,14 @@ fun updateWhenKeepMediaStatusChanges() {
|
||||
fun updateWhenThemeChanges() {
|
||||
collectScope.launch {
|
||||
delay(100) // Avoid recompose being cancelled
|
||||
if (isAtLeastS) {
|
||||
val mode = when (Settings.theme) {
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> UiModeManager.MODE_NIGHT_NO
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> UiModeManager.MODE_NIGHT_YES
|
||||
else -> UiModeManager.MODE_NIGHT_AUTO
|
||||
}
|
||||
uiModeManager.setApplicationNightMode(mode)
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +62,7 @@ fun updateWhenThemeChanges() {
|
||||
fun updateWhenAmoledModeChanges() {
|
||||
collectScope.launch {
|
||||
delay(100) // Avoid recompose being cancelled
|
||||
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY)
|
||||
AppCompatDelegate.setDefaultNightMode(MODE_NIGHT_AUTO_BATTERY) // Trigger onNightModeChanged
|
||||
AppCompatDelegate.setDefaultNightMode(Settings.theme)
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+25
-21
@@ -16,6 +16,11 @@
|
||||
package com.hippo.ehviewer.client
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import androidx.compose.ui.util.fastAny
|
||||
import androidx.compose.ui.util.fastFilter
|
||||
import androidx.compose.ui.util.fastForEach
|
||||
import androidx.compose.ui.util.fastJoinToString
|
||||
import androidx.compose.ui.util.fastMapNotNull
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
@@ -24,13 +29,15 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
object EhCookieStore : CookieJar {
|
||||
private val manager = CookieManager.getInstance()
|
||||
fun signOut() = manager.removeAllCookies(null)
|
||||
fun contains(url: HttpUrl, name: String) = get(url).any { it.name == name }
|
||||
fun contains(url: HttpUrl, name: String) = get(url).fastAny { it.name == name }
|
||||
|
||||
fun get(url: HttpUrl): List<Cookie> {
|
||||
val cookies = manager.getCookie(url.toString())
|
||||
|
||||
return if (cookies != null && cookies.isNotEmpty()) {
|
||||
cookies.split(";").mapNotNull { Cookie.parse(url, it) }.filterNot { it.name == KEY_UTMP_NAME }
|
||||
return if (!cookies.isNullOrEmpty()) {
|
||||
cookies.split(";").fastMapNotNull { setCookie ->
|
||||
Cookie.parse(url, setCookie)?.takeUnless { it.name == KEY_UTMP_NAME }
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
@@ -57,9 +64,12 @@ object EhCookieStore : CookieJar {
|
||||
}.build()
|
||||
|
||||
fun copyNecessaryCookies() {
|
||||
val cookie = get(EhUrl.HOST_E.toHttpUrl()).filter { it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH || it.name == KEY_IGNEOUS }
|
||||
cookie.forEach { manager.setCookie(EhUrl.HOST_EX, it.toString()) }
|
||||
flush()
|
||||
val cookies = get(EhUrl.HOST_E.toHttpUrl())
|
||||
cookies.fastForEach {
|
||||
if (it.name == KEY_STAR || it.name == KEY_IPB_MEMBER_ID || it.name == KEY_IPB_PASS_HASH) {
|
||||
manager.setCookie(EhUrl.HOST_EX, it.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCookie(url: HttpUrl, name: String) {
|
||||
@@ -67,35 +77,29 @@ object EhCookieStore : CookieJar {
|
||||
}
|
||||
|
||||
fun addCookie(cookie: Cookie) {
|
||||
if (EhUrl.DOMAIN_E in cookie.domain) manager.setCookie(EhUrl.HOST_E, cookie.toString()) else manager.setCookie(EhUrl.DOMAIN_EX, cookie.toString())
|
||||
manager.setCookie(if (EhUrl.DOMAIN_E == cookie.domain) EhUrl.HOST_E else EhUrl.HOST_EX, cookie.toString())
|
||||
}
|
||||
|
||||
fun flush() = manager.flush()
|
||||
|
||||
fun getCookieHeader(url: HttpUrl): String {
|
||||
val cookies = loadForRequest(url)
|
||||
val cookieHeader = StringBuilder()
|
||||
var i = 0
|
||||
val size = cookies.size
|
||||
while (i < size) {
|
||||
if (i > 0) {
|
||||
cookieHeader.append("; ")
|
||||
}
|
||||
val cookie = cookies[i]
|
||||
cookieHeader.append(cookie.name).append('=').append(cookie.value)
|
||||
i++
|
||||
return loadForRequest(url).fastJoinToString("; ") {
|
||||
"${it.name}=${it.value}"
|
||||
}
|
||||
return cookieHeader.toString()
|
||||
}
|
||||
|
||||
// See https://github.com/Ehviewer-Overhauled/Ehviewer/issues/873
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.filterNot { it.name == KEY_UTMP_NAME }.forEach { manager.setCookie(url.toString(), it.toString()) }.also { flush() }
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = cookies.fastForEach {
|
||||
if (it.name != KEY_UTMP_NAME) {
|
||||
manager.setCookie(url.toString(), it.toString())
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||
val checkTips = EhUrl.DOMAIN_E in url.host
|
||||
return get(url).run {
|
||||
if (checkTips) {
|
||||
filterNot { it.name == KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
|
||||
fastFilter { it.name != KEY_CONTENT_WARNING }.toMutableList().apply { add(sTipsCookie) }
|
||||
} else {
|
||||
this
|
||||
}
|
||||
+110
-99
@@ -21,6 +21,7 @@ import arrow.fx.coroutines.parZip
|
||||
import com.hippo.ehviewer.R
|
||||
import com.hippo.ehviewer.Settings
|
||||
import com.hippo.ehviewer.client.data.BaseGalleryInfo
|
||||
import com.hippo.ehviewer.client.data.FavListUrlBuilder
|
||||
import com.hippo.ehviewer.client.data.GalleryInfo
|
||||
import com.hippo.ehviewer.client.exception.EhException
|
||||
import com.hippo.ehviewer.client.exception.InsufficientFundsException
|
||||
@@ -34,7 +35,6 @@ import com.hippo.ehviewer.client.parser.GalleryApiParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryDetailParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryListParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryNotAvailableParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageApiParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryPageParser
|
||||
import com.hippo.ehviewer.client.parser.GalleryTokenApiParser
|
||||
import com.hippo.ehviewer.client.parser.HomeParser
|
||||
@@ -48,57 +48,50 @@ import com.hippo.ehviewer.client.parser.VoteTagParser
|
||||
import com.hippo.ehviewer.cronet.awaitBodyFully
|
||||
import com.hippo.ehviewer.cronet.cronetRequest
|
||||
import com.hippo.ehviewer.cronet.execute
|
||||
import com.hippo.ehviewer.cronet.withRequestBody
|
||||
import com.hippo.ehviewer.dailycheck.showEventNotification
|
||||
import com.hippo.ehviewer.dailycheck.today
|
||||
import com.hippo.ehviewer.util.AppConfig
|
||||
import com.hippo.ehviewer.util.StatusCodeException
|
||||
import com.hippo.ehviewer.util.isCronetSupported
|
||||
import io.ktor.utils.io.pool.DirectByteBufferPool
|
||||
import io.ktor.utils.io.pool.useInstance
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.ceil
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonArray
|
||||
import kotlinx.serialization.json.put
|
||||
import moe.tarsin.coroutines.removeAllSuspend
|
||||
import moe.tarsin.coroutines.runSuspendCatching
|
||||
import okhttp3.Headers
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import org.jsoup.Jsoup
|
||||
import splitties.init.appCtx
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.ceil
|
||||
|
||||
private const val TAG = "EhEngine"
|
||||
private const val MAX_REQUEST_SIZE = 25
|
||||
private const val SAD_PANDA_DISPOSITION = "inline; filename=\"sadpanda.jpg\""
|
||||
private const val SAD_PANDA_TYPE = "image/gif"
|
||||
private const val SAD_PANDA_LENGTH = "9615"
|
||||
private const val KOKOMADE_URL = "https://exhentai.org/img/kokomade.jpg"
|
||||
private const val U_CONFIG_TEXT = "Selected Profile"
|
||||
private val MEDIA_TYPE_JPEG: MediaType = "image/jpeg".toMediaType()
|
||||
|
||||
private fun rethrowExactly(code: Int, headers: Headers, body: String, e: Throwable): Nothing {
|
||||
private fun rethrowExactly(code: Int, body: String, e: Throwable): Nothing {
|
||||
// Don't translate coroutine cancellation
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
// Check sad panda
|
||||
if (SAD_PANDA_DISPOSITION == headers["Content-Disposition"] && SAD_PANDA_TYPE == headers["Content-Type"] && SAD_PANDA_LENGTH == headers["Content-Length"]) {
|
||||
throw EhException("Sad Panda")
|
||||
}
|
||||
|
||||
// Check sad panda(without panda)
|
||||
if ("text/html; charset=UTF-8" == headers["Content-Type"] && "0" == headers["Content-Length"] && EhUtils.isExHentai) {
|
||||
throw EhException("Sad Panda\n(without panda)")
|
||||
}
|
||||
|
||||
// Check kokomade
|
||||
if (body.contains(KOKOMADE_URL)) {
|
||||
throw EhException("今回はここまで ${appCtx.getString(R.string.kokomade_tip)}".trimIndent())
|
||||
if (body.isEmpty()) {
|
||||
if (EhUtils.isExHentai) {
|
||||
throw EhException("Sad Panda\n(without panda)")
|
||||
} else {
|
||||
throw EhException("IP banned")
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gallery Not Available
|
||||
@@ -114,10 +107,8 @@ private fun rethrowExactly(code: Int, headers: Headers, body: String, e: Throwab
|
||||
throw StatusCodeException(code)
|
||||
}
|
||||
|
||||
if (e is ParseException) {
|
||||
if (body.isEmpty()) {
|
||||
throw EhException(appCtx.getString(R.string.error_empty_html))
|
||||
} else if ("<" !in body) {
|
||||
if (e is ParseException || e is SerializationException) {
|
||||
if ("<" !in body) {
|
||||
throw EhException(body)
|
||||
} else {
|
||||
if (Settings.saveParseErrorBody) AppConfig.saveParseErrorBody(e, body)
|
||||
@@ -129,33 +120,53 @@ private fun rethrowExactly(code: Int, headers: Headers, body: String, e: Throwab
|
||||
throw e
|
||||
}
|
||||
|
||||
val httpContentPool = DirectByteBufferPool(16, 16384)
|
||||
val httpContentPool = DirectByteBufferPool(8, 0x80000)
|
||||
|
||||
suspend inline fun <T> fetchCompat(url: String, referer: String? = null, crossinline parser: (ByteBuffer) -> T): T {
|
||||
suspend fun <T> fetchCompat(
|
||||
url: String,
|
||||
referer: String? = null,
|
||||
origin: String? = null,
|
||||
reqBody: RequestBody? = null,
|
||||
parser: suspend (ByteBuffer) -> T,
|
||||
): T {
|
||||
Log.d(TAG, url)
|
||||
fun commonChecks(body: ByteBuffer) {
|
||||
// Check sad panda(without panda)
|
||||
if (!body.hasRemaining()) {
|
||||
if (EhUtils.isExHentai) {
|
||||
throw EhException("Sad Panda\n(without panda)")
|
||||
} else {
|
||||
throw EhException("IP banned")
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (isCronetSupported) {
|
||||
cronetRequest(url, referer).execute {
|
||||
val buffer = httpContentPool.borrow()
|
||||
try {
|
||||
awaitBodyFully {
|
||||
buffer.put(it)
|
||||
}
|
||||
cronetRequest(url, referer, origin) {
|
||||
reqBody?.let { withRequestBody(it) }
|
||||
}.execute {
|
||||
@Suppress("NewApi")
|
||||
val code = it.httpStatusCode
|
||||
if (code >= 400) {
|
||||
throw StatusCodeException(code)
|
||||
}
|
||||
httpContentPool.useInstance { buffer ->
|
||||
awaitBodyFully(buffer)
|
||||
buffer.flip()
|
||||
commonChecks(buffer)
|
||||
parser(buffer)
|
||||
} finally {
|
||||
httpContentPool.recycle(buffer)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ehRequest(url, referer).execute {
|
||||
val buffer = httpContentPool.borrow()
|
||||
try {
|
||||
ehRequest(url, referer, origin) {
|
||||
reqBody?.let { post(it) }
|
||||
}.execute {
|
||||
httpContentPool.useInstance { buffer ->
|
||||
while (true) {
|
||||
if (body.source().read(buffer) == -1) break
|
||||
}
|
||||
buffer.flip()
|
||||
commonChecks(buffer)
|
||||
parser(buffer)
|
||||
} finally {
|
||||
httpContentPool.recycle(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +180,7 @@ private suspend inline fun <T> Request.executeAndParsingWith(block: suspend Stri
|
||||
try {
|
||||
block(body)
|
||||
} catch (e: Exception) {
|
||||
rethrowExactly(code, headers, body, e)
|
||||
rethrowExactly(code, body, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +199,7 @@ object EhEngine {
|
||||
|
||||
suspend fun getTorrentList(url: String, gid: Long, token: String?): TorrentResult {
|
||||
val referer = EhUrl.getGalleryDetailUrl(gid, token)
|
||||
return fetchCompat(url, referer, TorrentParser::parse)
|
||||
return fetchCompat(url, referer, parser = TorrentParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getArchiveList(url: String, gid: Long, token: String?) = ehRequest(url, EhUrl.getGalleryDetailUrl(gid, token))
|
||||
@@ -232,9 +243,9 @@ object EhEngine {
|
||||
return ehRequest(url, referer).executeAndParsingWith(GalleryPageParser::parse)
|
||||
}
|
||||
|
||||
suspend fun getGalleryList(url: String) = ehRequest(url, EhUrl.referer)
|
||||
.executeAndParsingWith(GalleryListParser::parse)
|
||||
suspend fun getGalleryList(url: String) = fetchCompat(url, EhUrl.referer, parser = GalleryListParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url, true) }
|
||||
.takeUnless { it.galleryInfoList.isEmpty() } ?: GalleryListParser.emptyResult
|
||||
|
||||
suspend fun getGalleryDetail(url: String) = ehRequest(url, EhUrl.referer).executeAndParsingWith {
|
||||
EventPaneParser.parse(this)?.let {
|
||||
@@ -248,9 +259,8 @@ object EhEngine {
|
||||
GalleryDetailParser.parsePreviewList(this) to GalleryDetailParser.parsePreviewPages(this)
|
||||
}
|
||||
|
||||
suspend fun getFavorites(url: String) = ehRequest(url, EhUrl.referer)
|
||||
.executeAndParsingWith(FavoritesParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url, false) }
|
||||
suspend fun getFavorites(url: String) = fetchCompat(url, EhUrl.referer, parser = FavoritesParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url) }
|
||||
|
||||
suspend fun signIn(username: String, password: String): String {
|
||||
val referer = "https://forums.e-hentai.org/index.php?act=Login&CODE=00"
|
||||
@@ -268,13 +278,13 @@ object EhEngine {
|
||||
}.executeAndParsingWith(SignInParser::parse)
|
||||
}
|
||||
|
||||
suspend fun commentGallery(url: String, comment: String, id: String?) = ehRequest(url, url, EhUrl.origin) {
|
||||
suspend fun commentGallery(url: String, comment: String, id: Long = -1) = ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
if (id == null) {
|
||||
if (id == -1L) {
|
||||
add("commenttext_new", comment)
|
||||
} else {
|
||||
add("commenttext_edit", comment)
|
||||
add("edit_comment", id)
|
||||
add("edit_comment", id.toString())
|
||||
}
|
||||
}
|
||||
}.executeAndParsingWith {
|
||||
@@ -286,31 +296,21 @@ object EhEngine {
|
||||
GalleryDetailParser.parseComments(document)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param dstCat -1 for delete, 0 - 9 for cloud favorite, others throw Exception
|
||||
* @param note max 250 characters
|
||||
*/
|
||||
suspend fun modifyFavorites(
|
||||
gid: Long,
|
||||
token: String?,
|
||||
dstCat: Int = -1,
|
||||
note: String = "",
|
||||
) {
|
||||
suspend fun modifyFavorites(gid: Long, token: String?, dstCat: Int = -1, note: String = "") {
|
||||
val catStr: String = when (dstCat) {
|
||||
-1 -> "favdel"
|
||||
in 0..9 -> dstCat.toString()
|
||||
else -> throw EhException("Invalid dstCat: $dstCat")
|
||||
}
|
||||
val url = EhUrl.getAddFavorites(gid, token)
|
||||
return ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
add("favcat", catStr)
|
||||
add("favnote", note)
|
||||
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
|
||||
add("apply", "Apply Changes")
|
||||
add("update", "1")
|
||||
}
|
||||
}.executeAndParsingWith { }
|
||||
val body = formBody {
|
||||
add("favcat", catStr)
|
||||
add("favnote", note)
|
||||
// apply=Add+to+Favorites is not necessary, just use apply=Apply+Changes all the time
|
||||
add("apply", "Apply Changes")
|
||||
add("update", "1")
|
||||
}
|
||||
fetchCompat(url, url, EhUrl.origin, body) { }
|
||||
}
|
||||
|
||||
suspend fun downloadArchive(
|
||||
@@ -363,18 +363,24 @@ object EhEngine {
|
||||
}
|
||||
}.executeAndParsingWith(HomeParser::parseResetLimits)
|
||||
|
||||
suspend fun modifyFavorites(url: String, gidArray: LongArray, dstCat: Int): FavoritesParser.Result {
|
||||
suspend fun modifyFavorites(gidArray: LongArray, srcCat: Int, dstCat: Int): FavoritesParser.Result {
|
||||
val url = ehUrl {
|
||||
addPathSegments(EhUrl.FAV_PATH)
|
||||
if (FavListUrlBuilder.isValidFavCat(srcCat)) {
|
||||
addQueryParameter("favcat", srcCat.toString())
|
||||
}
|
||||
}.toString()
|
||||
val catStr: String = when (dstCat) {
|
||||
-1 -> "delete"
|
||||
in 0..9 -> "fav$dstCat"
|
||||
else -> throw EhException("Invalid dstCat: $dstCat")
|
||||
}
|
||||
return ehRequest(url, url, EhUrl.origin) {
|
||||
formBody {
|
||||
add("ddact", catStr)
|
||||
gidArray.forEach { add("modifygids[]", it.toString()) }
|
||||
}
|
||||
}.executeAndParsingWith(FavoritesParser::parse).apply { fillGalleryList(galleryInfoList, url, false) }
|
||||
val body = formBody {
|
||||
add("ddact", catStr)
|
||||
gidArray.forEach { add("modifygids[]", it.toString()) }
|
||||
}
|
||||
return fetchCompat(url, url, EhUrl.origin, body, FavoritesParser::parse)
|
||||
.apply { fillGalleryList(galleryInfoList, url) }
|
||||
}
|
||||
|
||||
suspend fun getGalleryPageApi(
|
||||
@@ -383,7 +389,7 @@ object EhEngine {
|
||||
pToken: String,
|
||||
showKey: String?,
|
||||
previousPToken: String?,
|
||||
): GalleryPageApiParser.Result {
|
||||
): GalleryPageParser.Result {
|
||||
val referer = if (index > 0 && previousPToken != null) EhUrl.getPageUrl(gid, index - 1, previousPToken) else null
|
||||
return ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
@@ -393,7 +399,7 @@ object EhEngine {
|
||||
put("imgkey", pToken)
|
||||
put("showkey", showKey)
|
||||
}
|
||||
}.executeAndParsingWith(GalleryPageApiParser::parse)
|
||||
}.executeAndParsingWith { GalleryPageParser.parse(filterNot { it == '\\' }) }
|
||||
}
|
||||
|
||||
suspend fun rateGallery(
|
||||
@@ -413,22 +419,23 @@ object EhEngine {
|
||||
}
|
||||
}.executeAndParsingWith(String::parseAs)
|
||||
|
||||
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) = galleryInfoList.chunked(MAX_REQUEST_SIZE).parMap {
|
||||
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "gdata")
|
||||
array("gidlist") {
|
||||
it.forEach {
|
||||
addJsonArray {
|
||||
add(it.gid)
|
||||
add(it.token)
|
||||
suspend fun fillGalleryListByApi(galleryInfoList: List<GalleryInfo>, referer: String) =
|
||||
galleryInfoList.chunked(MAX_REQUEST_SIZE).parMap(concurrency = Settings.multiThreadDownload) {
|
||||
ehRequest(EhUrl.apiUrl, referer, EhUrl.origin) {
|
||||
jsonBody {
|
||||
put("method", "gdata")
|
||||
array("gidlist") {
|
||||
it.forEach {
|
||||
addJsonArray {
|
||||
add(it.gid)
|
||||
add(it.token)
|
||||
}
|
||||
}
|
||||
}
|
||||
put("namespace", 1)
|
||||
}
|
||||
put("namespace", 1)
|
||||
}
|
||||
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
|
||||
}
|
||||
}.executeAndParsingWith { GalleryApiParser.parse(this, it) }
|
||||
}
|
||||
|
||||
suspend fun voteComment(
|
||||
apiUid: Long,
|
||||
@@ -484,17 +491,21 @@ object EhEngine {
|
||||
/**
|
||||
* @param image Must be jpeg
|
||||
*/
|
||||
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = ehRequest(EhUrl.imageSearchUrl, EhUrl.referer, EhUrl.origin) {
|
||||
suspend fun imageSearch(image: File, uss: Boolean, osc: Boolean) = fetchCompat(
|
||||
EhUrl.imageSearchUrl,
|
||||
EhUrl.referer,
|
||||
EhUrl.origin,
|
||||
multipartBody {
|
||||
setType(MultipartBody.FORM)
|
||||
addFormDataPart("sfile", "a.jpg", image.asRequestBody(MEDIA_TYPE_JPEG))
|
||||
if (uss) addFormDataPart("fs_similar", "on")
|
||||
if (osc) addFormDataPart("fs_covers", "on")
|
||||
addFormDataPart("f_sfile", "File Search")
|
||||
}
|
||||
}.executeAndParsingWith(GalleryListParser::parse).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl, true) }
|
||||
},
|
||||
parser = GalleryListParser::parse,
|
||||
).apply { fillGalleryList(galleryInfoList, EhUrl.imageSearchUrl) }
|
||||
|
||||
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean) {
|
||||
private suspend fun fillGalleryList(list: MutableList<BaseGalleryInfo>, url: String, filter: Boolean = false) {
|
||||
// Filter title and uploader
|
||||
if (filter) list.removeAllSuspend { EhFilter.filterTitle(it) || EhFilter.filterUploader(it) }
|
||||
|
||||
@@ -502,7 +513,7 @@ object EhEngine {
|
||||
var hasPages = false
|
||||
var hasRated = false
|
||||
for (gi in list) {
|
||||
if (gi.simpleTags != null) {
|
||||
if (!gi.simpleTags.isNullOrEmpty()) {
|
||||
hasTags = true
|
||||
}
|
||||
if (gi.pages != 0) {
|
||||
@@ -519,7 +530,7 @@ object EhEngine {
|
||||
if (filter) list.removeAllSuspend { EhFilter.filterUploader(it) || EhFilter.filterTag(it) || EhFilter.filterTagNamespace(it) }
|
||||
}
|
||||
|
||||
suspend fun modifyFavoritesRange(galleryList: List<Pair<Long, String>>, dstCat: Int) {
|
||||
galleryList.forEach { (gid, token) -> modifyFavorites(gid, token, dstCat) }
|
||||
suspend fun addFavorites(galleryList: List<Pair<Long, String>>, dstCat: Int) {
|
||||
galleryList.parMap(concurrency = Settings.multiThreadDownload) { (gid, token) -> modifyFavorites(gid, token, dstCat) }
|
||||
}
|
||||
}
|
||||
Loaded 100 of 526 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user